diff --git a/client/src/main/java/io/lionweb/client/delta/DeltaChannel.java b/client/src/main/java/io/lionweb/client/delta/DeltaChannel.java new file mode 100644 index 000000000..4e58158d5 --- /dev/null +++ b/client/src/main/java/io/lionweb/client/delta/DeltaChannel.java @@ -0,0 +1,45 @@ +package io.lionweb.client.delta; + +import io.lionweb.client.delta.messages.*; +import java.util.function.Function; + +/** + * The DeltaChannel must be a specific link between a Client and the Server. Different clients + * should use different DeltaChannels because the clientId must be determined from the channel. + */ +public interface DeltaChannel { + /** + * Queries initiated/requested by the client, with synchronous response by the repository. A query + * requests some information from the repository without changing the repository’s contents. The + * repository gathers all information needed to answer the query, and sends the information back. + * The repository might reply invalid queries with a failure message. We also use queries for + * managing participations. + */ + DeltaQueryResponse sendQuery(Function queryProducer); + + /** + * Commands initiated/requested by the client, with synchronous response by the repository. A + * command requests some change to the repository. The repository quickly confirms having received + * the command, or rejects a failed command.[5] However, the repository processes the command + * asynchronously, and eventually broadcasts the effect(s) as event. + */ + void sendCommand(String participationId, Function commandProducer); + + void sendEvent(Function eventProducer); + + void registerEventReceiver(DeltaEventReceiver deltaEventReceiver); + + void unregisterEventReceiver(DeltaEventReceiver deltaEventReceiver); + + void registerCommandReceiver(DeltaCommandReceiver deltaCommandReceiver); + + void unregisterCommandReceiver(DeltaCommandReceiver deltaCommandReceiver); + + void registerQueryReceiver(DeltaQueryReceiver deltaQueryReceiver); + + void unregisterQueryReceiver(DeltaQueryReceiver deltaQueryReceiver); + + void registerQueryResponseReceiver(DeltaQueryResponseReceiver deltaQueryResponseReceiver); + + void unregisterQueryResponseReceiver(DeltaQueryResponseReceiver deltaQueryResponseReceiver); +} diff --git a/client/src/main/java/io/lionweb/client/delta/DeltaClient.java b/client/src/main/java/io/lionweb/client/delta/DeltaClient.java new file mode 100644 index 000000000..fe1ba9214 --- /dev/null +++ b/client/src/main/java/io/lionweb/client/delta/DeltaClient.java @@ -0,0 +1,321 @@ +package io.lionweb.client.delta; + +import io.lionweb.LionWebVersion; +import io.lionweb.client.delta.messages.BaseDeltaEvent; +import io.lionweb.client.delta.messages.DeltaEvent; +import io.lionweb.client.delta.messages.DeltaQueryResponse; +import io.lionweb.client.delta.messages.commands.children.AddChild; +import io.lionweb.client.delta.messages.commands.children.DeleteChild; +import io.lionweb.client.delta.messages.commands.properties.ChangeProperty; +import io.lionweb.client.delta.messages.commands.references.AddReference; +import io.lionweb.client.delta.messages.events.ErrorEvent; +import io.lionweb.client.delta.messages.events.children.ChildAdded; +import io.lionweb.client.delta.messages.events.children.ChildDeleted; +import io.lionweb.client.delta.messages.events.properties.PropertyChanged; +import io.lionweb.client.delta.messages.events.references.ReferenceAdded; +import io.lionweb.client.delta.messages.queries.partitcipations.SignOnRequest; +import io.lionweb.client.delta.messages.queries.partitcipations.SignOnResponse; +import io.lionweb.language.Containment; +import io.lionweb.language.Property; +import io.lionweb.language.Reference; +import io.lionweb.model.*; +import io.lionweb.model.impl.ProxyNode; +import io.lionweb.serialization.*; +import io.lionweb.serialization.data.MetaPointer; +import io.lionweb.serialization.data.SerializationChunk; +import java.lang.ref.WeakReference; +import java.util.*; +import org.jetbrains.annotations.NotNull; + +public class DeltaClient implements DeltaEventReceiver, DeltaQueryResponseReceiver { + private LionWebVersion lionWebVersion; + private DeltaChannel channel; + private MonitoringObserver observer = new MonitoringObserver(); + private String participationId; + private HashMap>>> nodes = new HashMap<>(); + private PrimitiveValuesSerialization primitiveValuesSerialization = + new PrimitiveValuesSerialization(); + private AbstractSerialization serialization; + private Set queriesSent = new HashSet<>(); + private String clientId; + + public DeltaClient(DeltaChannel channel, String clientId) { + this(LionWebVersion.currentVersion, channel, clientId); + } + + public DeltaClient(LionWebVersion lionWebVersion, DeltaChannel channel, String clientId) { + this.clientId = clientId; + this.lionWebVersion = lionWebVersion; + this.channel = channel; + this.channel.registerEventReceiver(this); + this.channel.registerQueryResponseReceiver(this); + this.primitiveValuesSerialization.registerLionBuiltinsPrimitiveSerializersAndDeserializers( + lionWebVersion); + this.serialization = SerializationProvider.getStandardJsonSerialization(lionWebVersion); + this.serialization.setUnavailableParentPolicy(UnavailableNodePolicy.PROXY_NODES); + this.serialization.setUnavailableReferenceTargetPolicy(UnavailableNodePolicy.PROXY_NODES); + } + + /** + * It is responsibility of the caller to ensure that the partition is initially in sync with the + * server. + */ + public void monitor(@NotNull Node partition) { + Objects.requireNonNull(partition, "partition should not be null"); + synchronized (partition) { + partition + .thisAndAllDescendants() + .forEach( + n -> + nodes + .computeIfAbsent(n.getID(), id -> new HashSet<>()) + .add(new WeakReference<>(n))); + partition.registerPartitionObserver(observer); + } + } + + protected void monitorNode(Node node) { + nodes.computeIfAbsent(node.getID(), id -> new HashSet<>()).add(new WeakReference<>(node)); + } + + @Override + public void receiveEvent(DeltaEvent event) { + if (event instanceof BaseDeltaEvent) { + BaseDeltaEvent baseDeltaEvent = (BaseDeltaEvent) event; + + if (baseDeltaEvent.originCommands.stream() + .anyMatch( + (CommandSource originCommand) -> + originCommand.participationId.equals(this.participationId))) { + return; + } + } + observer.paused = true; + if (event instanceof PropertyChanged) { + PropertyChanged propertyChanged = (PropertyChanged) event; + Set>> matchingNodes = + this.nodes.get(propertyChanged.node); + if (matchingNodes != null) { + for (WeakReference> classifierInstanceRef : matchingNodes) { + ClassifierInstance classifierInstance = classifierInstanceRef.get(); + if (classifierInstance != null) { + ClassifierInstanceUtils.setPropertyValueByMetaPointer( + classifierInstance, propertyChanged.property, propertyChanged.newValue); + } + } + } + } else if (event instanceof ChildAdded) { + ChildAdded childAdded = (ChildAdded) event; + for (WeakReference> classifierInstanceRef : + nodes.get(childAdded.parent)) { + ClassifierInstance classifierInstance = classifierInstanceRef.get(); + if (classifierInstance != null) { + Node child = + (Node) serialization.deserializeSerializationChunk(childAdded.newChild).get(0); + monitorNode(child); + Containment containment = + classifierInstance + .getClassifier() + .getContainmentByMetaPointer(childAdded.containment); + if (containment == null) { + throw new IllegalStateException( + "Containment not found for " + + classifierInstance + + " using metapointer " + + childAdded.containment); + } + classifierInstance.addChild(containment, child, childAdded.index); + } + } + } else if (event instanceof ChildDeleted) { + ChildDeleted childDeleted = (ChildDeleted) event; + for (WeakReference> classifierInstanceRef : + nodes.get(childDeleted.parent)) { + ClassifierInstance classifierInstance = classifierInstanceRef.get(); + if (classifierInstance != null) { + Containment containment = + classifierInstance + .getClassifier() + .getContainmentByMetaPointer(childDeleted.containment); + if (containment == null) { + throw new IllegalStateException( + "Containment not found for " + + classifierInstance + + " using metapointer " + + childDeleted.containment); + } + classifierInstance.removeChild(containment, childDeleted.index); + } + } + } else if (event instanceof ErrorEvent) { + ErrorEvent errorEvent = (ErrorEvent) event; + observer.paused = false; + throw new ErrorEventReceivedException(errorEvent.errorCode, errorEvent.message); + } else if (event instanceof ReferenceAdded) { + ReferenceAdded referenceAdded = (ReferenceAdded) event; + for (WeakReference> classifierInstanceRef : + nodes.get(referenceAdded.parent)) { + ClassifierInstance classifierInstance = classifierInstanceRef.get(); + if (classifierInstance != null) { + Reference reference = + classifierInstance + .getClassifier() + .getReferenceByMetaPointer(referenceAdded.reference); + if (reference == null) { + throw new IllegalStateException( + "Reference not found for " + + classifierInstance + + " using metapointer " + + referenceAdded.reference); + } + classifierInstance.addReferenceValue( + reference, + referenceAdded.index, + new ReferenceValue( + new ProxyNode(referenceAdded.newTarget), referenceAdded.newResolveInfo)); + } + } + } else { + observer.paused = false; + throw new UnsupportedOperationException( + "Unsupported event type: " + event.getClass().getName()); + } + observer.paused = false; + } + + private class MonitoringObserver implements PartitionObserver { + + public boolean paused = false; + + @Override + public void propertyChanged( + ClassifierInstance classifierInstance, + Property property, + Object oldValue, + Object newValue) { + if (paused) return; + channel.sendCommand( + participationId, + commandId -> + new ChangeProperty( + commandId, + classifierInstance.getID(), + MetaPointer.from(property), + primitiveValuesSerialization.serialize(property.getType().getID(), newValue))); + } + + @Override + public void childAdded( + ClassifierInstance classifierInstance, + Containment containment, + int index, + Node newChild) { + if (paused) return; + SerializationChunk chunk = serialization.serializeNodesToSerializationChunk(newChild); + if (newChild.getID() == null) { + throw new IllegalStateException("Child id must not be null"); + } + channel.sendCommand( + participationId, + commandId -> + new AddChild( + commandId, + classifierInstance.getID(), + chunk, + MetaPointer.from(containment), + index)); + } + + @Override + public void childRemoved( + ClassifierInstance classifierInstance, + Containment containment, + int index, + @NotNull Node removedChild) { + if (paused) return; + Objects.requireNonNull(removedChild, "removedChild must not be null"); + String removedChildId = removedChild.getID(); + Objects.requireNonNull(removedChildId, "removedChildId must not be null"); + channel.sendCommand( + participationId, + commandId -> + new DeleteChild( + commandId, + classifierInstance.getID(), + MetaPointer.from(containment), + index, + removedChildId)); + } + + @Override + public void annotationAdded( + ClassifierInstance node, int index, AnnotationInstance newAnnotation) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void annotationRemoved( + ClassifierInstance node, int index, AnnotationInstance removedAnnotation) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void referenceValueAdded( + ClassifierInstance classifierInstance, + Reference reference, + int index, + ReferenceValue referenceValue) { + if (paused) return; + channel.sendCommand( + participationId, + commandId -> + new AddReference( + commandId, + classifierInstance.getID(), + MetaPointer.from(reference), + index, + referenceValue.getReferredID(), + referenceValue.getResolveInfo())); + } + + @Override + public void referenceValueChanged( + ClassifierInstance classifierInstance, + Reference reference, + int index, + String oldReferred, + String oldResolveInfo, + String newReferred, + String newResolveInfo) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void referenceValueRemoved( + ClassifierInstance classifierInstance, + Reference reference, + int index, + ReferenceValue referenceValue) { + throw new UnsupportedOperationException("Not supported yet."); + } + } + + @Override + public void receiveQueryResponse(DeltaQueryResponse queryResponse) { + if (!queriesSent.contains(queryResponse.queryId)) return; + if (queryResponse instanceof SignOnResponse) { + SignOnResponse signOnResponse = (SignOnResponse) queryResponse; + this.participationId = signOnResponse.participationId; + return; + } + throw new UnsupportedOperationException("Not supported yet."); + } + + public void sendSignOnRequest() { + channel.sendQuery( + queryId -> { + queriesSent.add(queryId); + return new SignOnRequest(queryId, DeltaProtocolVersion.v2025_1, clientId); + }); + } +} diff --git a/client/src/main/java/io/lionweb/client/delta/DeltaCommandReceiver.java b/client/src/main/java/io/lionweb/client/delta/DeltaCommandReceiver.java new file mode 100644 index 000000000..aff65f977 --- /dev/null +++ b/client/src/main/java/io/lionweb/client/delta/DeltaCommandReceiver.java @@ -0,0 +1,8 @@ +package io.lionweb.client.delta; + +import io.lionweb.client.delta.messages.DeltaCommand; + +public interface DeltaCommandReceiver { + + void receiveCommand(String participationId, DeltaCommand command); +} diff --git a/client/src/main/java/io/lionweb/client/delta/DeltaEventReceiver.java b/client/src/main/java/io/lionweb/client/delta/DeltaEventReceiver.java new file mode 100644 index 000000000..b55769145 --- /dev/null +++ b/client/src/main/java/io/lionweb/client/delta/DeltaEventReceiver.java @@ -0,0 +1,8 @@ +package io.lionweb.client.delta; + +import io.lionweb.client.delta.messages.DeltaEvent; + +public interface DeltaEventReceiver { + + void receiveEvent(DeltaEvent event); +} diff --git a/client/src/main/java/io/lionweb/client/delta/DeltaQueryReceiver.java b/client/src/main/java/io/lionweb/client/delta/DeltaQueryReceiver.java new file mode 100644 index 000000000..47e8585fa --- /dev/null +++ b/client/src/main/java/io/lionweb/client/delta/DeltaQueryReceiver.java @@ -0,0 +1,9 @@ +package io.lionweb.client.delta; + +import io.lionweb.client.delta.messages.DeltaQuery; +import io.lionweb.client.delta.messages.DeltaQueryResponse; + +public interface DeltaQueryReceiver { + + DeltaQueryResponse receiveQuery(DeltaQuery query); +} diff --git a/client/src/main/java/io/lionweb/client/delta/DeltaQueryResponseReceiver.java b/client/src/main/java/io/lionweb/client/delta/DeltaQueryResponseReceiver.java new file mode 100644 index 000000000..510026af7 --- /dev/null +++ b/client/src/main/java/io/lionweb/client/delta/DeltaQueryResponseReceiver.java @@ -0,0 +1,8 @@ +package io.lionweb.client.delta; + +import io.lionweb.client.delta.messages.DeltaQueryResponse; + +public interface DeltaQueryResponseReceiver { + + void receiveQueryResponse(DeltaQueryResponse queryResponse); +} diff --git a/client/src/main/java/io/lionweb/client/delta/ErrorEventReceivedException.java b/client/src/main/java/io/lionweb/client/delta/ErrorEventReceivedException.java new file mode 100644 index 000000000..2c115d17d --- /dev/null +++ b/client/src/main/java/io/lionweb/client/delta/ErrorEventReceivedException.java @@ -0,0 +1,34 @@ +package io.lionweb.client.delta; + +import java.util.Objects; + +public class ErrorEventReceivedException extends RuntimeException { + private String code; + private String errorMessage; + + public ErrorEventReceivedException(String code, String errorMessage) { + super("code=" + code + " message=" + errorMessage); + this.code = code; + this.errorMessage = errorMessage; + } + + public String getCode() { + return code; + } + + public String getErrorMessage() { + return errorMessage; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + ErrorEventReceivedException that = (ErrorEventReceivedException) o; + return Objects.equals(code, that.code) && Objects.equals(errorMessage, that.errorMessage); + } + + @Override + public int hashCode() { + return Objects.hash(code, errorMessage); + } +} diff --git a/client/src/main/java/io/lionweb/client/delta/InMemoryDeltaChannel.java b/client/src/main/java/io/lionweb/client/delta/InMemoryDeltaChannel.java new file mode 100644 index 000000000..e2b105a70 --- /dev/null +++ b/client/src/main/java/io/lionweb/client/delta/InMemoryDeltaChannel.java @@ -0,0 +1,85 @@ +package io.lionweb.client.delta; + +import io.lionweb.client.delta.messages.*; +import java.util.*; +import java.util.function.Function; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class InMemoryDeltaChannel implements DeltaChannel { + private final Set eventReceivers = new HashSet<>(); + private @Nullable DeltaCommandReceiver commandReceiver; + private @Nullable DeltaQueryReceiver queryReceiver; + private @Nullable List queryResponseReceivers = new ArrayList<>(); + private int nextEventId = 1; + private int nextCommandId = 1; + private int nextQueryId = 1; + + @Override + public DeltaQueryResponse sendQuery(Function queryProducer) { + if (queryReceiver != null) { + DeltaQueryResponse response = + queryReceiver.receiveQuery(queryProducer.apply("query-" + nextQueryId++)); + queryResponseReceivers.forEach(receiver -> receiver.receiveQueryResponse(response)); + return response; + } + + return null; + } + + @Override + public void sendCommand( + @NotNull String participationId, Function commandProducer) { + Objects.requireNonNull(participationId, "participationId must not be null"); + if (commandReceiver != null) { + commandReceiver.receiveCommand( + participationId, commandProducer.apply("cmd-" + nextCommandId++)); + } + } + + @Override + public void registerEventReceiver(DeltaEventReceiver deltaEventReceiver) { + eventReceivers.add(deltaEventReceiver); + } + + @Override + public void unregisterEventReceiver(DeltaEventReceiver deltaEventReceiver) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void registerCommandReceiver(DeltaCommandReceiver commandReceiver) { + this.commandReceiver = commandReceiver; + } + + @Override + public void unregisterCommandReceiver(DeltaCommandReceiver deltaCommandReceiver) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void sendEvent(Function eventProducer) { + eventReceivers.forEach(receiver -> receiver.receiveEvent(eventProducer.apply(nextEventId++))); + } + + @Override + public void registerQueryReceiver(DeltaQueryReceiver deltaQueryReceiver) { + this.queryReceiver = deltaQueryReceiver; + } + + @Override + public void unregisterQueryReceiver(DeltaQueryReceiver deltaQueryReceiver) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void registerQueryResponseReceiver(DeltaQueryResponseReceiver deltaQueryResponseReceiver) { + this.queryResponseReceivers.add(deltaQueryResponseReceiver); + } + + @Override + public void unregisterQueryResponseReceiver( + DeltaQueryResponseReceiver deltaQueryResponseReceiver) { + throw new UnsupportedOperationException("Not supported yet."); + } +} diff --git a/client/src/main/java/io/lionweb/client/inmemory/InMemoryServer.java b/client/src/main/java/io/lionweb/client/inmemory/InMemoryServer.java index b640f5aa8..c1aee213c 100644 --- a/client/src/main/java/io/lionweb/client/inmemory/InMemoryServer.java +++ b/client/src/main/java/io/lionweb/client/inmemory/InMemoryServer.java @@ -1,8 +1,33 @@ package io.lionweb.client.inmemory; +import io.lionweb.LionWebVersion; import io.lionweb.client.api.*; +import io.lionweb.client.delta.CommandSource; +import io.lionweb.client.delta.DeltaChannel; +import io.lionweb.client.delta.DeltaCommandReceiver; +import io.lionweb.client.delta.DeltaQueryReceiver; +import io.lionweb.client.delta.messages.DeltaCommand; +import io.lionweb.client.delta.messages.DeltaQuery; +import io.lionweb.client.delta.messages.DeltaQueryResponse; +import io.lionweb.client.delta.messages.commands.children.AddChild; +import io.lionweb.client.delta.messages.commands.children.DeleteChild; +import io.lionweb.client.delta.messages.commands.properties.ChangeProperty; +import io.lionweb.client.delta.messages.commands.references.AddReference; +import io.lionweb.client.delta.messages.events.ErrorEvent; +import io.lionweb.client.delta.messages.events.StandardErrorCode; +import io.lionweb.client.delta.messages.events.children.ChildAdded; +import io.lionweb.client.delta.messages.events.children.ChildDeleted; +import io.lionweb.client.delta.messages.events.properties.PropertyChanged; +import io.lionweb.client.delta.messages.events.references.ReferenceAdded; +import io.lionweb.client.delta.messages.queries.partitcipations.SignOnRequest; +import io.lionweb.client.delta.messages.queries.partitcipations.SignOnResponse; +import io.lionweb.model.ClassifierInstance; +import io.lionweb.model.Node; +import io.lionweb.serialization.AbstractSerialization; import io.lionweb.serialization.data.MetaPointer; +import io.lionweb.serialization.data.SerializationChunk; import io.lionweb.serialization.data.SerializedClassifierInstance; +import io.lionweb.serialization.data.SerializedReferenceValue; import io.lionweb.utils.ValidationResult; import java.util.*; import java.util.stream.Collectors; @@ -24,6 +49,8 @@ public class InMemoryServer { /** Internally we store the data separately for each repository. */ private final Map repositories = new LinkedHashMap<>(); + private int nextParticipationId = 1; + public @NotNull RepositoryConfiguration getRepositoryConfiguration( @NotNull String repositoryName) { return getRepository(repositoryName).configuration; @@ -81,6 +108,22 @@ public void deleteRepository(@NotNull String repositoryName) { return repositoryData.bumpVersion(); } + public @NotNull RepositoryVersionToken createPartition( + @NotNull String repositoryName, + @NotNull Node partition, + @NotNull AbstractSerialization serialization) { + Objects.requireNonNull(repositoryName, "RepositoryName should not be null"); + Objects.requireNonNull(partition, "Partition should not be null"); + Objects.requireNonNull(serialization, "Serialization should not be null"); + if (partition.getParent() != null) { + throw new IllegalArgumentException("Partition should not have a parent"); + } + + SerializationChunk serializationChunk = + serialization.serializeNodesToSerializationChunk(partition); + return createPartitionFromChunk(repositoryName, serializationChunk.getClassifierInstances()); + } + public @NotNull RepositoryVersionToken deletePartitions( @NotNull String repositoryName, @NotNull List partitionIds) { Objects.requireNonNull(partitionIds); @@ -99,6 +142,26 @@ public List retrieve( return retrieved; } + public @Nullable ClassifierInstance retrieveAsClassifierInstance( + @NotNull String repositoryName, + @NotNull String nodeId, + @NotNull AbstractSerialization serialization) { + Objects.requireNonNull(repositoryName, "RepositoryName should not be null"); + Objects.requireNonNull(nodeId, "NodeId should not be null"); + Objects.requireNonNull(serialization, "Serialization should not be null"); + List serializedNodes = + retrieve(repositoryName, Arrays.asList(nodeId), 1); + if (serializedNodes.isEmpty()) { + return null; + } + LionWebVersion lionWebVersion = + repositories.get(repositoryName).configuration.getLionWebVersion(); + List> nodes = + serialization.deserializeSerializationChunk( + SerializationChunk.fromNodes(lionWebVersion, serializedNodes)); + return nodes.stream().filter(n -> Objects.equals(n.getID(), nodeId)).findFirst().orElse(null); + } + public RepositoryVersionToken store( @NotNull String repositoryName, @NotNull List nodes) { Objects.requireNonNull(repositoryName, "RepositoryName should not be null"); @@ -182,6 +245,173 @@ public Map nodesByLanguage( return result; } + // + // Delta methods + // + + public void monitorDeltaChannel(String repositoryName, @NotNull DeltaChannel channel) { + Objects.requireNonNull(channel, "Channel should not be null"); + channel.registerCommandReceiver(new DeltaCommandReceiverImpl(repositoryName, channel)); + channel.registerQueryReceiver(new DeltaQueryReceiverImpl(repositoryName, channel)); + } + + private class DeltaQueryReceiverImpl implements DeltaQueryReceiver { + + private String repositoryName; + private DeltaChannel channel; + + private DeltaQueryReceiverImpl(String repositoryName, DeltaChannel channel) { + this.repositoryName = repositoryName; + this.channel = channel; + } + + @Override + public DeltaQueryResponse receiveQuery(DeltaQuery query) { + if (query instanceof SignOnRequest) { + SignOnRequest signOnRequest = (SignOnRequest) query; + return new SignOnResponse(signOnRequest.queryId, "participation-" + nextParticipationId++); + } + throw new UnsupportedOperationException("Not supported yet."); + } + } + + private class DeltaCommandReceiverImpl implements DeltaCommandReceiver { + private String repositoryName; + private DeltaChannel channel; + + private DeltaCommandReceiverImpl(String repositoryName, DeltaChannel channel) { + this.repositoryName = repositoryName; + this.channel = channel; + } + + @Override + public void receiveCommand(String participationId, DeltaCommand command) { + CommandSource source = new CommandSource(participationId, command.commandId); + if (command instanceof ChangeProperty) { + ChangeProperty changeProperty = (ChangeProperty) command; + RepositoryData repositoryData = getRepository(repositoryName); + List retrieved = new ArrayList<>(); + try { + repositoryData.retrieve(changeProperty.node, 0, retrieved); + } catch (IllegalArgumentException e) { + channel.sendEvent( + sequenceNumber -> + new ErrorEvent( + sequenceNumber, + StandardErrorCode.UNKNOWN_NODE, + "Node with id " + changeProperty.node + " not found")); + return; + } + SerializedClassifierInstance node = retrieved.get(0); + String oldValue = node.getPropertyValue(((ChangeProperty) command).property); + retrieved.get(0); + node.setPropertyValue(((ChangeProperty) command).property, changeProperty.newValue); + String newValue = node.getPropertyValue(((ChangeProperty) command).property); + channel.sendEvent( + sequenceNumber -> + new PropertyChanged( + sequenceNumber, node.getID(), changeProperty.property, newValue, oldValue) + .addSource(source)); + return; + } else if (command instanceof AddChild) { + AddChild addChild = (AddChild) command; + RepositoryData repositoryData = getRepository(repositoryName); + List retrieved = new ArrayList<>(); + try { + repositoryData.retrieve(addChild.parent, 0, retrieved); + } catch (IllegalArgumentException e) { + channel.sendEvent( + sequenceNumber -> + new ErrorEvent( + sequenceNumber, + StandardErrorCode.UNKNOWN_NODE, + "Node with id " + addChild.parent + " not found")); + return; + } + SerializedClassifierInstance node = retrieved.get(0); + repositoryData.store(addChild.newChild.getClassifierInstances()); + String childId = + addChild.newChild.getClassifierInstances().stream() + .filter(n -> n.getParentNodeID().equals(addChild.parent)) + .findFirst() + .get() + .getID(); + node.addChild(addChild.containment, childId, addChild.index); + channel.sendEvent( + sequenceNumber -> + new ChildAdded( + sequenceNumber, + addChild.parent, + addChild.newChild, + addChild.containment, + addChild.index) + .addSource(source)); + return; + } else if (command instanceof DeleteChild) { + DeleteChild deleteChild = (DeleteChild) command; + RepositoryData repositoryData = getRepository(repositoryName); + List retrieved = new ArrayList<>(); + try { + repositoryData.retrieve(deleteChild.parent, 0, retrieved); + } catch (IllegalArgumentException e) { + channel.sendEvent( + sequenceNumber -> + new ErrorEvent( + sequenceNumber, + StandardErrorCode.UNKNOWN_NODE, + "Node with id " + deleteChild.parent + " not found")); + return; + } + SerializedClassifierInstance node = retrieved.get(0); + channel.sendEvent( + sequenceNumber -> + new ChildDeleted( + sequenceNumber, + deleteChild.parent, + deleteChild.containment, + deleteChild.index, + deleteChild.deletedChild) + .addSource(source)); + return; + } else if (command instanceof AddReference) { + AddReference addReference = (AddReference) command; + RepositoryData repositoryData = getRepository(repositoryName); + List retrieved = new ArrayList<>(); + try { + repositoryData.retrieve(addReference.parent, 0, retrieved); + } catch (IllegalArgumentException e) { + channel.sendEvent( + sequenceNumber -> + new ErrorEvent( + sequenceNumber, + StandardErrorCode.UNKNOWN_NODE, + "Node with id " + addReference.parent + " not found")); + return; + } + SerializedClassifierInstance node = retrieved.get(0); + node.addReferenceValue( + addReference.reference, + addReference.index, + new SerializedReferenceValue.Entry( + addReference.newTarget, addReference.newResolveInfo)); + channel.sendEvent( + sequenceNumber -> + new ReferenceAdded( + sequenceNumber, + addReference.parent, + addReference.reference, + addReference.index, + addReference.newTarget, + addReference.newResolveInfo) + .addSource(source)); + return; + } + + throw new UnsupportedOperationException( + "Unsupported command type: " + command.getClass().getName()); + } + } + // // Private methods // diff --git a/client/src/test/java/io/lionweb/client/delta/DeltaClientAndServerTest.java b/client/src/test/java/io/lionweb/client/delta/DeltaClientAndServerTest.java new file mode 100644 index 000000000..1093a3f67 --- /dev/null +++ b/client/src/test/java/io/lionweb/client/delta/DeltaClientAndServerTest.java @@ -0,0 +1,472 @@ +package io.lionweb.client.delta; + +import static org.junit.Assert.*; + +import io.lionweb.LionWebVersion; +import io.lionweb.client.api.HistorySupport; +import io.lionweb.client.api.RepositoryConfiguration; +import io.lionweb.client.delta.messages.events.StandardErrorCode; +import io.lionweb.client.inmemory.InMemoryServer; +import io.lionweb.language.*; +import io.lionweb.serialization.JsonSerialization; +import io.lionweb.serialization.SerializationProvider; +import io.lionweb.utils.ModelComparator; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class DeltaClientAndServerTest { + + @Test + public void simpleSynchronizationOfNodesInstances() { + InMemoryServer server = new InMemoryServer(); + server.createRepository( + new RepositoryConfiguration("MyRepo", LionWebVersion.v2024_1, HistorySupport.DISABLED)); + + JsonSerialization serialization = + SerializationProvider.getStandardJsonSerialization(LionWebVersion.v2024_1); + Language language1 = new Language("Language A", "lang-a", "lang-a-key"); + server.createPartition("MyRepo", language1, serialization); + + Language language2 = + (Language) server.retrieveAsClassifierInstance("MyRepo", "lang-a", serialization); + Assertions.assertNotNull(language2); + + assertEquals(language1, language2); + + DeltaChannel channel = new InMemoryDeltaChannel(); + server.monitorDeltaChannel("MyRepo", channel); + + DeltaClient client1 = new DeltaClient(channel, "my-client-1"); + client1.sendSignOnRequest(); + client1.monitor(language1); + DeltaClient client2 = new DeltaClient(channel, "my-client-2"); + client2.sendSignOnRequest(); + client2.monitor(language2); + + assertEquals("Language A", language1.getName()); + assertEquals("Language A", language2.getName()); + + language1.setName("Language B"); + assertEquals("Language B", language1.getName()); + assertEquals("Language B", language2.getName()); + + language2.setName("Language C"); + assertEquals("Language C", language1.getName()); + assertEquals("Language C", language2.getName()); + + language1.setName("Language A"); + assertEquals("Language A", language1.getName()); + assertEquals("Language A", language2.getName()); + } + + @Test + public void changingUnexistingNodeCauseError() { + InMemoryServer server = new InMemoryServer(); + server.createRepository( + new RepositoryConfiguration("MyRepo", LionWebVersion.v2024_1, HistorySupport.DISABLED)); + + JsonSerialization serialization = + SerializationProvider.getStandardJsonSerialization(LionWebVersion.v2024_1); + Language language1 = new Language("Language A", "lang-a", "lang-a-key"); + // We do NOT create the partition on the repository + + DeltaChannel channel = new InMemoryDeltaChannel(); + server.monitorDeltaChannel("MyRepo", channel); + + DeltaClient client = new DeltaClient(channel, "my-client-1"); + client.sendSignOnRequest(); + + client.monitor(language1); + try { + language1.setName("Language B"); + } catch (ErrorEventReceivedException e) { + assertEquals(StandardErrorCode.UNKNOWN_NODE.code, e.getCode()); + assertEquals("Node with id lang-a not found", e.getErrorMessage()); + return; + } + fail("Expected exception not thrown"); + } + + @Test + public void addingChildren() { + InMemoryServer server = new InMemoryServer(); + server.createRepository( + new RepositoryConfiguration("MyRepo", LionWebVersion.v2024_1, HistorySupport.DISABLED)); + + JsonSerialization serialization = + SerializationProvider.getStandardJsonSerialization(LionWebVersion.v2024_1); + Language language1 = new Language("Language A", "lang-a", "lang-a-key"); + server.createPartition("MyRepo", language1, serialization); + + Language language2 = + (Language) server.retrieveAsClassifierInstance("MyRepo", "lang-a", serialization); + Assertions.assertNotNull(language2); + + assertEquals(language1, language2); + + DeltaChannel channel = new InMemoryDeltaChannel(); + server.monitorDeltaChannel("MyRepo", channel); + + DeltaClient client1 = new DeltaClient(channel, "my-client-1"); + client1.sendSignOnRequest(); + + DeltaClient client2 = new DeltaClient(channel, "my-client-2"); + client2.sendSignOnRequest(); + + client1.monitor(language1); + client2.monitor(language2); + + assertEquals(Collections.emptyList(), language1.getElements()); + assertEquals(Collections.emptyList(), language2.getElements()); + + Concept concept1 = new Concept(language1, "Concept A", "concept-a", "a"); + language1.addElement(concept1); + assertTrue( + ModelComparator.areEquivalent( + Collections.singletonList(concept1), language1.getElements())); + assertTrue( + ModelComparator.areEquivalent( + Collections.singletonList(concept1), language2.getElements())); + } + + @Test + public void removingChildren() { + InMemoryServer server = new InMemoryServer(); + server.createRepository( + new RepositoryConfiguration("MyRepo", LionWebVersion.v2024_1, HistorySupport.DISABLED)); + + JsonSerialization serialization = + SerializationProvider.getStandardJsonSerialization(LionWebVersion.v2024_1); + Language language1 = new Language("Language A", "lang-a", "lang-a-key"); + server.createPartition("MyRepo", language1, serialization); + + Language language2 = + (Language) server.retrieveAsClassifierInstance("MyRepo", "lang-a", serialization); + Assertions.assertNotNull(language2); + + assertEquals(language1, language2); + + DeltaChannel channel = new InMemoryDeltaChannel(); + server.monitorDeltaChannel("MyRepo", channel); + + DeltaClient client1 = new DeltaClient(channel, "my-client-1"); + client1.sendSignOnRequest(); + + DeltaClient client2 = new DeltaClient(channel, "my-client-2"); + client2.sendSignOnRequest(); + + client1.monitor(language1); + client2.monitor(language2); + + Concept concept1 = new Concept(language1, "Concept A", "concept-a", "a"); + language1.addElement(concept1); + Concept concept2 = new Concept(language1, "Concept B", "concept-b", "b"); + language1.addElement(concept2); + Concept concept3 = new Concept(language1, "Concept C", "concept-c", "c"); + language1.addElement(concept3); + + assertEquals(Arrays.asList(concept1, concept2, concept3), language1.getElements()); + assertEquals(Arrays.asList(concept1, concept2, concept3), language2.getElements()); + + language1.removeChild(concept2); + + assertEquals(Arrays.asList(concept1, concept3), language1.getElements()); + assertEquals(Arrays.asList(concept1, concept3), language2.getElements()); + + language1.removeChild(concept3); + + assertEquals(Arrays.asList(concept1), language1.getElements()); + assertEquals(Arrays.asList(concept1), language2.getElements()); + + language1.removeChild(concept1); + + assertEquals(Arrays.asList(), language1.getElements()); + assertEquals(Arrays.asList(), language2.getElements()); + } + + @Test + public void variousOperations() { + InMemoryServer server = new InMemoryServer(); + server.createRepository( + new RepositoryConfiguration("MyRepo", LionWebVersion.v2024_1, HistorySupport.DISABLED)); + + JsonSerialization serialization = + SerializationProvider.getStandardJsonSerialization(LionWebVersion.v2024_1); + Language language1 = new Language("Language A", "lang-a", "lang-a-key"); + server.createPartition("MyRepo", language1, serialization); + + Language language2 = + (Language) server.retrieveAsClassifierInstance("MyRepo", "lang-a", serialization); + Assertions.assertNotNull(language2); + + assertEquals(language1, language2); + + DeltaChannel channel = new InMemoryDeltaChannel(); + server.monitorDeltaChannel("MyRepo", channel); + + DeltaClient client1 = new DeltaClient(channel, "my-client-1"); + client1.sendSignOnRequest(); + + DeltaClient client2 = new DeltaClient(channel, "my-client-2"); + client2.sendSignOnRequest(); + + client1.monitor(language1); + client2.monitor(language2); + + // HERE DO A LOT OF OPERATIONS CREATING A LANGUAGE AND CHANGING IT + + // 1. Change language name multiple times + language1.setName("Language Modified"); + language1.setName("Business Domain Language"); + language1.setName("Enterprise Modeling Language"); + + // 2. Create enumerations + Enumeration statusEnum = new Enumeration(language1, "Status", "status-enum").setKey("status"); + language1.addElement(statusEnum); + + EnumerationLiteral activeStatus = + new EnumerationLiteral(statusEnum, "Active", "active-literal").setKey("active"); + statusEnum.addLiteral(activeStatus); + EnumerationLiteral inactiveStatus = + new EnumerationLiteral(statusEnum, "Inactive", "inactive-literal").setKey("inactive"); + statusEnum.addLiteral(inactiveStatus); + EnumerationLiteral pendingStatus = + new EnumerationLiteral(statusEnum, "Pending", "pending-literal").setKey("pending"); + statusEnum.addLiteral(pendingStatus); + + // Add another enumeration + Enumeration priorityEnum = + new Enumeration(language1, "Priority", "priority-enum").setKey("priority"); + language1.addElement(priorityEnum); + + EnumerationLiteral highPriority = + new EnumerationLiteral(priorityEnum, "High", "high-literal").setKey("high"); + priorityEnum.addLiteral(highPriority); + EnumerationLiteral mediumPriority = + new EnumerationLiteral(priorityEnum, "Medium", "medium-literal").setKey("medium"); + priorityEnum.addLiteral(mediumPriority); + EnumerationLiteral lowPriority = + new EnumerationLiteral(priorityEnum, "Low", "low-literal").setKey("low"); + priorityEnum.addLiteral(lowPriority); + + // 3. Create interfaces + Interface namedInterface = new Interface(language1, "Named", "named-interface", "named"); + language1.addElement(namedInterface); + + Interface identifiableInterface = + new Interface(language1, "Identifiable", "identifiable-interface", "identifiable"); + language1.addElement(identifiableInterface); + + Interface auditableInterface = + new Interface(language1, "Auditable", "auditable-interface", "auditable"); + language1.addElement(auditableInterface); + + Interface timestampedInterface = + new Interface(language1, "Timestamped", "timestamped-interface", "timestamped"); + language1.addElement(timestampedInterface); + + // 4. Make interfaces extend other interfaces + auditableInterface.addExtendedInterface(timestampedInterface); + namedInterface.addExtendedInterface(identifiableInterface); + + // 5. Add features to interfaces + Property nameProperty = new Property("name", namedInterface, "name-id"); + nameProperty.setType(LionCoreBuiltins.getString()); + namedInterface.addFeature(nameProperty); + + Property idProperty = new Property("id", identifiableInterface, "id0d"); + idProperty.setType(LionCoreBuiltins.getString()); + identifiableInterface.addFeature(idProperty); + + Property createdAtProperty = new Property("createdAt", timestampedInterface, "createdAt-id"); + createdAtProperty.setType(LionCoreBuiltins.getString()); + timestampedInterface.addFeature(createdAtProperty); + + Property modifiedAtProperty = new Property("modifiedAt", timestampedInterface, "modifiedAt-id"); + modifiedAtProperty.setType(LionCoreBuiltins.getString()); + timestampedInterface.addFeature(modifiedAtProperty); + + // 6. Create concepts + Concept personConcept = new Concept(language1, "Person", "person-concept", "person"); + language1.addElement(personConcept); + + Concept companyConcept = new Concept(language1, "Company", "company-concept", "company"); + language1.addElement(companyConcept); + + Concept addressConcept = new Concept(language1, "Address", "address-concept", "address"); + language1.addElement(addressConcept); + + Concept baseConcept = new Concept(language1, "BaseEntity", "base-entity-concept", "baseEntity"); + language1.addElement(baseConcept); + + // 7. Make concepts implement interfaces + personConcept.addImplementedInterface(namedInterface); + personConcept.addImplementedInterface(auditableInterface); + + companyConcept.addImplementedInterface(namedInterface); + companyConcept.addImplementedInterface(identifiableInterface); + + addressConcept.addImplementedInterface(timestampedInterface); + + baseConcept.addImplementedInterface(identifiableInterface); + baseConcept.addImplementedInterface(auditableInterface); + + // 8. Set up concept inheritance + personConcept.setExtendedConcept(baseConcept); + companyConcept.setExtendedConcept(baseConcept); + + // 9. Add features to concepts + Property ageProperty = new Property("age", personConcept, "age-id"); + ageProperty.setType(LionCoreBuiltins.getInteger()); + personConcept.addFeature(ageProperty); + + Property emailProperty = new Property("email", personConcept, "email-id"); + emailProperty.setType(LionCoreBuiltins.getString()); + personConcept.addFeature(emailProperty); + + Property statusProperty = new Property("status", personConcept, "status-id"); + statusProperty.setType(statusEnum); + personConcept.addFeature(statusProperty); + + Property employeeCountProperty = + new Property("employeeCount", companyConcept, "employeeCount-id"); + employeeCountProperty.setType(LionCoreBuiltins.getInteger()); + companyConcept.addFeature(employeeCountProperty); + + Property streetProperty = new Property("street", addressConcept, "street-id"); + streetProperty.setType(LionCoreBuiltins.getString()); + addressConcept.addFeature(streetProperty); + + Property cityProperty = new Property("city", addressConcept, "city-id"); + cityProperty.setType(LionCoreBuiltins.getString()); + addressConcept.addFeature(cityProperty); + + // 10. Add containment references + Containment addressesContainment = new Containment("addresses", personConcept, "addresses-id"); + addressesContainment.setType(addressConcept); + addressesContainment.setMultiple(true); + personConcept.addFeature(addressesContainment); + + Containment employeesContainment = new Containment("employees", companyConcept, "employees-id"); + employeesContainment.setType(personConcept); + employeesContainment.setMultiple(true); + companyConcept.addFeature(employeesContainment); + + // 11. Add regular references + Reference companyReference = new Reference("employer", personConcept, "employer-id"); + companyReference.setType(companyConcept); + companyReference.setOptional(true); + personConcept.addFeature(companyReference); + + // 12. Move features between concepts + personConcept.removeFeature(emailProperty); + baseConcept.addFeature(emailProperty); + + // 13. Modify feature properties + ageProperty.setOptional(true); + statusProperty.setOptional(false); + employeeCountProperty.setOptional(true); + + // 14. Add more enumeration literals + EnumerationLiteral archivedStatus = + new EnumerationLiteral(statusEnum, "Archived", "archived-id"); + statusEnum.addLiteral(archivedStatus); + + // Remove and re-add enumeration literal + statusEnum.removeChild(pendingStatus); + EnumerationLiteral reviewingStatus = + new EnumerationLiteral(statusEnum, "Reviewing", "reviewing-id"); + statusEnum.addLiteral(reviewingStatus); + + // 15. Modify interface hierarchy + Interface versionedInterface = + new Interface(language1, "Versioned", "versioned-interface", "versioned"); + language1.addElement(versionedInterface); + + Property versionProperty = new Property("version", versionedInterface, "version-id"); + versionProperty.setType(LionCoreBuiltins.getInteger()); + versionedInterface.addFeature(versionProperty); + + auditableInterface.addExtendedInterface(versionedInterface); + + // 16. Create abstract concepts + Concept documentConcept = new Concept(language1, "Document", "document-concept", "document"); + documentConcept.setAbstract(true); + language1.addElement(documentConcept); + + documentConcept.addImplementedInterface(namedInterface); + documentConcept.addImplementedInterface(versionedInterface); + + Concept reportConcept = new Concept(language1, "Report", "report-concept", "report"); + language1.addElement(reportConcept); + reportConcept.setExtendedConcept(documentConcept); + + Concept contractConcept = new Concept(language1, "Contract", "contract-concept", "contract"); + language1.addElement(contractConcept); + contractConcept.setExtendedConcept(documentConcept); + + // 17. Add features with different cardinalities + Property tagsProperty = new Property("tags", documentConcept, "tags-id"); + tagsProperty.setType(LionCoreBuiltins.getString()); + documentConcept.addFeature(tagsProperty); + + Property priorityProperty = new Property("priority", reportConcept, "priority-id"); + priorityProperty.setType(priorityEnum); + reportConcept.addFeature(priorityProperty); + + // 18. Move features within the same concept (change order) + personConcept.removeFeature(ageProperty); + personConcept.removeFeature(statusProperty); + personConcept.addFeature(statusProperty); + personConcept.addFeature(ageProperty); + + // 19. Create complex reference relationships + Reference authorReference = new Reference("author", documentConcept, "author-id"); + authorReference.setType(personConcept); + documentConcept.addFeature(authorReference); + + Reference clientReference = new Reference("client", contractConcept, "client-id"); + clientReference.setType(companyConcept); + contractConcept.addFeature(clientReference); + + // 20. Modify existing elements + statusEnum.setName("EntityStatus"); + priorityEnum.setName("TaskPriority"); + + activeStatus.setName("ACTIVE"); + inactiveStatus.setName("INACTIVE"); + + namedInterface.setName("NamedEntity"); + identifiableInterface.setName("UniqueEntity"); + + // 21. Remove and re-add features with modifications + companyConcept.removeFeature(employeeCountProperty); + Property staffSizeProperty = new Property("staffSize", companyConcept, "staffSize-id"); + staffSizeProperty.setType(LionCoreBuiltins.getInteger()); + staffSizeProperty.setOptional(false); + companyConcept.addFeature(staffSizeProperty); + + // 22. Change concept inheritance + Concept organizationConcept = + new Concept(language1, "Organization", "organization-concept", "organization"); + language1.addElement(organizationConcept); + organizationConcept.setExtendedConcept(baseConcept); + organizationConcept.addImplementedInterface(namedInterface); + + companyConcept.setExtendedConcept(organizationConcept); + + // 23. Add final modifications + Property descriptionProperty = + new Property("description", organizationConcept, "description-id"); + descriptionProperty.setType(LionCoreBuiltins.getString()); + descriptionProperty.setOptional(true); + organizationConcept.addFeature(descriptionProperty); + + // 24. Final language name change + language1.setName("Complete Enterprise Domain Language"); + + assertEquals(language1, language2); + } +}