diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index bf74ac7e1..af38920a5 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -1,6 +1,34 @@ = Changelog -== v2026.1.0 (work in progress) +== v2026.3.0 (work in progress) + +=== Shapes + +=== Breaking changes + +- https://github.com/eclipse-syson/syson/issues/1861[#1861] [publication] Split `SysONLibraryPublicationHandler` in two distinct classes so the publishing logic can be extended or re-used through the `ISysMLLibraryPublisher` API. + +=== Dependency update + +=== Bug fixes + +- https://github.com/eclipse-syson/syson/issues/1847[#1847] [export] Textual export duplicates "abstract" keyword for `OccurrenceUsage`. +- https://github.com/eclipse-syson/syson/issues/1887[#1887] [export] Export fails to escape some names when using qualified name. + +=== Improvements + +- https://github.com/eclipse-syson/syson/issues/1694[#1694] [syson] Customize Elasticsearch indices to work with SysML. +SysON now uses its own indexing logic instead of the default one provided by Sirius Web. +This allows to limit the size of the indices, and ensure information stored in the indices are useful to perform cross-project search. +You can find more information on how to setup Elasticsearch, how elements are mapped to index documents, and how to query them in the documentation. +- https://github.com/eclipse-syson/syson/issues/1861[#1861] [publication] Split `SysONLibraryPublicationHandler` in two distinct classes so the publishing logic can be extended or re-used through the `ISysMLLibraryPublisher` API. +- https://github.com/eclipse-syson/syson/issues/1895[#1895] [export] Implement textual export of `StateUsage` and `StateDefinition`. + + +=== New features + + +== v2026.1.0 === Shapes @@ -57,7 +85,7 @@ This fix ensure that imported models containing, for example, a top-level `Libra - https://github.com/eclipse-syson/syson/issues/1876[#1876] [explorer] Fix potential NPE when trying to delete elements with associated `EAnnotations` from the _Explorer_. - https://github.com/eclipse-syson/syson/issues/1858[#1858] [diagrams] Fix an issue where a new graphical node could be located inside the wrong graphical container. - https://github.com/eclipse-syson/syson/issues/1859[#1859] [diagrams] Fix an issue where the creation of an `ItemUsage` from an `ActionUsage` or an `ActionDefinition` graphical node was not revealing it. -- https://github.com/eclipse-syson/syson/issues/1869[#1869] [diagrams] When creating a _Do action with referenced action_ on a `StateUsage`, ensure the corresponding compartements are revealed. +- https://github.com/eclipse-syson/syson/issues/1869[#1869] [diagrams] When creating a _Do action with referenced action_ on a `StateUsage`, ensure the corresponding compartments are revealed. === Improvements diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/INestedIndexEntry.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/INestedIndexEntry.java new file mode 100644 index 000000000..475dfba97 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/INestedIndexEntry.java @@ -0,0 +1,57 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import org.eclipse.sirius.web.application.index.services.api.IIndexEntry; + +/** + * A sub index entry contained in an {@link IIndexEntry}. + * + *

+ * Nested index entries are used inside regular {@link IIndexEntry} POJOs to represent elements connected to the elements represented by the {@link IIndexEntry}. In most cases, + * {@link INestedIndexEntry} should not contain other {@link INestedIndexEntry}, in order to avoid infinite nesting depth, which cannot be stored in the index. This constraint may be relaxed for + * {@link INestedIndexEntry} representing relationships, which can themselves contain an {@link INestedIndexEntry} representing the other end of the relationship. + *

+ *

+ * For example, an {@link IIndexEntry} representing a SysML element can contain {@link INestedIndexEntry} representing its owned elements, but these nested entries should not contain information + * related to their own nested elements. The same {@link IIndexEntry} can contain {@link INestedIndexEntry} representing its relationships, which themselves can contain {@link INestedIndexEntry} + * representing the other end of the relationship, but this second level of nested entries cannot contain another level of {@link INestedIndexEntry}. + *

+ *

+ * The fields {@code id}, {@code type}, and {@code label} should match the identifier of the semantic element, the name of its SysML type, and the label used to present it to the end user. Note that + * these fields are serialized with a {@code @} prefix because they represent technical information, and shouldn't clash with potential fields computed from attributes in the SysML metamodel. + * An extra field {@code @nestedIndexEntryType} is also produced during the serialization to store the concrete type of the POJO. This lets Sirius Web find the actual type to use when deserializing + * query results. + * {@link INestedIndexEntry} does not provide {@code iconURLs} nor {@code editingContextId}, because they are not directly manipulated by Sirius Web (their containing {@link IIndexEntry} are). + *

+ * + * @author gdaniel + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@nestedIndexEntryType") +public interface INestedIndexEntry { + + String NESTED_INDEX_ENTRY_TYPE_FIELD = "@nestedIndexEntryType"; + + @JsonProperty(IIndexEntry.ID_FIELD) + String id(); + + @JsonProperty(IIndexEntry.TYPE_FIELD) + String type(); + + @JsonProperty(IIndexEntry.LABEL_FIELD) + String label(); + +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/IndexEntrySwitch.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/IndexEntrySwitch.java new file mode 100644 index 000000000..5845d09d3 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/IndexEntrySwitch.java @@ -0,0 +1,145 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.emf.ecore.EClass; +import org.eclipse.emf.ecore.EClassifier; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.sirius.components.core.api.IEditingContext; +import org.eclipse.sirius.components.core.api.IIdentityService; +import org.eclipse.sirius.components.core.api.ILabelService; +import org.eclipse.sirius.web.application.index.services.api.IIndexEntry; +import org.eclipse.syson.sysml.Namespace; +import org.eclipse.syson.sysml.SysmlPackage; +import org.eclipse.syson.sysml.Type; +import org.eclipse.syson.sysml.util.SysmlSwitch; + +/** + * Provides {@link IIndexEntry} for SysML elements. + * + *

+ * {@link IIndexEntry} are top-level POJOs representing SysML elements that are stored in Elasticsearch indices. They can contain {@link INestedIndexEntry} to represent elements connected to the + * element they represent. + *

+ *

+ * SysON serializes SysML elements into entries that can contain nested entries with a maximum depth of: + *

+ *

+ * + * @author gdaniel + */ +public class IndexEntrySwitch extends SysmlSwitch> { + + private final IIdentityService identityService; + + private final ILabelService labelService; + + private final IEditingContext editingContext; + + private final NestedIndexEntrySwitch nestedIndexEntrySwitch; + + public IndexEntrySwitch(IIdentityService identityService, ILabelService labelService, IEditingContext editingContext) { + this.identityService = Objects.requireNonNull(identityService); + this.labelService = Objects.requireNonNull(labelService); + this.editingContext = Objects.requireNonNull(editingContext); + this.nestedIndexEntrySwitch = new NestedIndexEntrySwitch(identityService, labelService); + } + + @Override + public Optional doSwitch(EObject eObject) { + Optional result = Optional.empty(); + if (eObject != null) { + result = super.doSwitch(eObject); + } + return result; + } + + @Override + public Optional defaultCase(EObject object) { + return Optional.empty(); + } + + @Override + public Optional caseNamespace(Namespace namespace) { + String objectId = this.identityService.getId(namespace); + String type = this.getEClassifierName(namespace.eClass()); + String label = this.labelService.getStyledLabel(namespace).toString(); + List iconURLs = this.labelService.getImagePaths(namespace); + + String name = namespace.getName(); + String shortName = namespace.getShortName(); + String qualifiedName = namespace.getQualifiedName(); + + Optional ownerNestedIndexEntry = this.nestedIndexEntrySwitch.doSwitch(namespace.getOwner()); + List ownedElementNestedIndexEntries = namespace.getOwnedElement().stream() + .map(this.nestedIndexEntrySwitch::doSwitch) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + + return Optional.of(new NamespaceIndexEntry(this.editingContext.getId(), objectId, type, label, iconURLs, name, shortName, qualifiedName, ownerNestedIndexEntry.orElse(null), + ownedElementNestedIndexEntries)); + } + + @Override + public Optional caseType(Type type) { + String objectId = this.identityService.getId(type); + String objectType = this.getEClassifierName(type.eClass()); + String label = this.labelService.getStyledLabel(type).toString(); + List iconURLs = this.labelService.getImagePaths(type); + + String name = type.getName(); + String shortName = type.getShortName(); + String qualifiedName = type.getQualifiedName(); + + Optional ownerNestedIndexEntry = this.nestedIndexEntrySwitch.doSwitch(type.getOwner()); + List ownedElementNestedIndexEntries = type.getOwnedElement().stream() + .map(this.nestedIndexEntrySwitch::doSwitch) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + List ownedSpecializationNestedIndexEntries = type.getOwnedSpecialization().stream() + .map(this.nestedIndexEntrySwitch::doSwitch) + .filter(Optional::isPresent) + .map(Optional::get) + .filter(NestedSpecializationIndexEntry.class::isInstance) + .map(NestedSpecializationIndexEntry.class::cast) + .toList(); + + return Optional.of(new TypeIndexEntry(this.editingContext.getId(), objectId, objectType, label, iconURLs, name, shortName, qualifiedName, ownerNestedIndexEntry.orElse(null), + ownedSpecializationNestedIndexEntries, ownedElementNestedIndexEntries)); + } + + private String getEClassifierName(EClassifier eClassifier) { + String name = eClassifier.getName(); + if (eClassifier instanceof EClass eClass) { + if (SysmlPackage.eINSTANCE.getUsage().isSuperTypeOf(eClass) + && !SysmlPackage.eINSTANCE.getConnectorAsUsage().equals(eClass) + && !SysmlPackage.eINSTANCE.getBindingConnectorAsUsage().equals(eClass) + && !SysmlPackage.eINSTANCE.getSuccessionAsUsage().equals(eClass)) { + if (eClass.getName().endsWith("Usage")) { + name = eClass.getName().substring(0, eClass.getName().length() - 5); + } + } + } + return name; + } +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NamespaceIndexEntry.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NamespaceIndexEntry.java new file mode 100644 index 000000000..cd0ba1e41 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NamespaceIndexEntry.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +import java.util.List; + +import org.eclipse.sirius.web.application.index.services.api.IIndexEntry; + +/** + * An index entry representing a {@link org.eclipse.syson.sysml.Namespace}. + * + *

+ * This record contains relevant data related to a SysML Namespace (e.g. name, short name, qualified name), as well as technical information used by Sirius Web to present index entries to the end user + * (label, icons, type). + *

+ *

+ * This class contains {@link INestedIndexEntry}, which are additional objects serialized as sub-fields of this index entry, which allows to access information related to elements connected to the + * namespace (e.g. {@code owner.name} to access the name of the owner, or {@code ownedElement.name} to access the name of the owned elements). + *

+ * + * @param editingContextId the identifier of the editing context containing the namespace + * @param id the identifier of the namespace + * @param type the name of the concrete SysML type of the namespace + * @param label the label of the namespace + * @param iconURLs the URLs of the icons of the namespace + * @param name the name of the namespace + * @param shortName the short name of the namespace + * @param qualifiedName the qualified name of the namespace + * @param owner the nested entry holding information related to the namespace's owner + * @param ownedElement the nested entries holding information related to the namespace owned elements + * + * @author gdaniel + */ +public record NamespaceIndexEntry( + String editingContextId, + String id, + String type, + String label, + List iconURLs, + String name, + String shortName, + String qualifiedName, + INestedIndexEntry owner, + List ownedElement +) implements IIndexEntry { +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NestedElementIndexEntry.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NestedElementIndexEntry.java new file mode 100644 index 000000000..dfa240bc8 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NestedElementIndexEntry.java @@ -0,0 +1,40 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +/** + * An index entry representing a nested {@link org.eclipse.syson.sysml.Element}. + * + *

+ * This record does not contain recursive {@link INestedIndexEntry} in order to avoid infinite entry nesting when converting elements into index entries. + * See {@link INestedIndexEntry} for more information. + *

+ * + * @param id the identifier of the element + * @param type the name of the concrete SysML type of the element + * @param label the label of the element + * @param name the name of the element + * @param shortName the short name of the element + * @param qualifiedName the qualified name of the element + * + * @author gdaniel + */ +public record NestedElementIndexEntry( + String id, + String type, + String label, + String name, + String shortName, + String qualifiedName +) implements INestedIndexEntry { +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NestedIndexEntrySwitch.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NestedIndexEntrySwitch.java new file mode 100644 index 000000000..df8a3bd14 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NestedIndexEntrySwitch.java @@ -0,0 +1,112 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.emf.ecore.EClass; +import org.eclipse.emf.ecore.EClassifier; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.sirius.components.core.api.IIdentityService; +import org.eclipse.sirius.components.core.api.ILabelService; +import org.eclipse.syson.sysml.Element; +import org.eclipse.syson.sysml.Specialization; +import org.eclipse.syson.sysml.SysmlPackage; +import org.eclipse.syson.sysml.util.SysmlSwitch; + + +/** + * Provides {@link INestedIndexEntry} for SysML elements. + * + *

+ * {@link INestedIndexEntry} are used inside {@link org.eclipse.sirius.web.application.index.services.api.IIndexEntry} to represent elements associated to the element represented by their containing + * {@link org.eclipse.sirius.web.application.index.services.api.IIndexEntry}. They are usually not recursive, and may contain different information than their + * {@link org.eclipse.sirius.web.application.index.services.api.IIndexEntry} counterparts. + *

+ * + * @see INestedIndexEntry + * + * @author gdaniel + */ +public class NestedIndexEntrySwitch extends SysmlSwitch> { + + private final IIdentityService identityService; + + private final ILabelService labelService; + + public NestedIndexEntrySwitch(IIdentityService identityService, ILabelService labelService) { + this.identityService = Objects.requireNonNull(identityService); + this.labelService = Objects.requireNonNull(labelService); + } + + @Override + public Optional doSwitch(EObject eObject) { + Optional result = Optional.empty(); + if (eObject != null) { + result = super.doSwitch(eObject); + } + return result; + } + + @Override + public Optional defaultCase(EObject object) { + return Optional.empty(); + } + + + @Override + public Optional caseElement(Element element) { + String objectId = this.identityService.getId(element); + String type = this.getEClassifierName(element.eClass()); + String label = this.labelService.getStyledLabel(element).toString(); + + String name = element.getName(); + String shortName = element.getShortName(); + String qualifiedName = element.getQualifiedName(); + + return Optional.of(new NestedElementIndexEntry(objectId, type, label, name, shortName, qualifiedName)); + } + + @Override + public Optional caseSpecialization(Specialization specialization) { + String objectId = this.identityService.getId(specialization); + String type = this.getEClassifierName(specialization.eClass()); + String label = this.labelService.getStyledLabel(specialization).toString(); + + String name = specialization.getName(); + String shortName = specialization.getShortName(); + String qualifiedName = specialization.getQualifiedName(); + + Optional general = this.doSwitch(specialization.getGeneral()) + .filter(NestedElementIndexEntry.class::isInstance) + .map(NestedElementIndexEntry.class::cast); + + return Optional.of(new NestedSpecializationIndexEntry(objectId, type, label, name, shortName, qualifiedName, general.orElse(null))); + } + + private String getEClassifierName(EClassifier eClassifier) { + String name = eClassifier.getName(); + if (eClassifier instanceof EClass eClass) { + if (SysmlPackage.eINSTANCE.getUsage().isSuperTypeOf(eClass) + && !SysmlPackage.eINSTANCE.getConnectorAsUsage().equals(eClass) + && !SysmlPackage.eINSTANCE.getBindingConnectorAsUsage().equals(eClass) + && !SysmlPackage.eINSTANCE.getSuccessionAsUsage().equals(eClass)) { + if (eClass.getName().endsWith("Usage")) { + name = eClass.getName().substring(0, eClass.getName().length() - 5); + } + } + } + return name; + } +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NestedSpecializationIndexEntry.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NestedSpecializationIndexEntry.java new file mode 100644 index 000000000..2781e5658 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/NestedSpecializationIndexEntry.java @@ -0,0 +1,41 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +/** + * An index entry representing a nested {@link org.eclipse.syson.sysml.Specialization}. + * + *

+ * This record contains a {@link NestedElementIndexEntry} which allows to access information related to the {@code general} element of the specialization. Note that this nested element is not + * recursive, preventing infinite entry nesting when converting elements into index entries. See {@link INestedIndexEntry} for more information. + *

+ * + * @param id the identifier of the specialization + * @param type the name of the concrete SysML type of the specialization + * @param label the label of the specialization + * @param name the name of the specialization + * @param shortName the short name of the specialization + * @param qualifiedName the qualified name of the specialization + * + * @author gdaniel + */ +public record NestedSpecializationIndexEntry( + String id, + String type, + String label, + String name, + String shortName, + String qualifiedName, + NestedElementIndexEntry general +) implements INestedIndexEntry { +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/SysONIndexCreationService.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/SysONIndexCreationService.java new file mode 100644 index 000000000..415ea6e8d --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/SysONIndexCreationService.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.sirius.web.application.index.services.api.IIndexEntry; +import org.eclipse.sirius.web.application.studio.services.api.IStudioCapableEditingContextPredicate; +import org.eclipse.sirius.web.infrastructure.elasticsearch.services.DefaultIndexCreationService; +import org.eclipse.sirius.web.infrastructure.elasticsearch.services.api.IIndexCreationServiceDelegate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.ElasticsearchException; +import co.elastic.clients.elasticsearch._types.mapping.DynamicTemplate; +import co.elastic.clients.util.NamedValue; + +/** + * Creates indices to store SysML models. + * + *

+ * This service creates and configures the index associated to each editing context. The created index has a name matching the pattern {@code editing-context-}. + * This service is automatically called by Sirius Web when an editing context is created (e.g. when a project is created). + *

+ * + * @author gdaniel + */ +@Service +public class SysONIndexCreationService implements IIndexCreationServiceDelegate { + + private final IStudioCapableEditingContextPredicate studioCapableEditingContextPredicate; + + private final Optional optionalElasticSearchClient; + + private final Logger logger = LoggerFactory.getLogger(SysONIndexCreationService.class); + + public SysONIndexCreationService(IStudioCapableEditingContextPredicate studioCapableEditingContextPredicate, Optional optionalElasticSearchClient) { + this.studioCapableEditingContextPredicate = Objects.requireNonNull(studioCapableEditingContextPredicate); + this.optionalElasticSearchClient = Objects.requireNonNull(optionalElasticSearchClient); + } + + @Override + public boolean canHandle(String editingContextId) { + return !this.studioCapableEditingContextPredicate.test(editingContextId); + } + + @Override + public boolean createIndex(String editingContextId) { + boolean indexCreated = false; + if (this.optionalElasticSearchClient.isPresent()) { + ElasticsearchClient elasticSearchClient = this.optionalElasticSearchClient.get(); + try { + if (!elasticSearchClient.indices().exists(existsRequest -> existsRequest.index(DefaultIndexCreationService.EDITING_CONTEXT_INDEX_NAME_PREFIX + editingContextId)).value()) { + elasticSearchClient.indices().create(createIndexRequest -> createIndexRequest + .index(DefaultIndexCreationService.EDITING_CONTEXT_INDEX_NAME_PREFIX + editingContextId) + .settings(settingsBuilder -> settingsBuilder.mode("lookup")) + .mappings(mappingsBuilder -> mappingsBuilder + // Do not index nestedIndexEntryType field, this is technical information we don't want to search for. + // Note that we need to use a dynamic template here because multiple fields may exist containing "@nestedIndexEntryType", + // and we cannot use wildcards in field names when defining properties. + .dynamicTemplates(NamedValue.of("nestedIndexEntryTypeMapping", DynamicTemplate.of(dynamicTemplateBuilder -> + dynamicTemplateBuilder.matchMappingType("string") + .match("*" + INestedIndexEntry.NESTED_INDEX_ENTRY_TYPE_FIELD) + .mapping(mappingBuilder -> mappingBuilder + .text(textBuilder -> textBuilder.index(false))) + ))) + // Do not index iconURLs, editingContextId, or entryType, this is technical information we don't want to search for. + .properties(IIndexEntry.ICON_URLS_FIELD, propertyBuilder -> + propertyBuilder.text(textPropertyBuilder -> + textPropertyBuilder.index(false))) + .properties(IIndexEntry.EDITING_CONTEXT_ID_FIELD, propertyBuilder -> + propertyBuilder.text(textPropertyBuilder -> + textPropertyBuilder.index(false))) + .properties(IIndexEntry.INDEX_ENTRY_TYPE_FIELD, propertyBuilder -> + propertyBuilder.text(textPropertyBuilder -> + textPropertyBuilder.index(false))))); + indexCreated = true; + } + } catch (IOException | ElasticsearchException exception) { + this.logger.warn("An error occurred while creating the index", exception); + } + } + return indexCreated; + } +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/SysONIndexEntryProvider.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/SysONIndexEntryProvider.java new file mode 100644 index 000000000..4df15246f --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/SysONIndexEntryProvider.java @@ -0,0 +1,70 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.sirius.components.core.api.IEditingContext; +import org.eclipse.sirius.components.core.api.IIdentityService; +import org.eclipse.sirius.components.core.api.ILabelService; +import org.eclipse.sirius.web.application.index.services.api.IIndexEntry; +import org.eclipse.sirius.web.application.index.services.api.IIndexEntryProviderDelegate; +import org.eclipse.sirius.web.application.studio.services.api.IStudioCapableEditingContextPredicate; +import org.eclipse.syson.sysml.Element; +import org.springframework.stereotype.Service; + +/** + * Provides index entries for SysON. + * + *

+ * This service converts SysML {@link Element} into {@link IIndexEntry} that can be persisted in the Elasticsearch indices. + *

+ *

+ * Note that this service does not accept {@code editingContext} instances that contain Studio data. For these {@code editingContext}, the default implementation of + * {@link org.eclipse.sirius.web.application.index.services.api.IDefaultIndexEntryProvider} is used instead. + *

+ * + * @author gdaniel + */ +@Service +public class SysONIndexEntryProvider implements IIndexEntryProviderDelegate { + + private final IStudioCapableEditingContextPredicate studioCapableEditingContextPredicate; + + private final IIdentityService identityService; + + private final ILabelService labelService; + + public SysONIndexEntryProvider(IStudioCapableEditingContextPredicate studioCapableEditingContextPredicate, IIdentityService identityService, ILabelService labelService) { + this.studioCapableEditingContextPredicate = Objects.requireNonNull(studioCapableEditingContextPredicate); + this.identityService = Objects.requireNonNull(identityService); + this.labelService = Objects.requireNonNull(labelService); + } + + @Override + public boolean canHandle(IEditingContext editingContext, Object object) { + // Only produce SysON index entries for non-studio projects. + return !this.studioCapableEditingContextPredicate.test(editingContext.getId()); + } + + @Override + public Optional getIndexEntry(IEditingContext editingContext, Object object) { + Optional result = Optional.empty(); + if (object instanceof Element element) { + result = new IndexEntrySwitch(this.identityService, this.labelService, editingContext) + .doSwitch(element); + } + return result; + } +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/SysONIndexUpdateService.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/SysONIndexUpdateService.java new file mode 100644 index 000000000..04475832a --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/SysONIndexUpdateService.java @@ -0,0 +1,130 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.StreamSupport; + +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.sirius.components.core.api.IEditingContext; +import org.eclipse.sirius.components.emf.services.api.IEMFEditingContext; +import org.eclipse.sirius.web.application.index.services.api.IIndexEntry; +import org.eclipse.sirius.web.application.index.services.api.IIndexEntryProvider; +import org.eclipse.sirius.web.application.studio.services.api.IStudioCapableEditingContextPredicate; +import org.eclipse.sirius.web.infrastructure.elasticsearch.services.DefaultIndexCreationService; +import org.eclipse.sirius.web.infrastructure.elasticsearch.services.api.IIndexCreationService; +import org.eclipse.sirius.web.infrastructure.elasticsearch.services.api.IIndexDeletionService; +import org.eclipse.sirius.web.infrastructure.elasticsearch.services.api.IIndexUpdateServiceDelegate; +import org.eclipse.syson.sysml.util.ElementUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._helpers.bulk.BulkIngester; + +/** + * A service to index the content of SysML model. + * + *

+ * This service is called by Sirius Web when changes are detected in an editing context, which may require to update the associated indices. This implementation ensures that all the resources in the + * editing context are indexed, excepted SysML and KerML standard libraries for performance reasons. + *

+ *

+ * Note that this service does not accept {@code editingContext} instances that contain Studio data. For these {@code editingContext}, the default implementation of + * {@link org.eclipse.sirius.web.infrastructure.elasticsearch.services.api.IDefaultIndexUpdateService} is used instead. + *

+ * + * + * @author gdaniel + */ +@Service +public class SysONIndexUpdateService implements IIndexUpdateServiceDelegate { + + private final IStudioCapableEditingContextPredicate studioCapableEditingContextPredicate; + + private final IIndexCreationService indexCreationService; + + private final IIndexDeletionService indexDeletionService; + + private final IIndexEntryProvider indexEntryProvider; + + private final Optional optionalElasticSearchClient; + + private final Logger logger = LoggerFactory.getLogger(SysONIndexUpdateService.class); + + public SysONIndexUpdateService(IStudioCapableEditingContextPredicate studioCapableEditingContextPredicate, IIndexCreationService indexCreationService, IIndexDeletionService indexDeletionService, + IIndexEntryProvider indexEntryProvider, Optional optionalElasticSearchClient) { + this.studioCapableEditingContextPredicate = Objects.requireNonNull(studioCapableEditingContextPredicate); + this.indexCreationService = Objects.requireNonNull(indexCreationService); + this.indexDeletionService = Objects.requireNonNull(indexDeletionService); + this.indexEntryProvider = Objects.requireNonNull(indexEntryProvider); + this.optionalElasticSearchClient = Objects.requireNonNull(optionalElasticSearchClient); + } + + @Override + public boolean canHandle(IEditingContext editingContext) { + return !this.studioCapableEditingContextPredicate.test(editingContext.getId()); + } + + @Override + public void updateIndex(IEditingContext editingContext) { + long start = System.nanoTime(); + if (this.optionalElasticSearchClient.isPresent()) { + if (editingContext instanceof IEMFEditingContext emfEditingContext) { + this.clearIndex(editingContext); + BulkIngester bulkIngester = BulkIngester.of(bulkIngesterBuilder -> bulkIngesterBuilder + .client(this.optionalElasticSearchClient.get()) + ); + // We don't want to index SysML/KerML standard libraries: they are duplicated in each project and pollutes the search. + List resourcesToIndex = emfEditingContext.getDomain().getResourceSet().getResources().stream() + .filter(resource -> !ElementUtil.isStandardLibraryResource(resource)) + .toList(); + for (Resource resourceToIndex : resourcesToIndex) { + StreamSupport.stream(Spliterators.spliteratorUnknownSize(resourceToIndex.getAllContents(), Spliterator.ORDERED), false) + .forEach(eObject -> { + Optional optionalIndexEntry = this.indexEntryProvider.getIndexEntry(editingContext, eObject); + if (optionalIndexEntry.isPresent()) { + IIndexEntry indexEntry = optionalIndexEntry.get(); + bulkIngester.add(bulkOperation -> bulkOperation + .index(indexOperation -> indexOperation + .index(DefaultIndexCreationService.EDITING_CONTEXT_INDEX_NAME_PREFIX + editingContext.getId()) + .id(indexEntry.id()) + .document(indexEntry) + ) + ); + } + }); + } + bulkIngester.close(); + } + } + Duration timeToIndexEditingContext = Duration.ofNanos(System.nanoTime() - start); + this.logger.trace("Indexed editing context {} in {}ms", editingContext.getId(), timeToIndexEditingContext.toMillis()); + } + + private void clearIndex(IEditingContext editingContext) { + /* + * In some cases (particularly when a new project is created), this line may throw an exception indicating that the index does not exist. This exception doesn't crash the application, it just + * prevents an update of the index, which is not critical (it will be updated later when the semantic data gets updated). + * This issue is linked to https://github.com/eclipse-sirius/sirius-web/issues/6044, and will disappear once it is fixed in Sirius Web. + */ + this.indexDeletionService.deleteIndex(editingContext.getId()); + this.indexCreationService.createIndex(editingContext.getId()); + } +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/TypeIndexEntry.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/TypeIndexEntry.java new file mode 100644 index 000000000..bd9a155e9 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/index/TypeIndexEntry.java @@ -0,0 +1,58 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.index; + +import java.util.List; + +import org.eclipse.sirius.web.application.index.services.api.IIndexEntry; + +/** + * An index entry representing a {@link org.eclipse.syson.sysml.Type}. + * + *

+ * This record contains relevant data related to a SysML Type (e.g. name, short name, qualified name), as well as technical information used by Sirius Web to present index entries to the end user + * (label, icons, type). + *

+ *

+ * This class contains {@link INestedIndexEntry}, which are additional objects serialized as sub-fields of this index entry, which allows to access information related to elements connected to the + * type (e.g. {@code owner.name} to access the name of the owner, or {@code ownedSpecialization.general.name} to access the name of the element at the other end of an owned specialization). + *

+ * + * @param editingContextId the identifier of the editing context containing the type + * @param id the identifier of the type + * @param type the name of the concrete SysML type of the element + * @param label the label of the type + * @param iconURLs the URLs of the icons of the type + * @param name the name of the type + * @param shortName the short name of the type + * @param qualifiedName the qualified name of the type + * @param owner the nested entry holding information related to the type's owner + * @param ownedSpecialization the nested entries holding information related to the type owned specializations + * @param ownedElement the nested entries holding information related to the type owned elements + * + * @author gdaniel + */ +public record TypeIndexEntry( + String editingContextId, + String id, + String type, + String label, + List iconURLs, + String name, + String shortName, + String qualifiedName, + INestedIndexEntry owner, + List ownedSpecialization, + List ownedElement +) implements IIndexEntry { +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONLibraryPublicationHandler.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONLibraryPublicationHandler.java index 8073871fe..d2479356b 100644 --- a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONLibraryPublicationHandler.java +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONLibraryPublicationHandler.java @@ -12,91 +12,50 @@ *******************************************************************************/ package org.eclipse.syson.application.publication; -import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; -import org.eclipse.emf.ecore.EObject; -import org.eclipse.emf.ecore.resource.Resource; -import org.eclipse.emf.ecore.resource.ResourceSet; import org.eclipse.sirius.components.core.api.ErrorPayload; import org.eclipse.sirius.components.core.api.IEditingContextSearchService; import org.eclipse.sirius.components.core.api.IPayload; -import org.eclipse.sirius.components.core.api.SuccessPayload; -import org.eclipse.sirius.components.emf.ResourceMetadataAdapter; import org.eclipse.sirius.components.emf.services.api.IEMFEditingContext; -import org.eclipse.sirius.components.events.ICause; import org.eclipse.sirius.components.representations.Message; import org.eclipse.sirius.components.representations.MessageLevel; -import org.eclipse.sirius.web.application.editingcontext.services.DocumentData; -import org.eclipse.sirius.web.application.editingcontext.services.EPackageEntry; -import org.eclipse.sirius.web.application.editingcontext.services.api.IResourceToDocumentService; import org.eclipse.sirius.web.application.library.dto.PublishLibrariesInput; -import org.eclipse.sirius.web.application.library.services.LibraryMetadataAdapter; +import org.eclipse.sirius.web.application.library.services.api.ILibraryApplicationService; import org.eclipse.sirius.web.application.library.services.api.ILibraryPublicationHandler; import org.eclipse.sirius.web.application.project.services.api.IProjectEditingContextService; -import org.eclipse.sirius.web.application.studio.services.library.api.DependencyGraph; -import org.eclipse.sirius.web.domain.boundedcontexts.library.Library; -import org.eclipse.sirius.web.domain.boundedcontexts.library.services.api.ILibrarySearchService; -import org.eclipse.sirius.web.domain.boundedcontexts.project.Project; import org.eclipse.sirius.web.domain.boundedcontexts.project.services.api.IProjectSearchService; -import org.eclipse.sirius.web.domain.boundedcontexts.semanticdata.Document; -import org.eclipse.sirius.web.domain.boundedcontexts.semanticdata.SemanticData; -import org.eclipse.sirius.web.domain.boundedcontexts.semanticdata.services.api.ISemanticDataCreationService; -import org.eclipse.sirius.web.domain.services.IResult; -import org.eclipse.sirius.web.domain.services.Success; -import org.eclipse.syson.application.publication.api.ISysONLibraryDependencyCollector; -import org.eclipse.syson.sysml.SysmlPackage; -import org.eclipse.syson.sysml.util.ElementUtil; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.data.jdbc.core.mapping.AggregateReference; +import org.eclipse.syson.application.publication.api.ISysMLLibraryPublisher; import org.springframework.stereotype.Service; /** * {@link ILibraryPublicationHandler} for publishing libraries in SysON. * + * @see ILibraryApplicationService * @see SysONLibraryPublicationListener * @author flatombe */ @Service public class SysONLibraryPublicationHandler implements ILibraryPublicationHandler { - private static final Logger LOGGER = LoggerFactory.getLogger(SysONLibraryPublicationHandler.class); private final IEditingContextSearchService editingContextSearchService; private final IProjectEditingContextService projectEditingContextService; - private final ISemanticDataCreationService semanticDataCreationService; - - private final IResourceToDocumentService resourceToDocumentService; - private final IProjectSearchService projectSearchService; - private final ILibrarySearchService librarySearchService; - - private final ISysONLibraryDependencyCollector sysonLibraryDependencyCollector; + private final ISysMLLibraryPublisher sysONSysMLLibraryPublisher; public SysONLibraryPublicationHandler(final IEditingContextSearchService editingContextSearchService, final IProjectEditingContextService projectEditingContextService, - final ISemanticDataCreationService semanticDataCreationService, - final IResourceToDocumentService resourceToDocumentService, final IProjectSearchService projectSearchService, - final ILibrarySearchService librarySearchService, - final ISysONLibraryDependencyCollector sysonLibraryDependencyCollector) { - this.projectSearchService = Objects.requireNonNull(projectSearchService); - this.projectEditingContextService = Objects.requireNonNull(projectEditingContextService); + final ISysMLLibraryPublisher sysONSysMLLibraryPublisher) { this.editingContextSearchService = Objects.requireNonNull(editingContextSearchService); - this.semanticDataCreationService = Objects.requireNonNull(semanticDataCreationService); - this.resourceToDocumentService = Objects.requireNonNull(resourceToDocumentService); - this.librarySearchService = Objects.requireNonNull(librarySearchService); - this.sysonLibraryDependencyCollector = Objects.requireNonNull(sysonLibraryDependencyCollector); + this.projectEditingContextService = Objects.requireNonNull(projectEditingContextService); + this.projectSearchService = Objects.requireNonNull(projectSearchService); + this.sysONSysMLLibraryPublisher = Objects.requireNonNull(sysONSysMLLibraryPublisher); } @Override @@ -106,6 +65,7 @@ public boolean canHandle(final PublishLibrariesInput input) { @Override public IPayload handle(final PublishLibrariesInput input) { + IPayload payload = null; var editingContextId = input.editingContextId(); var optionalProjectId = this.projectEditingContextService.getProjectId(editingContextId); @@ -117,7 +77,13 @@ public IPayload handle(final PublishLibrariesInput input) { .map(IEMFEditingContext.class::cast); if (optionalProject.isPresent() && optionalEditingContext.isPresent()) { - payload = this.handle(input, optionalProject.get(), optionalEditingContext.get().getDomain().getResourceSet()); + payload = this.sysONSysMLLibraryPublisher.publish( + input, + optionalEditingContext.get(), + projectId, + optionalProject.get().getName(), + input.version(), + input.description()); } else { payload = new ErrorPayload(input.id(), List.of(new Message("Could not find project with following editingContextId '%s'.".formatted(editingContextId), MessageLevel.ERROR))); } @@ -126,140 +92,4 @@ public IPayload handle(final PublishLibrariesInput input) { } return payload; } - - protected IPayload handle(final PublishLibrariesInput input, final Project project, final ResourceSet resourceSet) { - final IPayload result; - - final String libraryName = project.getName(); - final String libraryVersion = input.version(); - - if (this.librarySearchService.findByNamespaceAndNameAndVersion(project.getId(), libraryName, libraryVersion).isPresent()) { - // There is already a Library from our namespace with that name and version. - result = new ErrorPayload(input.id(), - List.of(new Message("Library '%s' (version '%s') already exists in namespace '%s'.".formatted(libraryName, libraryVersion, project.getId()), MessageLevel.ERROR))); - } else { - final Set resourcesToPublish = this.getProperSysMLRootContents(resourceSet); - if (!resourcesToPublish.isEmpty()) { - DependencyGraph dependencyGraph = this.sysonLibraryDependencyCollector.collectDependencies(resourceSet); - - List> dependencies = this.getDependencies(dependencyGraph, resourcesToPublish); - - final Optional maybePublishedLibrarySemanticData = this.publishAsLibrary(input, resourcesToPublish, libraryName, libraryVersion, dependencies); - // After this transaction is done, SysONLibraryPublicationListener reacts by also creating the - // associated Library metadata. - - result = maybePublishedLibrarySemanticData - .map(publishedLibrary -> (IPayload) new SuccessPayload(input.id(), - List.of(new Message( - "Successfully published the SysML contents of project '%s' as version '%s' of library '%s'.".formatted(project.getName(), - libraryVersion, - libraryName), - MessageLevel.SUCCESS)))) - .orElseGet( - () -> new ErrorPayload(input.id(), - List.of(new Message("Failed to publish the SysML contents of project '%s' as a library.".formatted(project.getName(), project.getId()), - MessageLevel.ERROR)))); - } else { - result = new ErrorPayload(input.id(), - List.of(new Message("There are no SysML contents in project '%s' to publish as library.".formatted(project.getName()), MessageLevel.ERROR))); - } - } - return result; - } - - private List> getDependencies(DependencyGraph dependencyGraph, final Set resourcesToPublish) { - List> dependencies = new ArrayList<>(); - - for (Resource resourceToPublish : resourcesToPublish) { - for (Resource dependencyCandidate : dependencyGraph.getDependencies(resourceToPublish)) { - Optional optionalLibraryMetadata = dependencyCandidate.eAdapters().stream() - .filter(LibraryMetadataAdapter.class::isInstance) - .map(LibraryMetadataAdapter.class::cast) - .findFirst(); - if (optionalLibraryMetadata.isPresent()) { - LibraryMetadataAdapter libraryMetadataAdapter = optionalLibraryMetadata.get(); - this.librarySearchService.findByNamespaceAndNameAndVersion(libraryMetadataAdapter.getNamespace(), libraryMetadataAdapter.getName(), libraryMetadataAdapter.getVersion()) - .map(Library::getSemanticData) - .ifPresentOrElse(dependencies::add, () -> LOGGER.warn("Cannot retrieve library {}:{}:{}", libraryMetadataAdapter.getNamespace(), libraryMetadataAdapter.getName(), - libraryMetadataAdapter.getVersion())); - } - // Ignore the resource if it isn't a library: all non-library resources are published in a single - // library in SysON. - } - } - return dependencies.stream() - .distinct() - .toList(); - } - - protected Optional publishAsLibrary(final ICause parentCause, final Set resources, final String name, final String version, - final List> dependencies) { - final ICause cause = new SysONPublishedLibrarySemanticDataCreationRequested(parentCause, name); - // Remove the imported flag on the resource: the resource is now a library, and its read-only/read-write nature - // should be determined by its import kind (reference or copy), and not whether it was imported from a textual - // SysML file in the first place. - resources.forEach(resource -> ElementUtil.setIsImported(resource, false)); - return this.createSemanticData(cause, resources, dependencies); - } - - protected Optional createSemanticData(final ICause event, final Collection resources, final List> dependencies) { - final List documentDatas = resources.stream() - .map(resource -> this.resourceToDocumentService.toDocument(resource, false)) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); - final List documents = documentDatas.stream() - .map(DocumentData::document) - .toList(); - final List domains = documentDatas.stream() - .map(DocumentData::ePackageEntries) - .flatMap(List::stream) - .map(EPackageEntry::nsURI) - .distinct() - .toList(); - - final IResult creationResult = this.semanticDataCreationService.create(event, documents, domains, dependencies); - if (creationResult instanceof Success creationSuccess) { - return Optional.ofNullable(creationSuccess.data()); - } else { - return Optional.empty(); - } - } - - protected Set getProperSysMLRootContents(final ResourceSet resourceSet) { - Objects.requireNonNull(resourceSet); - - final List dependencies = this.getDependenciesToPublishedLibraries(resourceSet); - return resourceSet.getResources().stream() - .filter(resource -> !dependencies.contains(resource)) - .collect(Collectors.toSet()); - } - - protected List getDependenciesToPublishedLibraries(ResourceSet resourceSet) { - return resourceSet.getResources().stream() - .filter(resource -> this.getLibraryMetadata(resource).isPresent() - || resource.getURI().scheme().equals(ElementUtil.KERML_LIBRARY_SCHEME) - || resource.getURI().scheme().equals(ElementUtil.SYSML_LIBRARY_SCHEME)) - .toList(); - } - - protected Optional getLibraryMetadata(final Resource resource) { - return resource.eAdapters().stream() - .filter(LibraryMetadataAdapter.class::isInstance) - .map(LibraryMetadataAdapter.class::cast) - .findFirst(); - } - - protected boolean isSysmlContent(final EObject rootEObject) { - return rootEObject.eClass().getEPackage() == SysmlPackage.eINSTANCE; - } - - protected String getResourceName(final Resource resource) { - return resource.eAdapters().stream() - .filter(ResourceMetadataAdapter.class::isInstance) - .map(ResourceMetadataAdapter.class::cast) - .map(ResourceMetadataAdapter::getName) - .findFirst() - .orElse(null); - } } diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONLibraryPublicationListener.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONLibraryPublicationListener.java index c641994b1..ae0dfbb18 100644 --- a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONLibraryPublicationListener.java +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONLibraryPublicationListener.java @@ -14,14 +14,10 @@ import java.util.Objects; -import org.eclipse.sirius.web.application.library.dto.PublishLibrariesInput; -import org.eclipse.sirius.web.application.project.services.api.IProjectEditingContextService; import org.eclipse.sirius.web.domain.boundedcontexts.library.Library; import org.eclipse.sirius.web.domain.boundedcontexts.library.services.api.ILibraryCreationService; import org.eclipse.sirius.web.domain.boundedcontexts.semanticdata.SemanticData; import org.eclipse.sirius.web.domain.boundedcontexts.semanticdata.events.SemanticDataCreatedEvent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.data.jdbc.core.mapping.AggregateReference; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -36,37 +32,26 @@ @Service public class SysONLibraryPublicationListener { - private static final Logger LOGGER = LoggerFactory.getLogger(SysONLibraryPublicationListener.class); - - private final IProjectEditingContextService projectEditingContextService; - private final ILibraryCreationService libraryCreationService; - public SysONLibraryPublicationListener(final IProjectEditingContextService projectEditingContextService, final ILibraryCreationService libraryCreationService) { - this.projectEditingContextService = Objects.requireNonNull(projectEditingContextService); + public SysONLibraryPublicationListener(final ILibraryCreationService libraryCreationService) { this.libraryCreationService = Objects.requireNonNull(libraryCreationService); } @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener public void onSemanticDataCreatedEvent(final SemanticDataCreatedEvent semanticDataCreatedEvent) { - if (semanticDataCreatedEvent.causedBy() instanceof SysONPublishedLibrarySemanticDataCreationRequested request - && request.causedBy() instanceof PublishLibrariesInput publishLibrariesInput) { - var createdSemanticData = semanticDataCreatedEvent.semanticData(); - var editingContextId = publishLibrariesInput.editingContextId(); - var optProjectId = this.projectEditingContextService.getProjectId(editingContextId); - if (optProjectId.isPresent()) { - Library createdLibrary = Library.newLibrary() - .namespace(optProjectId.get()) - .name(request.libraryName()) - .semanticData(AggregateReference.to(createdSemanticData.getId())) - .version(publishLibrariesInput.version()) - .description(publishLibrariesInput.description()) - .build(semanticDataCreatedEvent); - this.libraryCreationService.createLibrary(createdLibrary); - } else { - LOGGER.warn("Cannot create library from the editingContextId {}", editingContextId); - } + if (semanticDataCreatedEvent.causedBy() instanceof SysONPublishedLibrarySemanticDataCreationRequested request) { + final SemanticData createdSemanticData = semanticDataCreatedEvent.semanticData(); + + final Library createdLibrary = Library.newLibrary() + .namespace(request.libraryNamespace()) + .name(request.libraryName()) + .version(request.libraryVersion()) + .description(request.libraryDescription()) + .semanticData(AggregateReference.to(createdSemanticData.getId())) + .build(semanticDataCreatedEvent); + this.libraryCreationService.createLibrary(createdLibrary); } } } diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONPublishedLibrarySemanticDataCreationRequested.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONPublishedLibrarySemanticDataCreationRequested.java index bd330cb2b..b62f1241d 100644 --- a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONPublishedLibrarySemanticDataCreationRequested.java +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONPublishedLibrarySemanticDataCreationRequested.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2025 Obeo. + * Copyright (c) 2025, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -27,16 +27,23 @@ public record SysONPublishedLibrarySemanticDataCreationRequested( UUID id, ICause causedBy, - String libraryName) implements ICause { + String libraryNamespace, + String libraryName, + String libraryVersion, + String libraryDescription) implements ICause { - public SysONPublishedLibrarySemanticDataCreationRequested(final ICause cause, final String libraryName) { - this(UUID.randomUUID(), cause, libraryName); + public SysONPublishedLibrarySemanticDataCreationRequested(final ICause cause, final String libraryNamespace, final String libraryName, final String libraryVersion, + final String libraryDescription) { + this(UUID.randomUUID(), cause, libraryNamespace, libraryName, libraryVersion, libraryDescription); } public SysONPublishedLibrarySemanticDataCreationRequested { Objects.requireNonNull(id); Objects.requireNonNull(causedBy); + Objects.requireNonNull(libraryNamespace); Objects.requireNonNull(libraryName); + Objects.requireNonNull(libraryVersion); + Objects.requireNonNull(libraryDescription); } } diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONSysMLLibraryPublisher.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONSysMLLibraryPublisher.java new file mode 100644 index 000000000..bd5b0a317 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/SysONSysMLLibraryPublisher.java @@ -0,0 +1,243 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.publication; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.emf.ecore.resource.ResourceSet; +import org.eclipse.sirius.components.core.api.ErrorPayload; +import org.eclipse.sirius.components.core.api.IEditingContext; +import org.eclipse.sirius.components.core.api.IPayload; +import org.eclipse.sirius.components.core.api.SuccessPayload; +import org.eclipse.sirius.components.emf.services.api.IEMFEditingContext; +import org.eclipse.sirius.components.events.ICause; +import org.eclipse.sirius.components.representations.Message; +import org.eclipse.sirius.components.representations.MessageLevel; +import org.eclipse.sirius.web.application.editingcontext.services.DocumentData; +import org.eclipse.sirius.web.application.editingcontext.services.EPackageEntry; +import org.eclipse.sirius.web.application.editingcontext.services.api.IResourceToDocumentService; +import org.eclipse.sirius.web.application.library.services.LibraryMetadataAdapter; +import org.eclipse.sirius.web.application.studio.services.library.api.DependencyGraph; +import org.eclipse.sirius.web.domain.boundedcontexts.library.Library; +import org.eclipse.sirius.web.domain.boundedcontexts.library.services.api.ILibrarySearchService; +import org.eclipse.sirius.web.domain.boundedcontexts.semanticdata.Document; +import org.eclipse.sirius.web.domain.boundedcontexts.semanticdata.SemanticData; +import org.eclipse.sirius.web.domain.boundedcontexts.semanticdata.services.api.ISemanticDataCreationService; +import org.eclipse.sirius.web.domain.services.IResult; +import org.eclipse.sirius.web.domain.services.Success; +import org.eclipse.syson.application.omnibox.api.IPredicateCanEditingContextPublishSysMLProject; +import org.eclipse.syson.application.publication.api.ISysMLLibraryPublisher; +import org.eclipse.syson.application.publication.api.ISysONLibraryDependencyCollector; +import org.eclipse.syson.application.services.SysONResourceService; +import org.eclipse.syson.services.api.ISysONResourceService; +import org.eclipse.syson.sysml.util.ElementUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.jdbc.core.mapping.AggregateReference; +import org.springframework.stereotype.Service; + +/** + * SysON implementation of {@link ISysMLLibraryPublisher}. + *

+ * It publishes as one library all the {@link Resource resources} of the given {@link ResourceSet} matching the + * following criteria: + *

    + *
  • The {@link Resource} is not from a library + * ({@link SysONResourceService#isFromReferencedLibrary(IEditingContext, Resource) published} or + * {@link ElementUtil#isStandardLibraryResource(Resource) standard})
  • + *
  • The {@link Resource} is {@link SysONResourceService#isSysML(Resource) identified as a SysML resource}
  • + *
+ * Note: Attempting to publish a library that already exists (exact same {@code namespace}, {@code name} and + * {@code version}) will result in an error. + * + * @author flatombe + */ +@Service +public class SysONSysMLLibraryPublisher implements ISysMLLibraryPublisher { + private static final Logger LOGGER = LoggerFactory.getLogger(SysONSysMLLibraryPublisher.class); + + private final ILibrarySearchService librarySearchService; + + private final IPredicateCanEditingContextPublishSysMLProject predicateCanEditingContextPublishSysMLProject; + + private final IResourceToDocumentService resourceToDocumentService; + + private final ISemanticDataCreationService semanticDataCreationService; + + private final ISysONLibraryDependencyCollector sysONLibraryDependencyCollector; + + private final ISysONResourceService sysONResourceService; + + public SysONSysMLLibraryPublisher(final ILibrarySearchService librarySearchService, final IPredicateCanEditingContextPublishSysMLProject predicateCanEditingContextPublishSysMLProject, + final IResourceToDocumentService resourceToDocumentService, final ISemanticDataCreationService semanticDataCreationService, + final ISysONLibraryDependencyCollector sysonLibraryDependencyCollector, final ISysONResourceService sysONResourceService) { + this.librarySearchService = Objects.requireNonNull(librarySearchService); + this.predicateCanEditingContextPublishSysMLProject = Objects.requireNonNull(predicateCanEditingContextPublishSysMLProject); + this.resourceToDocumentService = Objects.requireNonNull(resourceToDocumentService); + this.semanticDataCreationService = Objects.requireNonNull(semanticDataCreationService); + this.sysONLibraryDependencyCollector = Objects.requireNonNull(sysonLibraryDependencyCollector); + this.sysONResourceService = Objects.requireNonNull(sysONResourceService); + } + + @Override + public IPayload publish(final ICause cause, final IEditingContext libraryAuthoringEditingContext, final String libraryNamespace, final String libraryName, + final String libraryVersion, final String libraryDescription) { + final IPayload result; + + if (!this.predicateCanEditingContextPublishSysMLProject.test(libraryAuthoringEditingContext.getId())) { + result = new ErrorPayload(cause.id(), + List.of(new Message("Cannot publish SysML library from editing context '%s'.".formatted(libraryAuthoringEditingContext.getId()), MessageLevel.ERROR))); + } else if (this.librarySearchService.findByNamespaceAndNameAndVersion(libraryNamespace, libraryName, libraryVersion).isPresent()) { + // The Sirius Web lifecycle for published libraries relies on their immutability, so we want to prevent the + // publication from happening. + result = new ErrorPayload(cause.id(), + List.of(new Message("Library '%s:%s@%s' already exists.".formatted(libraryNamespace, libraryName, libraryVersion), MessageLevel.ERROR))); + } else if (!(libraryAuthoringEditingContext instanceof IEMFEditingContext)) { + result = new ErrorPayload(cause.id(), + List.of(new Message( + "Editing context '%s' is of an unsupported type: %s".formatted(libraryAuthoringEditingContext.getId(), libraryAuthoringEditingContext.getClass().getCanonicalName()), + MessageLevel.ERROR))); + } else { + result = this.doPublish(cause, (IEMFEditingContext) libraryAuthoringEditingContext, libraryNamespace, libraryName, libraryVersion, libraryDescription); + } + return result; + } + + /** + * Publishes all the proper SysML contents of an editing context as a single library with the specified identity + * (namespace, name and version) and description. + * + * @param cause + * the (non-{@code null}) originating {@link ICause}. + * @param emfEditingContext + * the (non-{@code null}) {@link IEMFEditingContext} that authors the library. + * @param libraryNamespace + * the (non-{@code null}) desired {@link Library#getNamespace() namespace} for the library to publish. + * @param libraryName + * the (non-{@code null}) desired {@link Library#getName() name} for the library to publish. + * @param libraryVersion + * the (non-{@code null}) desired {@link Library#getVersion() version} for the library to publish. + * @param libraryDescription + * the (non-{@code null}) desired {@link Library#getDescription() description} for the library to + * publish. + * @return the (non-{@code null}) resulting {@link IPayload}. + */ + protected IPayload doPublish(final ICause cause, final IEMFEditingContext emfEditingContext, final String libraryNamespace, final String libraryName, + final String libraryVersion, final String libraryDescription) { + final Set resourcesToPublish = this.getResourcesToPublish(emfEditingContext); + final DependencyGraph dependencyGraph = this.sysONLibraryDependencyCollector.collectDependencies(emfEditingContext.getDomain().getResourceSet()); + + final List> dependencies = this.getDependencies(dependencyGraph, resourcesToPublish); + + final Optional maybePublishedLibrarySemanticData = this.publishAsLibrary(cause, resourcesToPublish, libraryNamespace, libraryName, libraryVersion, libraryDescription, + dependencies); + // After this transaction is done, SysONLibraryPublicationListener reacts by also creating the + // associated Library metadata. + + return maybePublishedLibrarySemanticData + .map(publishedLibrarySemanticData -> (IPayload) new SuccessPayload(cause.id(), + List.of(new Message("Successfully published library '%s:%s@%s'.".formatted(libraryNamespace, libraryName, libraryVersion), MessageLevel.SUCCESS)))) + .orElseGet( + () -> new ErrorPayload(cause.id(), + List.of(new Message("Failed to publish library '%s:%s@%s'.".formatted(libraryNamespace, libraryName, libraryVersion), MessageLevel.ERROR)))); + } + + protected List> getDependencies(final DependencyGraph dependencyGraph, final Set resourcesToPublish) { + final List> dependencies = new ArrayList<>(); + + for (final Resource resourceToPublish : resourcesToPublish) { + for (final Resource dependencyCandidate : dependencyGraph.getDependencies(resourceToPublish)) { + // This Resource is a "dependency" in the EMF sense of the word. + // It may be a proper Resource of the ResourceSet, or a Resource present in the ResourceSet because it + // originally belongs to a published library (a dependency in the Sirius Web sense of the word). + // Since this implementation publishes all proper Resources of the ResourceSet into a single library, we + // only need to look for the Resources which come from the published libraries we have in dependencies. + this.getLibraryMetadata(dependencyCandidate).ifPresent(libraryMetadataAdapter -> { + this.librarySearchService.findByNamespaceAndNameAndVersion( + libraryMetadataAdapter.getNamespace(), + libraryMetadataAdapter.getName(), + libraryMetadataAdapter.getVersion()) + .map(Library::getSemanticData) + .ifPresentOrElse(dependencies::add, + () -> LOGGER.warn("Cannot retrieve contents of library '%s:%s@%s'" + .formatted(libraryMetadataAdapter.getNamespace(), libraryMetadataAdapter.getName(), + libraryMetadataAdapter.getVersion()))); + }); + } + } + return dependencies.stream() + .distinct() + .toList(); + } + + protected Optional publishAsLibrary(final ICause parentCause, final Collection resources, final String libraryNamespace, final String libraryName, + final String libraryVersion, final String libraryDescription, final List> dependencies) { + // Remove the imported flag on the resource: the resource is now a library, and its read-only/read-write nature + // should be determined by its import kind (reference or copy), and not whether it was imported from a textual + // SysML file in the first place. + resources.forEach(resource -> ElementUtil.setIsImported(resource, false)); + + final ICause cause = new SysONPublishedLibrarySemanticDataCreationRequested(parentCause, libraryNamespace, libraryName, libraryVersion, libraryDescription); + return this.createSemanticData(cause, resources, dependencies); + } + + protected Optional createSemanticData(final ICause cause, final Collection resources, final List> dependencies) { + final List documentDatas = resources.stream() + .map(resource -> this.resourceToDocumentService.toDocument(resource, false)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + final List documents = documentDatas.stream() + .map(DocumentData::document) + .toList(); + final List domains = documentDatas.stream() + .map(DocumentData::ePackageEntries) + .flatMap(List::stream) + .map(EPackageEntry::nsURI) + .distinct() + .toList(); + + final IResult creationResult = this.semanticDataCreationService.create(cause, documents, domains, dependencies); + if (creationResult instanceof Success creationSuccess) { + return Optional.ofNullable(creationSuccess.data()); + } else { + return Optional.empty(); + } + } + + protected Set getResourcesToPublish(final IEMFEditingContext emfEditingContext) { + return emfEditingContext.getDomain().getResourceSet().getResources().stream() + .filter(Predicate.not(ElementUtil::isStandardLibraryResource)) + .filter(resource -> !this.sysONResourceService.isFromReferencedLibrary(emfEditingContext, resource)) + // Only the ".sysml" resources can be published. + .filter(this.sysONResourceService::isSysML) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + protected Optional getLibraryMetadata(final Resource resource) { + return resource.eAdapters().stream() + .filter(LibraryMetadataAdapter.class::isInstance) + .map(LibraryMetadataAdapter.class::cast) + .findFirst(); + } +} diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/api/ISysMLLibraryPublisher.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/api/ISysMLLibraryPublisher.java new file mode 100644 index 000000000..8dc4db8c4 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/publication/api/ISysMLLibraryPublisher.java @@ -0,0 +1,27 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.publication.api; + +import org.eclipse.sirius.components.core.api.IEditingContext; +import org.eclipse.sirius.components.core.api.IPayload; +import org.eclipse.sirius.components.events.ICause; +import org.eclipse.sirius.web.domain.boundedcontexts.library.Library; + +/** + * Publishes the proper SysML contents of an {@link IEditingContext} as a {@link Library}. + * + * @author flatombe + */ +public interface ISysMLLibraryPublisher { + IPayload publish(ICause cause, IEditingContext libraryAuthoringEditingContext, String libraryNamespace, String libraryName, String libraryVersion, String libraryDescription); +} diff --git a/backend/application/syson-application/pom.xml b/backend/application/syson-application/pom.xml index a105b8873..11e0d7acb 100644 --- a/backend/application/syson-application/pom.xml +++ b/backend/application/syson-application/pom.xml @@ -185,6 +185,11 @@ postgresql test + + org.testcontainers + elasticsearch + test + org.testcontainers junit-jupiter diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/AbstractIntegrationTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/AbstractIntegrationTests.java index 2ab74b2cf..37cd57b3e 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/AbstractIntegrationTests.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/AbstractIntegrationTests.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -18,6 +18,7 @@ import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.elasticsearch.ElasticsearchContainer; /** * Superclass of all the integration tests used to setup the test environment. @@ -28,9 +29,17 @@ public abstract class AbstractIntegrationTests { public static final PostgreSQLContainer POSTGRESQL_CONTAINER; + public static final ElasticsearchContainer ELASTICSEARCH_CONTAINER; + + static { POSTGRESQL_CONTAINER = new PostgreSQLContainer<>("postgres:latest").withReuse(true); POSTGRESQL_CONTAINER.start(); + ELASTICSEARCH_CONTAINER = new ElasticsearchContainer("elasticsearch:9.2.1") + .withEnv("xpack.security.transport.ssl.enabled", "false") + .withEnv("xpack.security.http.ssl.enabled", "false") + .withReuse(true); + ELASTICSEARCH_CONTAINER.start(); } @DynamicPropertySource diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/ElasticsearchDynamicPropertyRegistrar.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/ElasticsearchDynamicPropertyRegistrar.java new file mode 100644 index 000000000..4e8f80fc5 --- /dev/null +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/ElasticsearchDynamicPropertyRegistrar.java @@ -0,0 +1,39 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson; + +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.DynamicPropertyRegistrar; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.testcontainers.elasticsearch.ElasticsearchContainer; + +/** + * Test configuration to register Elasticsearch properties for tests that require Elasticsearch. + *

+ * Use {@link SysONTestsProperties#ELASTICSEARCH_PROPERTY} in the test configuration to enable Elasticsearch. + *

+ * + * @author gdaniel + */ +@Configuration +@Conditional(OnElasticsearchEnabledTests.class) +public class ElasticsearchDynamicPropertyRegistrar implements DynamicPropertyRegistrar { + + @Override + public void accept(DynamicPropertyRegistry registry) { + registry.add("spring.elasticsearch.uris", AbstractIntegrationTests.ELASTICSEARCH_CONTAINER::getHttpHostAddress); + registry.add("spring.elasticsearch.username", () -> "elastic"); + registry.add("spring.elasticsearch.password", () -> ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD); + } +} diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/GivenSysONServer.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/GivenSysONServer.java new file mode 100644 index 000000000..1b5df8479 --- /dev/null +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/GivenSysONServer.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; + +/** + * Initializes the test with SQL scripts and cleans up the database after the test. + * + * @author theogiraudet + */ +@Documented +@Target({ ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@SysONServerInit +@Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) +public @interface GivenSysONServer { + + /** + * SQL scripts to execute before the test. + */ + @AliasFor(annotation = SysONServerInit.class, attribute = "value") + String[] value() default {}; +} diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/OnElasticsearchEnabledTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/OnElasticsearchEnabledTests.java new file mode 100644 index 000000000..af80951da --- /dev/null +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/OnElasticsearchEnabledTests.java @@ -0,0 +1,46 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson; + +import java.util.Arrays; + +import org.springframework.boot.autoconfigure.condition.ConditionMessage; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * Custom condition used to detect that a test requires Elasticsearch. + * + * @author gdaniel + */ +public class OnElasticsearchEnabledTests extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + var message = ConditionMessage.forCondition(this.getClass().getSimpleName()).notAvailable(SysONTestsProperties.SYSON_TEST_ENABLED); + ConditionOutcome outcome = ConditionOutcome.noMatch(message); + + var sysonTestEnabled = context.getEnvironment().getProperty(SysONTestsProperties.SYSON_TEST_ENABLED, ""); + var sysonTestEnabledFeatures = Arrays.stream(sysonTestEnabled.split(",")).map(String::trim).toList(); + + if (sysonTestEnabledFeatures.contains(SysONTestsProperties.ELASTICSEARCH)) { + message = ConditionMessage.forCondition(this.getClass().getSimpleName()).available(SysONTestsProperties.ELASTICSEARCH_PROPERTY); + outcome = ConditionOutcome.match(message); + } + + return outcome; + } + +} diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/SysONServerInit.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/SysONServerInit.java new file mode 100644 index 000000000..1d1cbaf39 --- /dev/null +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/SysONServerInit.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; + +/** + * Executes SQL scripts before the test method in an isolated transaction. + * + * @author theogiraudet + */ +@Documented +@Target({ ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) +public @interface SysONServerInit { + + /** + * SQL scripts to execute before the test. + */ + @AliasFor(annotation = Sql.class, attribute = "scripts") + String[] value() default {}; + +} diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/SysONTestsProperties.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/SysONTestsProperties.java index fc7236f1d..f7897955e 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/SysONTestsProperties.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/SysONTestsProperties.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2025 Obeo. + * Copyright (c) 2025, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -25,4 +25,8 @@ public class SysONTestsProperties { public static final String NO_DEFAULT_LIBRARIES_PROPERTY = SYSON_TEST_ENABLED + "=" + NO_DEFAULT_LIBRARIES; + public static final String ELASTICSEARCH = "elastic-search"; + + public static final String ELASTICSEARCH_PROPERTY = SYSON_TEST_ENABLED + "=" + ELASTICSEARCH; + } diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVActionDefinitionParameterTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVActionDefinitionParameterTests.java index 61ad02133..9ea91c7b7 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVActionDefinitionParameterTests.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVActionDefinitionParameterTests.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2025 Obeo. + * Copyright (c) 2025, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -12,11 +12,14 @@ *******************************************************************************/ package org.eclipse.syson.application.controllers.diagrams.general.view; +import static org.eclipse.sirius.components.diagrams.tests.DiagramEventPayloadConsumer.assertRefreshedDiagramThat; + import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import org.eclipse.sirius.components.collaborative.diagrams.dto.DiagramEventInput; import org.eclipse.sirius.components.collaborative.diagrams.dto.DiagramRefreshedEventPayload; @@ -28,21 +31,17 @@ import org.eclipse.syson.AbstractIntegrationTests; import org.eclipse.syson.SysONTestsProperties; import org.eclipse.syson.application.controllers.diagrams.checkers.CheckDiagramElementCount; -import org.eclipse.syson.application.controllers.diagrams.checkers.DiagramCheckerService; -import org.eclipse.syson.application.controllers.diagrams.checkers.IDiagramChecker; import org.eclipse.syson.application.controllers.diagrams.testers.ToolTester; import org.eclipse.syson.application.data.GeneralViewWithTopNodesTestProjectData; import org.eclipse.syson.services.diagrams.DiagramComparator; import org.eclipse.syson.services.diagrams.DiagramDescriptionIdProvider; import org.eclipse.syson.services.diagrams.api.IGivenDiagramDescription; -import org.eclipse.syson.services.diagrams.api.IGivenDiagramReference; import org.eclipse.syson.services.diagrams.api.IGivenDiagramSubscription; import org.eclipse.syson.standard.diagrams.view.SDVDescriptionNameGenerator; import org.eclipse.syson.sysml.ActionDefinition; import org.eclipse.syson.sysml.SysmlPackage; import org.eclipse.syson.util.IDescriptionNameGenerator; import org.eclipse.syson.util.SysONRepresentationDescriptionIdentifiers; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -52,6 +51,7 @@ import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; import reactor.test.StepVerifier; /** @@ -69,9 +69,6 @@ public class GVActionDefinitionParameterTests extends AbstractIntegrationTests { @Autowired private IGivenInitialServerState givenInitialServerState; - @Autowired - private IGivenDiagramReference givenDiagram; - @Autowired private IGivenDiagramDescription givenDiagramDescription; @@ -87,35 +84,16 @@ public class GVActionDefinitionParameterTests extends AbstractIntegrationTests { @Autowired private DiagramComparator diagramComparator; - private DiagramDescriptionIdProvider diagramDescriptionIdProvider; - - private DiagramCheckerService diagramCheckerService; - - private StepVerifier.Step verifier; - - private AtomicReference diagram; - - @BeforeEach - public void setUp() { - this.givenInitialServerState.initialize(); + private Flux givenSubscriptionToDiagram() { var diagramEventInput = new DiagramEventInput(UUID.randomUUID(), GeneralViewWithTopNodesTestProjectData.EDITING_CONTEXT_ID, GeneralViewWithTopNodesTestProjectData.GraphicalIds.DIAGRAM_ID); - var flux = this.givenDiagramSubscription.subscribe(diagramEventInput); - this.verifier = StepVerifier.create(flux); - this.diagram = this.givenDiagram.getDiagram(this.verifier); - var diagramDescription = this.givenDiagramDescription.getDiagramDescription(GeneralViewWithTopNodesTestProjectData.EDITING_CONTEXT_ID, - SysONRepresentationDescriptionIdentifiers.GENERAL_VIEW_DIAGRAM_DESCRIPTION_ID); - this.diagramDescriptionIdProvider = new DiagramDescriptionIdProvider(diagramDescription, this.diagramIdProvider); - this.diagramCheckerService = new DiagramCheckerService(this.diagramComparator, this.descriptionNameGenerator); + return this.givenDiagramSubscription.subscribe(diagramEventInput); } - @AfterEach - public void tearDown() { - if (this.verifier != null) { - this.verifier.thenCancel() - .verify(Duration.ofSeconds(10)); - } + @BeforeEach + public void setUp() { + this.givenInitialServerState.initialize(); } @DisplayName("GIVEN a SysML Project with an ActionDefinition that subclasses a UseCaseDefinition, WHEN creating a subject in the UseCaseDefinition, THEN it should not be displayed in the ActionDefinition parameters compartment") @@ -124,25 +102,39 @@ public void tearDown() { @Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) @Test public void checkActionDefinitionInParameter() { - this.checkItemParameterOnActionDefinition("In"); + this.checkItemParameterOnActionDefinition(); } - private void checkItemParameterOnActionDefinition(String kind) { + private void checkItemParameterOnActionDefinition() { + var flux = this.givenSubscriptionToDiagram(); List variables = new ArrayList<>(); variables.add(new ToolVariable("selectedObject", GeneralViewWithTopNodesTestProjectData.SemanticIds.PART_USAGE_ID, ToolVariableType.OBJECT_ID)); - String creationToolId = this.diagramDescriptionIdProvider.getNodeToolId(this.descriptionNameGenerator.getNodeName(SysmlPackage.eINSTANCE.getUseCaseDefinition()), "New Subject"); - this.verifier.then(() -> this.nodeCreationTester.invokeTool(GeneralViewWithTopNodesTestProjectData.EDITING_CONTEXT_ID, this.diagram, "UseCaseDefinition", creationToolId, variables)); + var diagramDescription = this.givenDiagramDescription.getDiagramDescription(GeneralViewWithTopNodesTestProjectData.EDITING_CONTEXT_ID, + SysONRepresentationDescriptionIdentifiers.GENERAL_VIEW_DIAGRAM_DESCRIPTION_ID); + var diagramDescriptionIdProvider = new DiagramDescriptionIdProvider(diagramDescription, this.diagramIdProvider); + + String creationToolId = diagramDescriptionIdProvider.getNodeToolId(this.descriptionNameGenerator.getNodeName(SysmlPackage.eINSTANCE.getUseCaseDefinition()), "New Subject"); - IDiagramChecker diagramChecker = (initialDiagram, newDiagram) -> { + AtomicReference diagram = new AtomicReference<>(); + + Consumer initialDiagramContentConsumer = assertRefreshedDiagramThat(diagram::set); + + Runnable newCreationTool = () -> this.nodeCreationTester.invokeTool(GeneralViewWithTopNodesTestProjectData.EDITING_CONTEXT_ID, diagram, "UseCaseDefinition", creationToolId, variables); + + Consumer updatedDiagramConsumer = assertRefreshedDiagramThat(newDiagram -> { new CheckDiagramElementCount(this.diagramComparator) .hasNewBorderNodeCount(0) .hasNewNodeCount(1) // Only one subject in the UseCaseDefinition subjects compartment. .hasNewEdgeCount(0) - .check(initialDiagram, newDiagram); - - }; - - this.diagramCheckerService.checkDiagram(diagramChecker, this.diagram, this.verifier); + .check(diagram.get(), newDiagram); + }); + + StepVerifier.create(flux) + .consumeNextWith(initialDiagramContentConsumer) + .then(newCreationTool) + .consumeNextWith(updatedDiagramConsumer) + .thenCancel() + .verify(Duration.ofSeconds(10)); } } diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVAddExistingElementsTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVAddExistingElementsTests.java index 3e0bba7e8..306f04cfe 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVAddExistingElementsTests.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVAddExistingElementsTests.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -13,7 +13,7 @@ package org.eclipse.syson.application.controllers.diagrams.general.view; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.fail; +import static org.eclipse.sirius.components.diagrams.tests.DiagramEventPayloadConsumer.assertRefreshedDiagramThat; import java.text.MessageFormat; import java.time.Duration; @@ -28,7 +28,6 @@ import org.eclipse.sirius.components.diagrams.Diagram; import org.eclipse.sirius.components.diagrams.Node; import org.eclipse.sirius.components.diagrams.ViewModifier; -import org.eclipse.sirius.components.view.diagram.DiagramDescription; import org.eclipse.sirius.components.view.emf.diagram.IDiagramIdProvider; import org.eclipse.sirius.web.tests.services.api.IGivenInitialServerState; import org.eclipse.syson.AbstractIntegrationTests; @@ -36,10 +35,8 @@ import org.eclipse.syson.application.data.GeneralViewAddExistingElementsTestProjectData; import org.eclipse.syson.services.diagrams.DiagramDescriptionIdProvider; import org.eclipse.syson.services.diagrams.api.IGivenDiagramDescription; -import org.eclipse.syson.services.diagrams.api.IGivenDiagramReference; import org.eclipse.syson.services.diagrams.api.IGivenDiagramSubscription; import org.eclipse.syson.util.SysONRepresentationDescriptionIdentifiers; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -48,8 +45,8 @@ import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; import reactor.test.StepVerifier; -import reactor.test.StepVerifier.Step; /** * Tests the invocation of the "addExistingElements" tool in the General View diagram. @@ -79,9 +76,6 @@ public class GVAddExistingElementsTests extends AbstractIntegrationTests { @Autowired private IGivenInitialServerState givenInitialServerState; - @Autowired - private IGivenDiagramReference givenDiagram; - @Autowired private IGivenDiagramDescription givenDiagramDescription; @@ -94,34 +88,17 @@ public class GVAddExistingElementsTests extends AbstractIntegrationTests { @Autowired private ToolTester nodeCreationTester; - private DiagramDescriptionIdProvider diagramDescriptionIdProvider; - - private Step verifier; - - private AtomicReference diagram; - - private DiagramDescription diagramDescription; - - @BeforeEach - public void setUp() { - this.givenInitialServerState.initialize(); + private Flux givenSubscriptionToDiagram() { var diagramEventInput = new DiagramEventInput(UUID.randomUUID(), GeneralViewAddExistingElementsTestProjectData.EDITING_CONTEXT_ID, GeneralViewAddExistingElementsTestProjectData.GraphicalIds.DIAGRAM_ID); - var flux = this.givenDiagramSubscription.subscribe(diagramEventInput); - this.verifier = StepVerifier.create(flux); - this.diagram = this.givenDiagram.getDiagram(this.verifier); - this.diagramDescription = this.givenDiagramDescription.getDiagramDescription(GeneralViewAddExistingElementsTestProjectData.EDITING_CONTEXT_ID, - SysONRepresentationDescriptionIdentifiers.GENERAL_VIEW_DIAGRAM_DESCRIPTION_ID); - this.diagramDescriptionIdProvider = new DiagramDescriptionIdProvider(this.diagramDescription, this.diagramIdProvider); + return this.givenDiagramSubscription.subscribe(diagramEventInput); } - @AfterEach - public void tearDown() { - if (this.verifier != null) { - this.verifier.thenCancel() - .verify(Duration.ofSeconds(10)); - } + + @BeforeEach + public void setUp() { + this.givenInitialServerState.initialize(); } @Sql(scripts = { GeneralViewAddExistingElementsTestProjectData.SCRIPT_PATH }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, @@ -129,25 +106,40 @@ public void tearDown() { @Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) @Test public void addExistingElementsOnDiagram() { - String creationToolId = this.diagramDescriptionIdProvider.getDiagramCreationToolId("Add existing elements"); + var flux = this.givenSubscriptionToDiagram(); + + var diagramDescription = this.givenDiagramDescription.getDiagramDescription(GeneralViewAddExistingElementsTestProjectData.EDITING_CONTEXT_ID, + SysONRepresentationDescriptionIdentifiers.GENERAL_VIEW_DIAGRAM_DESCRIPTION_ID); + var diagramDescriptionIdProvider = new DiagramDescriptionIdProvider(diagramDescription, this.diagramIdProvider); + + AtomicReference diagram = new AtomicReference<>(); + + Consumer initialDiagramContentConsumer = assertRefreshedDiagramThat(diagram::set); + + String creationToolId = diagramDescriptionIdProvider.getDiagramCreationToolId("Add existing elements"); assertThat(creationToolId).as("The tool 'Add existing elements' should exist on the diagram").isNotNull(); - this.verifier.then(() -> this.nodeCreationTester.invokeTool(GeneralViewAddExistingElementsTestProjectData.EDITING_CONTEXT_ID, this.diagram, creationToolId)); - - Consumer updatedDiagramConsumer = payload -> Optional.of(payload) - .map(DiagramRefreshedEventPayload::diagram) - .ifPresentOrElse(newDiagram -> { - assertThat(newDiagram.getNodes()).as("3 nodes should be visible on the diagram").hasSize(4); - assertThat(newDiagram.getNodes()) - .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PACKAGE1)) - .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PACKAGE1)) - .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, ACTION1)) - .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), ACTION1)) - .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PART1)) - .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PART1)) - .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, "RequirementUsage")) - .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), "RequirementUsage")); - }, () -> fail("Missing diagram")); - this.verifier.consumeNextWith(updatedDiagramConsumer); + + Runnable nodeCreationRunner = () -> this.nodeCreationTester.invokeTool(GeneralViewAddExistingElementsTestProjectData.EDITING_CONTEXT_ID, diagram, creationToolId); + + Consumer updatedDiagramConsumer = assertRefreshedDiagramThat(newDiagram -> { + assertThat(newDiagram.getNodes()).as("3 nodes should be visible on the diagram").hasSize(4); + assertThat(newDiagram.getNodes()) + .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PACKAGE1)) + .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PACKAGE1)) + .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, ACTION1)) + .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), ACTION1)) + .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PART1)) + .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PART1)) + .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, "RequirementUsage")) + .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), "RequirementUsage")); + }); + + StepVerifier.create(flux) + .consumeNextWith(initialDiagramContentConsumer) + .then(nodeCreationRunner) + .consumeNextWith(updatedDiagramConsumer) + .thenCancel() + .verify(Duration.ofSeconds(10)); } @@ -156,50 +148,63 @@ public void addExistingElementsOnDiagram() { @Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) @Test public void addExistingElementsRecursiveOnDiagram() { - String creationToolId = this.diagramDescriptionIdProvider.getDiagramCreationToolId("Add existing elements (recursive)"); + var flux = this.givenSubscriptionToDiagram(); + + var diagramDescription = this.givenDiagramDescription.getDiagramDescription(GeneralViewAddExistingElementsTestProjectData.EDITING_CONTEXT_ID, + SysONRepresentationDescriptionIdentifiers.GENERAL_VIEW_DIAGRAM_DESCRIPTION_ID); + var diagramDescriptionIdProvider = new DiagramDescriptionIdProvider(diagramDescription, this.diagramIdProvider); + + AtomicReference diagram = new AtomicReference<>(); + + Consumer initialDiagramContentConsumer = assertRefreshedDiagramThat(diagram::set); + + String creationToolId = diagramDescriptionIdProvider.getDiagramCreationToolId("Add existing elements (recursive)"); assertThat(creationToolId).as("The tool 'Add existing elements (recursive)' should exist on the diagram").isNotNull(); - this.verifier.then(() -> this.nodeCreationTester.invokeTool(GeneralViewAddExistingElementsTestProjectData.EDITING_CONTEXT_ID, this.diagram, creationToolId)); - - Consumer updatedDiagramConsumer = payload -> Optional.of(payload) - .map(DiagramRefreshedEventPayload::diagram) - .ifPresentOrElse(newDiagram -> { - assertThat(newDiagram.getNodes()).as("6 nodes should be visible on the diagram").hasSize(7); - assertThat(newDiagram.getEdges().stream().filter(e -> ViewModifier.Normal.equals(e.getState())).toList()) - .as("3 edges should be visible on the diagram") - .hasSize(3) - .as("The diagram should contain a composite edge between part2 and part1") - .anyMatch(edge -> edge.getTargetObjectLabel().equals(PART1)) - .as("The diagram should contain a composite edge between action1 and action2") - .anyMatch(edge -> edge.getTargetObjectLabel().equals(ACTION1)) - .as("The diagram should contain a composite edge between action2 and action3") - .anyMatch(edge -> edge.getTargetObjectLabel().equals(ACTION2)); - assertThat(newDiagram.getNodes()) - .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PACKAGE1)) - .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PACKAGE1)) - .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, ACTION1)) - .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), ACTION1)) - .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PART1)) - .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PART1)) - .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PART2)) - .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PART2)) - .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, "RequirementUsage")) - .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), "RequirementUsage")); - - this.checkPackageNode(newDiagram); - - - // @technical-debt enable this part when start node will be synchronized - // .as(ACTION1 + " action flow compartment should contain a start node") - // .anyMatch(n -> n.getStyle() instanceof ImageNodeStyle imageStyle && - // Objects.equals(imageStyle.getImageURL(), "images/start_action.svg") - // && Objects.equals("start", n.getTargetObjectLabel())); - - this.checkAction2(newDiagram); - - this.checkRequirementUsage(newDiagram); - - }, () -> fail("Missing diagram")); - this.verifier.consumeNextWith(updatedDiagramConsumer); + + Runnable nodeCreationRunner = () -> this.nodeCreationTester.invokeTool(GeneralViewAddExistingElementsTestProjectData.EDITING_CONTEXT_ID, diagram, creationToolId); + + Consumer updatedDiagramConsumer = assertRefreshedDiagramThat(newDiagram -> { + assertThat(newDiagram.getNodes()).as("6 nodes should be visible on the diagram").hasSize(7); + assertThat(newDiagram.getEdges().stream().filter(e -> ViewModifier.Normal.equals(e.getState())).toList()) + .as("3 edges should be visible on the diagram") + .hasSize(3) + .as("The diagram should contain a composite edge between part2 and part1") + .anyMatch(edge -> edge.getTargetObjectLabel().equals(PART1)) + .as("The diagram should contain a composite edge between action1 and action2") + .anyMatch(edge -> edge.getTargetObjectLabel().equals(ACTION1)) + .as("The diagram should contain a composite edge between action2 and action3") + .anyMatch(edge -> edge.getTargetObjectLabel().equals(ACTION2)); + assertThat(newDiagram.getNodes()) + .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PACKAGE1)) + .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PACKAGE1)) + .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, ACTION1)) + .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), ACTION1)) + .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PART1)) + .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PART1)) + .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, PART2)) + .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), PART2)) + .as(MessageFormat.format(NODE_SHOULD_BE_ON_DIAGRAM_MESSAGE, "RequirementUsage")) + .anyMatch(n -> Objects.equals(n.getTargetObjectLabel(), "RequirementUsage")); + + this.checkPackageNode(newDiagram); + + // @technical-debt enable this part when start node will be synchronized + // .as(ACTION1 + " action flow compartment should contain a start node") + // .anyMatch(n -> n.getStyle() instanceof ImageNodeStyle imageStyle && + // Objects.equals(imageStyle.getImageURL(), "images/start_action.svg") + // && Objects.equals("start", n.getTargetObjectLabel())); + + this.checkAction2(newDiagram); + + this.checkRequirementUsage(newDiagram); + }); + + StepVerifier.create(flux) + .consumeNextWith(initialDiagramContentConsumer) + .then(nodeCreationRunner) + .consumeNextWith(updatedDiagramConsumer) + .thenCancel() + .verify(Duration.ofSeconds(10)); } private void checkPackageNode(Diagram newDiagram) { @@ -215,7 +220,6 @@ private void checkPackageNode(Diagram newDiagram) { } private void checkAction2(Diagram newDiagram) { - var action1ActionFlowCompartment = this.checkAction1(newDiagram); var optAction2Node = action1ActionFlowCompartment.getChildNodes().stream() .filter(n -> Objects.equals(n.getTargetObjectLabel(), ACTION2)) diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVItemAndAttributeExpressionTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVItemAndAttributeExpressionTests.java index 113919713..656d649d5 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVItemAndAttributeExpressionTests.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVItemAndAttributeExpressionTests.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2025 Obeo. + * Copyright (c) 2025, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -170,7 +170,7 @@ public void nestedBinaryOperatorExpressionWithNameOverlapping() { // Here the direct edit input need to explicitly give the qualified name of x3 since the default x3 is the one // located in p1_1 whereas we are targeting RootPackage::p1::x3 this.directEditInitialLabelTester.checkDirectEditInitialLabelOnNode(this.verifier, this.diagram, GeneralViewItemAndAttributeProjectData.GraphicalIds.P1_1_X1_ID, - "x1 = p1::x3 / p1::x3 * 10"); + "x1 = RootPackage::p1::x3 / RootPackage::p1::x3 * 10"); } diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVPackageTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVPackageTests.java index da06ff05c..2206436a8 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVPackageTests.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVPackageTests.java @@ -28,6 +28,7 @@ import org.eclipse.sirius.components.view.emf.diagram.IDiagramIdProvider; import org.eclipse.sirius.web.tests.services.api.IGivenInitialServerState; import org.eclipse.syson.AbstractIntegrationTests; +import org.eclipse.syson.GivenSysONServer; import org.eclipse.syson.application.controllers.diagrams.testers.ToolTester; import org.eclipse.syson.application.data.GeneralViewWithTopNodesTestProjectData; import org.eclipse.syson.services.diagrams.DiagramDescriptionIdProvider; @@ -43,8 +44,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; @@ -94,9 +93,7 @@ public void beforeEach() { } @DisplayName("GIVEN a diagram with a Package node, WHEN a Part node and a Sub-Part node are created, THEN the Part node and the Sub-Part node are visible inside the Package, on the same level, with an edge between them") - @Sql(scripts = { GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, - config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) - @Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @GivenSysONServer({ GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }) @Test public void testCreateSubPartInPackage() { var flux = this.givenSubscriptionToDiagram(); @@ -165,9 +162,7 @@ public void testCreateSubPartInPackage() { } @DisplayName("GIVEN a diagram with a Package node, WHEN a sub-Package node is created, THEN the sub-Package node is only visible inside the Package") - @Sql(scripts = { GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, - config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) - @Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @GivenSysONServer({ GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }) @Test public void testCreatePackageInPackage() { var flux = this.givenSubscriptionToDiagram(); @@ -216,9 +211,7 @@ public void testCreatePackageInPackage() { } @DisplayName("GIVEN a diagram with a Package node, WHEN a sub-Package node and a sub-element in the sub-Package are created, THEN the sub-Package node is only visible inside the Package and the sub-Element node is only visible inside the sub-Package") - @Sql(scripts = { GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, - config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) - @Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @GivenSysONServer({ GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }) @Test public void testCreateElementInPackageInPackage() { var flux = this.givenSubscriptionToDiagram(); @@ -289,9 +282,7 @@ public void testCreateElementInPackageInPackage() { } @DisplayName("GIVEN a diagram with a Package node, WHEN a Documentation node is created, THEN the Documentation node is only visible beside the Package") - @Sql(scripts = { GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, - config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) - @Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @GivenSysONServer({ GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }) @Test public void testCreateDocumentationInPackage() { var flux = this.givenSubscriptionToDiagram(); @@ -339,9 +330,7 @@ public void testCreateDocumentationInPackage() { } @DisplayName("GIVEN a diagram with a Package with an ItemDefinition IDA with an Item itemA, a SubPackage with an ItemDefinition IDB, WHEN an Item itemB node is created from IDB, THEN itemA node is still visible inside the Package") - @Sql(scripts = { GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, - config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) - @Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @GivenSysONServer({ GeneralViewWithTopNodesTestProjectData.SCRIPT_PATH }) @Test public void testCreateItemInSubPackage() { var flux = this.givenSubscriptionToDiagram(); diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/omnibox/ProjectsOmniboxControllerIntegrationTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/omnibox/ProjectsOmniboxControllerIntegrationTests.java new file mode 100644 index 000000000..d52d7ac5d --- /dev/null +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/omnibox/ProjectsOmniboxControllerIntegrationTests.java @@ -0,0 +1,108 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.controllers.omnibox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import com.jayway.jsonpath.JsonPath; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.sirius.components.core.api.IEditingContextSearchService; +import org.eclipse.sirius.web.infrastructure.elasticsearch.services.api.IIndexUpdateService; +import org.eclipse.sirius.web.tests.graphql.ProjectsOmniboxSearchQueryRunner; +import org.eclipse.syson.AbstractIntegrationTests; +import org.eclipse.syson.SysONTestsProperties; +import org.eclipse.syson.application.data.SimpleProjectElementsTestProjectData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.transaction.annotation.Transactional; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; + +/** + * Integration tests of the projects omnibox controllers. + * + * @author gdaniel + */ +@Transactional +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { SysONTestsProperties.SYSON_TEST_ENABLED + "=" + SysONTestsProperties.NO_DEFAULT_LIBRARIES + ", " + SysONTestsProperties.ELASTICSEARCH }) +public class ProjectsOmniboxControllerIntegrationTests extends AbstractIntegrationTests { + + @Autowired + private ProjectsOmniboxSearchQueryRunner projectsOmniboxSearchQueryRunner; + + @Autowired + private IIndexUpdateService indexUpdateService; + + @Autowired + private Optional optionalElasticSearchClient; + + @Autowired + private IEditingContextSearchService editingContextSearchService; + + @Test + @DisplayName("GIVEN a query, WHEN the objects are searched in the projects omnibox, THEN the objects are returned") + @Sql(scripts = { SimpleProjectElementsTestProjectData.SCRIPT_PATH }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + @Sql(scripts = { "/scripts/cleanup.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + public void givenQueryWhenObjectsAreSearchedInProjectsOmniboxThenObjectsAreReturned() { + assertThat(this.optionalElasticSearchClient.isPresent()); + this.editingContextSearchService.findById(SimpleProjectElementsTestProjectData.EDITING_CONTEXT_ID).ifPresent(this.indexUpdateService::updateIndex); + // Wait for Elasticsearch's refresh to ensure the indexed documents can be queried. + this.optionalElasticSearchClient.ifPresent(elasticSearchClient -> { + try { + elasticSearchClient.indices().refresh(); + } catch (IOException exception) { + fail(exception); + } + }); + + Map emptyQueryVariables = Map.of( + "query", "" + ); + + var emptyQueryResult = this.projectsOmniboxSearchQueryRunner.run(emptyQueryVariables); + List emptyQueryObjectLabels = JsonPath.read(emptyQueryResult.data(), "$.data.viewer.projectsOmniboxSearch.edges[*].node.label"); + assertThat(emptyQueryObjectLabels).isEmpty(); + + Map filterQueryVariables = Map.of( + "query", "name:Pack*" + ); + + var filterQueryResult = this.projectsOmniboxSearchQueryRunner.run(filterQueryVariables); + List filterQueryObjectLabels = JsonPath.read(filterQueryResult.data(), "$.data.viewer.projectsOmniboxSearch.edges[*].node.label"); + assertThat(filterQueryObjectLabels) + .hasSize(2) + .anySatisfy(label -> assertThat(label).contains("Package1", SimpleProjectElementsTestProjectData.PROJECT_NAME)) + .anySatisfy(label -> assertThat(label).contains("Package2", SimpleProjectElementsTestProjectData.PROJECT_NAME)); + + Map complexQueryVariables = Map.of( + "query", "@type:Part AND owner.name:Package1" + ); + + var complexQueryResult = this.projectsOmniboxSearchQueryRunner.run(complexQueryVariables); + List complexQueryObjectLabels = JsonPath.read(complexQueryResult.data(), "$.data.viewer.projectsOmniboxSearch.edges[*].node.label"); + assertThat(complexQueryObjectLabels).isNotEmpty() + .anySatisfy(label -> assertThat(label).contains("p", SimpleProjectElementsTestProjectData.PROJECT_NAME)); + } +} diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/export/ImportExportTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/export/ImportExportTests.java index 9a4b823b5..c6c238981 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/export/ImportExportTests.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/export/ImportExportTests.java @@ -18,7 +18,9 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.function.Consumer; +import org.eclipse.emf.ecore.resource.Resource; import org.eclipse.sirius.components.core.api.IEditingContext; import org.eclipse.sirius.components.core.api.IEditingContextSearchService; import org.eclipse.sirius.components.core.api.IPayload; @@ -34,7 +36,9 @@ import org.eclipse.sirius.web.tests.services.api.IGivenInitialServerState; import org.eclipse.syson.AbstractIntegrationTests; import org.eclipse.syson.application.export.checker.SysmlImportExportChecker; +import org.eclipse.syson.sysml.ItemUsage; import org.eclipse.syson.sysml.export.SysMLv2DocumentExporter; +import org.eclipse.syson.sysml.helper.EMFUtils; import org.eclipse.syson.sysml.upload.SysMLExternalResourceLoaderService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -111,7 +115,9 @@ public void checkExportRootExport() throws IOException { var input = """ private import ScalarValues::*; package p1;"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -126,7 +132,9 @@ enum def Enum1 { attribute z1 : Enum1 = Enum1::e1; } }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -142,7 +150,9 @@ public void checkEscapedKeywordName() throws IOException { attribute 'comment'; } }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -165,7 +175,9 @@ public void checkFlowUsageBaseExample() throws IOException { flow from p2.po1.item1 to p3.item2; flow f1 from p2.po1.item1 to p3.item2; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -244,7 +256,9 @@ public void checkFlowUsageWithPayload() throws IOException { flow of Fuel from eng.engineFuelPort.fuelReturn to tankAssy.fuelTankPort.fuelReturn; } }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @Test @@ -275,7 +289,9 @@ public void checkRequirementConstraintMembershipFullNotation() throws IOExceptio requirement def R1 { require constraint c1 :>> c; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -294,7 +310,9 @@ public void checkRequirementConstraintMembershipShorthandNotation() throws IOExc doc /* Some doc */ } }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -311,7 +329,9 @@ public void checkRequirementConstraintMembershipWithMetadataUsage() throws IOExc assume #goal constraint c1; require #goal constraint c2; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -336,7 +356,9 @@ public void checkForkNode() throws IOException { then a2; first start then fork1; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -376,7 +398,9 @@ public void checkJoinNode() throws IOException { first a2 then join1; first join1 then done; }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @Test @@ -394,7 +418,9 @@ public void checkMergeNode() throws IOException { first a1 then merge1; first a2 then merge1; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -407,7 +433,9 @@ public void checkTextualRepresentation() throws IOException { rep l2 language "naturalLanguage2" /* some comment 3 */ }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -425,7 +453,9 @@ public void checkTransitionUsageBetweenActions() throws IOException { doc /* Some documentation on that succession */ } }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -447,7 +477,9 @@ public void checkViewUsage() throws IOException { render asTreeDiagram; } } """; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -474,7 +506,9 @@ public void checkAcceptActionUsage() throws IOException { accept when b.f; } }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -507,7 +541,9 @@ public void checkDecisionWithNamedTransition() throws IOException { succession sd2 first d1 if x == 0 then a2; succession sd3 first d1 then a3; }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @Test @@ -524,7 +560,9 @@ public void checkDecisionNode() throws IOException { if x >= 1.1 and x < 2.1 then a2; else a3; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -544,7 +582,9 @@ public void checkConjugatedPortUse() throws IOException { } }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -557,7 +597,9 @@ public void checkNamedSuccessionAsUsageInActionDefinitionTest() throws IOExcepti succession s1 first a1 then a2; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -572,7 +614,9 @@ public void checkSuccessionAsUsageImplicitSourceTest() throws IOException { then a0; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -602,7 +646,9 @@ public void checkSuccessionDefiningImplicitTarget() throws IOException { action a2; }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @Test @@ -628,7 +674,9 @@ public void checkSuccessionAsUsageImplicitSourceToStartTest() throws IOException first start then a2; }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @Test @@ -643,7 +691,9 @@ public void checkSuccessionAsUsageExplicitSourceTest() throws IOException { first a1 then a2; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -657,7 +707,9 @@ public void checkUnrestrictedNamesResolution() throws IOException { action 'a 2' : 'p 2'::'A 1'; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } /** @@ -742,7 +794,9 @@ public void checkUseCaseTest() throws IOException { } }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } /** @@ -758,7 +812,9 @@ public void checkImportPort() throws IOException { part part1 { port port1 : Port1; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } /** @@ -808,7 +864,9 @@ enum def MyEnum { enum1; } }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } /** @@ -863,7 +921,9 @@ public void checkImportTest() throws IOException { } }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } /** @@ -898,7 +958,38 @@ public void checkConcernTest() throws IOException { } }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); + } + + @Test + @DisplayName("GIVEN of a model with implicit referential usages, WHEN exporting that model, THEN the implicit referential element should no use the ref keyword") + // See org.eclipse.syson.application.imports.ImportSysMLModelTest.testReferentialUsages for test importing the same model + public void checkImplicitReferentialUsages() throws IOException { + var input = """ + package root { + part part1 { + part part11; + attribute attr; + in part part22; + ref part part13; + } + action a1 { + action a11; + action a12; + succession suc1 first a11 then a12; + } + use case def ucd_1 { + objective c; + subject subject_1; + actor partU_1; + } + }"""; + this.checker.textToImport(input) + .expectedResult(input) + .check(); + } /** @@ -969,7 +1060,9 @@ public void checkOccurrenceTest() throws IOException { } }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @Test @@ -982,7 +1075,22 @@ public void checkReferencingWithShortName() throws IOException { part p2 :> p1 { attribute :>> attr1; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); + } + + @Test + @DisplayName("GIVEN an abstract OccurrenceUsage, WHEN importing and exporting the model, THEN the abstract keyword should be correctly exported") + public void checkAbstractOccurrenceUsage() throws IOException { + var input = """ + package root { + abstract occurrence test; + abstract occurrence def Test; + }"""; + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -1015,7 +1123,9 @@ enum def Enum1 { attribute attribute4 : Enum1 = Enum1::enumeration2; attribute attribute5 = Enum1::enumeration1; }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @@ -1032,7 +1142,9 @@ public void checkFeatureReferenceExpressionWithQualifiedNameDeresolution() throw } }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -1054,7 +1166,9 @@ public void checkRedefineAttributeWithShortName() throws IOException { attribute :>> a; }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @@ -1063,7 +1177,9 @@ public void checkRedefineAttributeWithShortName() throws IOException { public void checkLiteralString() throws IOException { var input = """ attribute myAttribute = "value";"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -1071,7 +1187,9 @@ public void checkLiteralString() throws IOException { public void checkMultiplicityRangeWithLiteralIntegerBounds() throws IOException { var input = """ part myPart [1..2];"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -1103,7 +1221,9 @@ public void checkMetaOperatorExpressionExport() throws IOException { metadata def MD2 :> SemanticMetadata { :>> baseType default = p meta SysML::Systems::PartUsage; }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @Test @@ -1117,7 +1237,9 @@ public void checkUnaryOperator() throws IOException { attribute c : Integer = +1; attribute d : Boolean = not true; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -1156,7 +1278,9 @@ public void checkSatisfyRequirementUsage() throws IOException { assert satisfy Req1 by Context1; assert satisfy requirement Req2 : REQ2 by Context1.'System 1'; }"""; - this.checker.check(input, expected); + this.checker.textToImport(input) + .expectedResult(expected) + .check(); } @Test @@ -1182,7 +1306,9 @@ public void checkConnectionUsage() throws IOException { connect (p1, p2, Parts::part3); connect p1.po1 to p2.po2; }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } @Test @@ -1204,6 +1330,123 @@ public void checkConnectionUsageWithBody() throws IOException { end #derive ::> Sre1; } }"""; - this.checker.check(input, input); + this.checker.textToImport(input) + .expectedResult(input) + .check(); } + + /** + * Test import/export on test file StateTest.sysml. The content of StateTest.sysml that have been copied below + * is under LGPL-3.0-only license. The LGPL-3.0-only license is accessible at the root of this repository, in the + * LICENSE-LGPL file. + * + *

NOTE: The TransitionUsage has been remove from the original model for this test.

+ * + * @see StateTest.sysml + */ + @Test + @DisplayName("GIVEN a model with StateUsage and StateDefinition, WHEN importing and exporting the model, THEN the states are properly exported") + public void checkStates() throws IOException { + var input = """ + package StateTest { + attribute def Sig { + x; + } + attribute def Exit; + part p; + action act; + state def S { + do action A; + entry ; + state S1; + state S2 { + state S3; + } + exit act; + state S3 { + state S3a; + } + } + state s0 { + state s1 { + state s2; + } + state s3 { + state s4; + } + } + state s parallel { + state s1; + state s2; + } + state s4 { + do action a; + action c; + } + state s5 :> s4 { + do action b :>> c; + } + }"""; + this.checker.textToImport(input) + .expectedResult(input) + .check(); + } + + @Test + @DisplayName("GIVEN with conflicting names used in refence, WHEN importing and exporting the model, THEN the names used in the qualified name should be properly escaped.") + public void checkNameConflictResolutionWithQn() throws IOException { + var input = """ + package 'root && root' { + package 'AA & BB' { + item def ItemDef0; + requirement def RecDef1 { + requirementsSpecification : ItemDef0; + } + package 'JJ & GG' { + item requirements : ItemDef0; + item requirements2 : ItemDef0; + } + } + package 'CC & DD' { + private import 'AA & BB'::'JJ & GG'::*; + requirement rec0 : 'AA & BB'::RecDef1 { + requirementsSpecification :> 'AA & BB'::RecDef1::requirementsSpecification = 'root && root'::'AA & BB'::'JJ & GG'::requirements2; + } + } + }"""; + // Modify the model to generate a name conflict between "'root && root'::'AA & BB'::'JJ & GG'::requirements" and "'root && root'::'AA & BB'::'JJ & GG'::requirements2" + // This will cause an issue the value of "'root && root'::'CC & DD'::rec0::requirementsSpecification" + Consumer createNameConflicts = resource -> { + EMFUtils.allContainedObjectOfType(resource, ItemUsage.class) + .filter(item -> "requirements2".equals(item.getName())) + .findFirst() + .orElseThrow() + .setDeclaredName("requirements"); + }; + var expected = """ + package 'root && root' { + package 'AA & BB' { + item def ItemDef0; + requirement def RecDef1 { + requirementsSpecification : ItemDef0; + } + package 'JJ & GG' { + item requirements : ItemDef0; + item requirements : ItemDef0; + } + } + package 'CC & DD' { + private import 'AA & BB'::'JJ & GG'::*; + requirement rec0 : 'AA & BB'::RecDef1 { + requirementsSpecification :> 'AA & BB'::RecDef1::requirementsSpecification = 'root && root'::'AA & BB'::'JJ & GG'::requirements; + } + } + }"""; + this.checker.textToImport(input) + .modifyLoadedModel(createNameConflicts) + .expectedResult(expected) + .check(); + } + } diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/export/checker/SysmlImportExportChecker.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/export/checker/SysmlImportExportChecker.java index bd35b5642..0db61afc9 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/export/checker/SysmlImportExportChecker.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/export/checker/SysmlImportExportChecker.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -20,6 +20,7 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; +import java.util.function.Consumer; import org.eclipse.emf.common.util.URI; import org.eclipse.emf.ecore.resource.Resource; @@ -41,6 +42,12 @@ public class SysmlImportExportChecker { private final EditingContext editingContext; + private Consumer loadedModelModifier; + + private String textToImport; + + private String expectedResult; + public SysmlImportExportChecker(SysMLExternalResourceLoaderService sysmlLoader, SysMLv2DocumentExporter exporter, EditingContext editingContext) { super(); this.editingContext = Objects.requireNonNull(editingContext); @@ -48,20 +55,60 @@ public SysmlImportExportChecker(SysMLExternalResourceLoaderService sysmlLoader, this.exporter = Objects.requireNonNull(exporter); } - public void check(String importedText, String expectedResult) throws IOException { + /** + * Optional consumer used to modify the loaded model. + * + * @param modifier + * a consumer to modify the loaded {@link Resource} + * @return this for convenience + */ + public SysmlImportExportChecker modifyLoadedModel(Consumer modifier) { + this.loadedModelModifier = modifier; + return this; + } - try (var inputStream = new ByteArrayInputStream(importedText.getBytes())) { + /** + * Expected String result. + * + * @param expected + * the expected result + * @return this for convenience + */ + public SysmlImportExportChecker expectedResult(String expected) { + this.expectedResult = expected; + return this; + } + + /** + * Expected String result. + * + * @param toImport + * the text about to be imported + * @return this for convenience + */ + public SysmlImportExportChecker textToImport(String toImport) { + this.textToImport = toImport; + return this; + } + + public void check() throws IOException { + Objects.requireNonNull(this.textToImport); + Objects.requireNonNull(this.expectedResult); + try (var inputStream = new ByteArrayInputStream(this.textToImport.getBytes())) { Optional optLoadedResources = this.sysmlLoader.getResource(inputStream, this.createFakeURI(UUID.randomUUID()), this.editingContext.getDomain().getResourceSet(), false); assertTrue(optLoadedResources.isPresent()); + if (this.loadedModelModifier != null) { + // Modify loaded model + this.loadedModelModifier.accept(optLoadedResources.get()); + } Optional bytes = this.exporter.getBytes(optLoadedResources.get(), MediaType.TEXT_HTML.toString()); assertTrue(bytes.isPresent()); String content = new String(bytes.get()); - assertEquals(this.normalize(expectedResult), this.normalize(content)); + assertEquals(this.normalize(this.expectedResult), this.normalize(content)); } - } private URI createFakeURI(UUID uuid) { diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/imports/ImportSysMLModelTest.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/imports/ImportSysMLModelTest.java index 1e0429da7..937177b71 100644 --- a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/imports/ImportSysMLModelTest.java +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/imports/ImportSysMLModelTest.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2025 Obeo. + * Copyright (c) 2025, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -25,6 +25,7 @@ import java.util.Optional; import java.util.UUID; +import org.eclipse.emf.common.notify.Notifier; import org.eclipse.emf.common.util.EList; import org.eclipse.sirius.components.core.api.IEditingContext; import org.eclipse.sirius.components.core.api.IEditingContextSearchService; @@ -81,6 +82,7 @@ import org.eclipse.syson.sysml.TransitionFeatureMembership; import org.eclipse.syson.sysml.TransitionUsage; import org.eclipse.syson.sysml.Type; +import org.eclipse.syson.sysml.Usage; import org.eclipse.syson.sysml.helper.EMFUtils; import org.eclipse.syson.sysml.upload.SysMLExternalResourceLoaderService; import org.junit.jupiter.api.AfterEach; @@ -154,7 +156,71 @@ public void tearDown() { } @Test - @DisplayName("Given of model with redefinition depending on inherited memberships computation, WHEN importing the model, THEN redefined feature should resolve properly using inherited memberships") + @DisplayName("GIVEN a set of Usages, WHEN checking if the usage is referential, THEN the computation should be correct whether or not the ref keyword is present.") + public void testReferentialUsages() throws IOException { + var input = """ + package root { + // Referential because contained in package + part part1 { + part part11; // Composite because no "ref" keyword + attribute attr; // ReReferential because AttributeUsage + in part part22; // Referential because "directed" feature + ref part part13; // Referential since using "ref" keyword + } + // Referential because contained in package + action a1 { + action a11; // Composite because no "ref" keyword + action a12; // Composite because no "ref" keyword + succession suc1 first a11 then a12; // Referential because is a Connector + } + + use case def ucd_1 { + objective c; // Composite because no "ref" keyword + subject subject_1; // Ref because ReferenceUsage + actor partU_1; // Ref because in actor + } + } + """; + this.checker.checkImportedModel(resource -> { + assertThat(this.getByName(PartUsage.class, resource, "part1")) + .as("part1 should be referential since contained in a package") + .matches(Usage::isIsReference); + assertThat(this.getByName(PartUsage.class, resource, "part11")) + .as("part11 should be composite since contained in another type and not prefixed with ref") + .doesNotMatch(Usage::isIsReference); + assertThat(this.getByName(AttributeUsage.class, resource, "attr")) + .as("attr should be referential since it is a AttributeUsage") + .matches(Usage::isIsReference); + assertThat(this.getByName(PartUsage.class, resource, "part22")) + .as("part22 should be referential since it is a directed feature") + .matches(Usage::isIsReference); + assertThat(this.getByName(PartUsage.class, resource, "part13")) + .as("part13 should be referential using ref keyword") + .matches(Usage::isIsReference); + assertThat(this.getByName(ActionUsage.class, resource, "a1")) + .as("a1 should be referential since contained in a package") + .matches(Usage::isIsReference); + assertThat(this.getByName(ActionUsage.class, resource, "a11")) + .as("a11 should be composite since contained in another type and not prefixed with ref") + .doesNotMatch(Usage::isIsReference); + assertThat(this.getByName(ActionUsage.class, resource, "a12")) + .as("a12 should be composite since contained in another type and not prefixed with ref") + .doesNotMatch(Usage::isIsReference); + assertThat(this.getByName(SuccessionAsUsage.class, resource, "suc1")) + .as("suc1 should be referential since is a connector") + .matches(Usage::isIsReference); + assertThat(this.getByName(ReferenceUsage.class, resource, "subject_1")) + .as("subject_1 should be referential since ReferenceUsage") + .matches(Usage::isIsReference); + assertThat(this.getByName(PartUsage.class, resource, "partU_1")) + .as("partU_1 should be referential since stored in an ActorMembership") + .matches(Usage::isIsReference); + }); + } + + @Test + @DisplayName("GIVEN of model with redefinition depending on inherited memberships computation, WHEN importing the model, THEN redefined feature should resolve properly using inherited " + + "memberships") public void checkRedefinedFeatureToInheritedFields() throws IOException { var input = """ private import ISQBase::mass; @@ -285,7 +351,7 @@ public void checkMemberFeatureOfEndFeatureMembership() throws IOException { package pa1 { part pa1; part pa2; - + connect pa1 to pa2; } """; @@ -311,7 +377,7 @@ public void checkBasicMetadaUsage() throws IOException { attribute y : ScalarValues::String; :> annotatedElement : SysML::PartUsage; } - + #MD1 part p1; part p2 { @MD1 { @@ -322,7 +388,7 @@ public void checkBasicMetadaUsage() throws IOException { metadata MD1 about p3; part p4; metadata m1 : MD1 about p4; - + #MD2 part p5; }"""; this.checker.checkImportedModel(resource -> { @@ -375,17 +441,17 @@ public void checkBasicMetadaUsage() throws IOException { public void checkSemanticMetadataDefinition() throws IOException { var input = """ private import Metaobjects::SemanticMetadata; - + part def Functions { attribute x : ScalarValues::String; } - + part functions : Functions [*] nonunique; - + metadata def Function :> SemanticMetadata { :>> baseType = functions meta SysML::ActionUsage; } - + #Function action a0; action a1; metadata Function about a1; @@ -472,7 +538,7 @@ public void checkBindingConnectorWithFeatureChaine() throws IOException { package pk1 { action a1 { in item i1; - + action a11 { in item i11; } @@ -537,11 +603,11 @@ public void checkTextualRepresentationFeatures() throws IOException { public void checkFeatureChainExpressionNameResolution() throws IOException { var input = """ action def P1 { - + action def A2 { out pr2 : ScalarValues::Boolean; } - + action a2 : A2 { out pr2; } @@ -593,11 +659,11 @@ public void checkFeatureChainExpressionWithImplicitParameterRedefinitionNameReso part def P1 { isValid : ScalarValues::Boolean; } - + action def A2 { out prA2 : P1; } - + action a2 : A2 { out pra2; } @@ -898,7 +964,7 @@ public void checkSuccessionAsUsageWkithStartSourceTest() throws IOException { public void checkTransitionUsageWithAcceptActionUsageTest() throws IOException { var input = """ attribute def StartSignal; - + state myState { state off; accept StartSignal then on; @@ -910,9 +976,9 @@ public void checkTransitionUsageWithAcceptActionUsageTest() throws IOException { TransitionUsage transitionUsage = transitionUsages.get(0); assertThat(transitionUsage.getTriggerAction()).hasSize(1); Optional optionalTransitionFeatureMembership = transitionUsage.getOwnedRelationship().stream() - .filter(TransitionFeatureMembership.class::isInstance) - .map(TransitionFeatureMembership.class::cast) - .findFirst(); + .filter(TransitionFeatureMembership.class::isInstance) + .map(TransitionFeatureMembership.class::cast) + .findFirst(); assertThat(optionalTransitionFeatureMembership).isPresent(); assertThat(optionalTransitionFeatureMembership.get().getKind()).isEqualTo(TransitionFeatureKind.TRIGGER); }).check(input); @@ -923,7 +989,7 @@ public void checkTransitionUsageWithAcceptActionUsageTest() throws IOException { public void checkTransitionUsageWithAcceptActionUsageAndSendSignalActionTest() throws IOException { var input = """ attribute def StartSignal; - + state myState { state off; accept StartSignal @@ -1028,7 +1094,7 @@ public void checkPartUsageMultiplicityChainedFeatureReferenceExpressionBoundsTes attribute upper:ScalarValues::Integer = 2; } } - + part myPart[bounds::lower..bounds::upperBounds::upper]; """; this.checker.checkImportedModel(resource -> { @@ -1121,6 +1187,10 @@ private void checkMetadaUsage(MetadataUsage metadata, String expectedDefinitionN } } + private T getByName(Class type, Notifier root, String name) { + return EMFUtils.allContainedObjectOfType(root, type).filter(e -> name.equals(e.getDeclaredName())).findFirst().orElseThrow(); + } + private void assertStringValue(Feature f, String expectedValue) { FeatureValue valuation = new UtilService().getValuation(f); assertNotNull(valuation); diff --git a/backend/metamodel/syson-sysml-metamodel/src/main/java/org/eclipse/syson/sysml/textual/SysMLElementSerializer.java b/backend/metamodel/syson-sysml-metamodel/src/main/java/org/eclipse/syson/sysml/textual/SysMLElementSerializer.java index f614eb926..115362d26 100644 --- a/backend/metamodel/syson-sysml-metamodel/src/main/java/org/eclipse/syson/sysml/textual/SysMLElementSerializer.java +++ b/backend/metamodel/syson-sysml-metamodel/src/main/java/org/eclipse/syson/sysml/textual/SysMLElementSerializer.java @@ -50,6 +50,7 @@ import org.eclipse.syson.sysml.ConjugatedPortDefinition; import org.eclipse.syson.sysml.ConjugatedPortTyping; import org.eclipse.syson.sysml.ConnectionUsage; +import org.eclipse.syson.sysml.Connector; import org.eclipse.syson.sysml.ConstraintUsage; import org.eclipse.syson.sysml.ControlNode; import org.eclipse.syson.sysml.DecisionNode; @@ -59,6 +60,7 @@ import org.eclipse.syson.sysml.EndFeatureMembership; import org.eclipse.syson.sysml.EnumerationDefinition; import org.eclipse.syson.sysml.EnumerationUsage; +import org.eclipse.syson.sysml.EventOccurrenceUsage; import org.eclipse.syson.sysml.Expose; import org.eclipse.syson.sysml.Expression; import org.eclipse.syson.sysml.Feature; @@ -126,6 +128,8 @@ import org.eclipse.syson.sysml.SelectExpression; import org.eclipse.syson.sysml.Specialization; import org.eclipse.syson.sysml.StakeholderMembership; +import org.eclipse.syson.sysml.StateDefinition; +import org.eclipse.syson.sysml.StateSubactionMembership; import org.eclipse.syson.sysml.StateUsage; import org.eclipse.syson.sysml.Subclassification; import org.eclipse.syson.sysml.SubjectMembership; @@ -182,9 +186,9 @@ public class SysMLElementSerializer extends SysmlSwitch { * Simple constructor. * * @param lineSeparator - * the string used to separate line + * the string used to separate line * @param indentation - * the string used to indent the file + * the string used to indent the file */ public SysMLElementSerializer(String lineSeparator, String indentation, INameDeresolver nameDeresolver, Consumer reportConsumer) { super(); @@ -226,13 +230,24 @@ public String caseActionDefinition(ActionDefinition actionDef) { @Override public String caseActionUsage(ActionUsage actionUsage) { Appender builder = new Appender(this.lineSeparator, this.indentation); - this.appendOccurrenceUsagePrefix(builder, actionUsage); - builder.appendWithSpaceIfNeeded("action"); - this.appendActionUsageDeclaration(builder, actionUsage); + if (!this.isEmptyActionAsStateSubAction(actionUsage)) { + // Normal rule "ActionUsage" + this.appendOccurrenceUsagePrefix(builder, actionUsage); + builder.appendWithSpaceIfNeeded("action"); + this.appendActionUsageDeclaration(builder, actionUsage); + + } // Rule "EmptyActionUsage" when store in StateSubactionMembership => Do nothing + this.appendChildrenContent(builder, actionUsage, actionUsage.getOwnedMembership()); + return builder.toString(); } + private boolean isEmptyActionAsStateSubAction(ActionUsage actionUsage) { + return actionUsage.getOwningMembership() instanceof StateSubactionMembership && actionUsage.getOwnedRelationship() + .isEmpty() && actionUsage.getDeclaredName() == null && actionUsage.getShortName() == null; + } + @Override public String caseAnalysisCaseUsage(AnalysisCaseUsage analysisCaseUsage) { this.reportUnhandledType(analysisCaseUsage); @@ -748,7 +763,6 @@ public String caseObjectiveMembership(ObjectiveMembership objective) { @Override public String caseOccurrenceUsage(OccurrenceUsage occurrenceUsage) { Appender builder = new Appender(this.lineSeparator, this.indentation); - this.appendUsagePrefix(builder, occurrenceUsage); this.appendOccurrenceUsagePrefix(builder, occurrenceUsage); if (PortionKind.SNAPSHOT.equals(occurrenceUsage.getPortionKind())) { builder.appendWithSpaceIfNeeded("snapshot"); @@ -858,33 +872,25 @@ public String casePartDefinition(PartDefinition partDef) { @Override public String casePartUsage(PartUsage partUsage) { - return this.appendDefaultUsage(this.newAppender(), partUsage).toString(); + return this.appendDefaultUsage(this.newAppender(), partUsage); } @Override - public String casePerformActionUsage(PerformActionUsage perfomActionUsage) { + public String casePerformActionUsage(PerformActionUsage performActionUsage) { Appender builder = new Appender(this.lineSeparator, this.indentation); + if (!(performActionUsage.getOwningMembership() instanceof StateSubactionMembership)) { + // Rule simple "PerformActionUsage" from BehaviorUsageElement + this.appendOccurrenceUsagePrefix(builder, performActionUsage); - this.appendOccurrenceUsagePrefix(builder, perfomActionUsage); - - builder.appendWithSpaceIfNeeded("perform"); - - Appender nameAppender = new Appender(this.lineSeparator, this.indentation); - this.appendNameWithShortName(nameAppender, perfomActionUsage); + builder.appendWithSpaceIfNeeded("perform"); + } // Else do nothing using rule PerformActionUsageDeclaration - if (nameAppender.isEmpty() && perfomActionUsage.getOwnedReferenceSubsetting() != null) { - // Use simple form : perfom - this.appendOwnedReferenceSubsetting(builder, perfomActionUsage.getOwnedReferenceSubsetting()); - } else { - // Use complete form - builder.appendWithSpaceIfNeeded("action"); - this.appendUsageDeclaration(builder, perfomActionUsage); - } + this.appendPerformActionUsageDeclaration(performActionUsage, builder); - this.appendValuePart(builder, perfomActionUsage); + this.appendValuePart(builder, performActionUsage); - this.appendChildrenContent(builder, perfomActionUsage, perfomActionUsage.getOwnedMembership()); + this.appendChildrenContent(builder, performActionUsage, performActionUsage.getOwnedMembership()); return builder.toString(); } @@ -1064,10 +1070,56 @@ public String caseStakeholderMembership(StakeholderMembership stakeholderMembers return builder.toString(); } + @Override + public String caseStateDefinition(StateDefinition stateDefinition) { + Appender builder = this.newAppender(); + this.appendDefinitionPrefix(builder, stateDefinition); + builder.appendSpaceIfNeeded().append(SysMLv2Keywords.STATE + " " + SysMLv2Keywords.DEF); + this.appendDefinitionDeclaration(builder, stateDefinition); + + if (stateDefinition.isIsParallel()) { + builder.appendWithSpaceIfNeeded(SysMLv2Keywords.PARALLEL); + } + + this.appendChildrenContent(builder, stateDefinition, stateDefinition.getOwnedMembership()); + return builder.toString(); + } + + @Override + public String caseStateSubactionMembership(StateSubactionMembership stateSubactionMembership) { + Appender builder = this.newAppender(); + + this.appendMembershipPrefix(stateSubactionMembership, builder); + + String stateKind = switch (stateSubactionMembership.getKind()) { + case DO -> SysMLv2Keywords.DO; + case ENTRY -> SysMLv2Keywords.ENTRY; + case EXIT -> SysMLv2Keywords.EXIT; + }; + + builder.appendWithSpaceIfNeeded(stateKind); + + String content = stateSubactionMembership.getOwnedRelatedElement().stream() + .map(this::doSwitch).filter(Objects::nonNull).collect(joining(builder.getNewLine())); + builder.appendSpaceIfNeeded().append(content); + + return builder.toString(); + } + @Override public String caseStateUsage(StateUsage stateUsage) { - this.reportUnhandledType(stateUsage); - return ""; + Appender builder = this.newAppender(); + + this.appendOccurrenceUsagePrefix(builder, stateUsage); + builder.appendWithSpaceIfNeeded(SysMLv2Keywords.STATE); + this.appendActionUsageDeclaration(builder, stateUsage); + + if (stateUsage.isIsParallel()) { + builder.appendWithSpaceIfNeeded(SysMLv2Keywords.PARALLEL); + } + + this.appendChildrenContent(builder, stateUsage, stateUsage.getOwnedMembership()); + return builder.toString(); } @Override @@ -1267,7 +1319,7 @@ public String caseViewpointDefinition(ViewpointDefinition vp) { * Get a String representation of the "AcceptParameterPart" BNF rule to be used on {@link AcceptActionUsage}. * * @param acceptActionUsage - * a non null {@link AcceptActionUsage} + * a non null {@link AcceptActionUsage} * @return a String representation of the "AcceptParameterPart" */ public String getAcceptParameterPart(AcceptActionUsage acceptActionUsage) { @@ -1412,7 +1464,7 @@ private void appendDefinitionBody(Appender builder, Usage usage) { * Checks if the source feature define force the given {@link EndFeatureMembership} is implicit or not * * @param endFeatureMembership - * the element to test + * the element to test * @return true if the given EndFeatureMembership represent an implicit feature */ private boolean isSuccessionUsageImplicitSource(EndFeatureMembership endFeatureMembership) { @@ -2139,11 +2191,11 @@ private boolean isNotSuccessionWithSameSource(Membership m, Feature source) { * candidates {@link FeatureMembership} are filtered using a given predicate * * @param expectedFeature - * the expected feature + * the expected feature * @param sourceElement - * the source feature from which the previous elements will be searched + * the source feature from which the previous elements will be searched * @param candidatePredicate - * an optional predicate to filter among the previous elements + * an optional predicate to filter among the previous elements * @return true if the previous feature is the expected one */ private boolean isPreviousFeatureEqualsTo(Feature expectedFeature, Feature sourceElement, Predicate candidatePredicate) { @@ -2175,16 +2227,14 @@ private void reportUnhandledType(Element e) { * Returns true if the comment describes its direct owning namespace * * @param comment - * a comment + * a comment * @return true if described is direct owning namespace */ private boolean isSelfNamespaceDescribingComment(Comment comment) { EList annotatedElements = comment.getAnnotatedElement(); if (!annotatedElements.isEmpty()) { Element annotatedElement = annotatedElements.get(0); - if (annotatedElement instanceof Namespace owningNamespace && (owningNamespace == this.getDirectContainer(comment, Namespace.class))) { - return true; - } + return annotatedElement instanceof Namespace owningNamespace && (owningNamespace == this.getDirectContainer(comment, Namespace.class)); } return false; } @@ -2201,9 +2251,9 @@ private void appendAnnotatedElements(Appender builder, Comment comment, EListnull if no direct container of the expected type */ private T getDirectContainer(EObject element, Class expected) { @@ -2231,7 +2281,7 @@ private void appendLocale(Appender builder, String local) { private void appendControlNodePrefix(Appender builder, ControlNode controlNode) { final String isRef; - if (controlNode.isIsReference() && !this.isImplicitlyReferential(controlNode)) { + if (controlNode.isIsReference() && this.requireReferentialKeyword(controlNode)) { isRef = "ref"; } else { isRef = ""; @@ -2274,7 +2324,7 @@ private void appenDecisionTransition(TransitionUsage transitionUsage, Appender b if (sourceFeature != null // Skip this part for Transition with implicit source and no declared name && (!this.isPreviousFeatureEqualsTo(sourceFeature, transitionUsage, m -> this.isNotSuccessionWithSameSource(m, sourceFeature)) - || !declarionAppender.isEmpty())) { + || !declarionAppender.isEmpty())) { builder.appendWithSpaceIfNeeded("first ").append(this.getDeresolvableName(sourceFeature, transitionUsage)); } @@ -2351,9 +2401,9 @@ private void appendUsageDeclaration(Appender builder, Usage usage) { * Get a deresolvable name for a given element in a given context * * @param toDeresolve - * the object to deresolve + * the object to deresolve * @param context - * a context + * a context * @return a name */ private String getDeresolvableName(Element toDeresolve, Element context) { @@ -2436,15 +2486,6 @@ private void appendDefinitionPrefix(Appender builder, Definition def) { private void appendUsagePrefix(Appender builder, Usage usage) { this.appendBasicUsagePrefix(builder, usage); - final String isRef; - if (usage.isIsReference() && !this.isImplicitlyReferential(usage)) { - isRef = "ref"; - } else { - isRef = ""; - } - - builder.appendSpaceIfNeeded().append(isRef); - this.appendExtensionKeyword(builder, usage); } @@ -2462,10 +2503,48 @@ private void appendOccurrenceUsagePrefix(Appender builder, OccurrenceUsage occUs this.appendExtensionKeyword(builder, occUsage); } - private boolean isImplicitlyReferential(Usage usage) { - return usage.getOwningMembership() instanceof ActorMembership - || usage instanceof AttributeUsage - || usage instanceof ReferenceUsage; + /** + * Check if the given referential {@link Usage} needs to explicitly add the "ref" keyword in the textual format. + * + *

Note that this method should only be called if {@link Usage#isIsReference()} is true

+ * + * @param usage + * a non null usage + * @return {@code true} if the "ref" keyword can be omitted + */ + private boolean requireReferentialKeyword(Usage usage) { + // In some case the ref keyword can be omitted if the referential nature of the usage is implicit + return !this.isReferentialByNature(usage) && !this.isReferentialByFeature(usage) && !this.isReferentialByConstruct(usage); + } + + private boolean isReferentialByNature(Usage usage) { + // If is an AttributeUsage see SysML V2 specification : + // "AttributeUsages are also syntactically restricted by the validateAttributeUsageIsReference to be referential (non-composite)" + return usage instanceof AttributeUsage + // Is a ReferenceUsage see SysML V2 specification 7.6.4 Reference Usages: + // " However, a reference usage is always, by definition, referential. A reference usage is otherwise declared like any other usage, as given above." + || usage instanceof ReferenceUsage + // Is an EventOccurrenceUsage see cf SysML V2 8.4.5.3 Event Occurrence Usages : + // "An EventOccurrenceUsage is a kind of OccurrenceUsage that is required to always be referential" + || usage instanceof EventOccurrenceUsage + // Is a connector + || usage instanceof Connector; + } + + private boolean isReferentialByConstruct(Usage usage) { + // Feature not contained in type + // See https://groups.google.com/g/sysml-v2-release/c/BTwBJLCyozM/m/aNILfQylAQAJ + return !(usage.getOwningMembership() instanceof FeatureMembership && usage.getOwner() instanceof Type) + || usage.getOwningMembership() instanceof ActorMembership; + } + + private boolean isReferentialByFeature(Usage usage) { + // If is a directed feature see SysML v2 Section 7.6.3 Usages (Snippet 5) : + // "Note also that a directed usage is always referential, whether or not the keyword ref is also given explicitly in its declaration." + return usage.getDirection() != null + // Is end feature see SysML V2 specification 7.13.2 Connection Definitions and Usages + // "End features are always considered referential (non-composite), whether or not their declaration explicitly includes the ref keyword" + || usage.isIsEnd(); } private void appendExtensionKeyword(Appender builder, Type type) { @@ -2487,7 +2566,7 @@ private void appendPrefixMetadataMember(Appender builder, MetadataUsage metadata } } - + private void appendRequirementConstraintUsage(Appender builder, ConstraintUsage constraintUsage) { if (this.useRequirementConstraintUsageShortHandNotation(constraintUsage)) { this.appendRequirementConstraintUsageShorthandNotation(builder, constraintUsage); @@ -2567,6 +2646,9 @@ private void appendBasicUsagePrefix(Appender builder, Usage usage) { builder.appendSpaceIfNeeded(); builder.append("end"); } + if (usage.isIsReference() && this.requireReferentialKeyword(usage)) { + builder.appendWithSpaceIfNeeded(SysMLv2Keywords.REF); + } } private FeatureDirectionKind getDefaultDirection(Usage usage) { @@ -2772,4 +2854,18 @@ private boolean useRequirementConstraintUsageShortHandNotation(ConstraintUsage c && constraintUsage.getOwnedReferenceSubsetting() != null && constraintUsage.getNestedMetadata().isEmpty(); } + + private void appendPerformActionUsageDeclaration(PerformActionUsage performActionUsage, Appender builder) { + Appender nameAppender = new Appender(this.lineSeparator, this.indentation); + this.appendNameWithShortName(nameAppender, performActionUsage); + + if (nameAppender.isEmpty() && performActionUsage.getOwnedReferenceSubsetting() != null) { + // Use simple form : perfom + this.appendOwnedReferenceSubsetting(builder, performActionUsage.getOwnedReferenceSubsetting()); + } else { + // Use complete form + builder.appendWithSpaceIfNeeded("action"); + this.appendUsageDeclaration(builder, performActionUsage); + } + } } diff --git a/backend/metamodel/syson-sysml-metamodel/src/main/java/org/eclipse/syson/sysml/textual/utils/FileNameDeresolver.java b/backend/metamodel/syson-sysml-metamodel/src/main/java/org/eclipse/syson/sysml/textual/utils/FileNameDeresolver.java index e8fe52390..b02b4c88a 100644 --- a/backend/metamodel/syson-sysml-metamodel/src/main/java/org/eclipse/syson/sysml/textual/utils/FileNameDeresolver.java +++ b/backend/metamodel/syson-sysml-metamodel/src/main/java/org/eclipse/syson/sysml/textual/utils/FileNameDeresolver.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -229,13 +229,7 @@ private String buildRelativeQualifiedName(Element element, Namespace owningNames // conflict. // In that case keep we need a more detailed qualified name if (resolvedElement != null && !this.match(element, resolvedElement)) { - // Last try if the element is in the containment tree find the shortest qualified name - String qualifiedName = this.getQualifiedName(owningNamespace); - if (!qualifiedName.isBlank() && elementQn.startsWith(qualifiedName)) { - relativeQualifiedName = owningNamespace.getName() + "::" + elementQn.substring(qualifiedName.length() + 2); - } else { - relativeQualifiedName = elementQn; - } + relativeQualifiedName = elementQn; } return relativeQualifiedName; diff --git a/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/FileNameDeresolverTest.java b/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/FileNameDeresolverTest.java index 665736208..f292b8545 100644 --- a/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/FileNameDeresolverTest.java +++ b/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/FileNameDeresolverTest.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -87,7 +87,7 @@ public void checkNameCollision() { * ref attribute mass; // Needs qualified name since the attribute p3::mass hides the imported element * package p3x1 { * ref attribute mass2 :> Lib2::mass; // Needs qualified name since the attribute p3::mass hides the imported element - * part Part1 : p3::Part1; // We need relative qualified name because Part1 usage conflict with Part1 definition + * part Part1 : p3::Part1; // We need qualified name because Part1 usage conflict with Part1 definition * } * } * } @@ -143,8 +143,8 @@ public void checkNameCollision() { assertEquals("Lib2::mass", this.getDeresolvedName(massAttr, p3Mass)); // Needs qualified name since the attribute p3::mass hides the imported element assertEquals("Lib2::mass", this.getDeresolvedName(massAttr, p3x1Mass)); - // Need relative qualified name - assertEquals("p3::Part1", this.getDeresolvedName(part1Def, part1Usage)); + // Need qualified name cause name conflict + assertEquals("Root::p3::Part1", this.getDeresolvedName(part1Def, part1Usage)); } @Test diff --git a/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/SysMLElementSerializerTest.java b/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/SysMLElementSerializerTest.java index d736e2ac5..867f9c82e 100644 --- a/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/SysMLElementSerializerTest.java +++ b/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/SysMLElementSerializerTest.java @@ -346,7 +346,7 @@ public void partUsage() { this.builder.createIn(Comment.class, partUsage).setBody(BODY); this.assertTextualFormEquals(""" - ref part PartUsage1 : 'Part Def1' { + part PartUsage1 : 'Part Def1' { /* A body */ }""", partUsage); } @@ -1402,7 +1402,9 @@ public void actionUsageWithSuccessionSimple() { this.assertTextualFormEquals("action a;", actionUsage); ActionUsage subAction1 = this.builder.createInWithName(ActionUsage.class, actionUsage, "a_1"); + subAction1.setIsComposite(true); ActionUsage subAction2 = this.builder.createInWithName(ActionUsage.class, actionUsage, "a_2"); + subAction2.setIsComposite(true); this.assertTextualFormEquals(""" action a { @@ -1457,6 +1459,7 @@ public void actionUsageWithActionDefinitionAndSuccession() { this.assertTextualFormEquals("action a : A;", actionUsage); ActionUsage subAction2 = this.builder.createInWithName(ActionUsage.class, actionUsage, "a_2"); + subAction2.setIsComposite(true); this.builder.createSuccessionAsUsage(SuccessionAsUsage.class, actionUsage, subAction1, subAction2); this.assertTextualFormEquals(""" @@ -1492,11 +1495,15 @@ public void successionUsageWithUnamedAction() { this.assertTextualFormEquals("action a;", actionUsage); ActionUsage subAction1 = this.builder.createInWithName(ActionUsage.class, actionUsage, "a_1"); + subAction1.setIsComposite(true); + this.builder.createInWithName(ActionUsage.class, actionUsage, "a_2"); ActionUsage unamedAction = this.builder.createIn(ActionUsage.class, actionUsage); + unamedAction.setIsComposite(true); this.assertTextualFormEquals(""" action a { action a_1; + ref action a_2; action; }""", actionUsage); @@ -1506,6 +1513,7 @@ public void successionUsageWithUnamedAction() { this.assertTextualFormEquals(""" action a { action a_1; + ref action a_2; action { /* This is an action with no name */ } @@ -1516,6 +1524,7 @@ public void successionUsageWithUnamedAction() { this.assertTextualFormEquals(""" action a { action a_1; + ref action a_2; action { /* This is an action with no name */ } @@ -1566,10 +1575,10 @@ public void perfomActionFullForm() { part def Camera { private import PictureTaking::*; perform action takePicture :> PictureTaking::takePicture [0..*]; - ref part focusingSubsystem { + part focusingSubsystem { perform takePicture.focus; } - ref part imagingSubsystem { + part imagingSubsystem { perform takePicture.shoot; } }""", cameraModel.getCamera()); @@ -1646,6 +1655,7 @@ public void portUsageFull() { PortUsage subPortUsage = this.builder.createInWithName(PortUsage.class, portUsage, "subPortUsage 1"); this.builder.setType(subPortUsage, portDefinition); + subPortUsage.setIsComposite(true); this.assertTextualFormEquals(""" package pack1 { @@ -1669,7 +1679,7 @@ public void documentation() { this.builder.createIn(Documentation.class, partUsage).setBody("A comment"); this.assertTextualFormEquals(""" - ref part PartUsage1 : 'Part Def1' { + part PartUsage1 : 'Part Def1' { doc /* A comment */ }""", partUsage); } @@ -1687,7 +1697,7 @@ public void documentationWithDeclaredName() { partUsage.getDocumentation().get(0).setDeclaredName(ANNOTATING1); this.assertTextualFormEquals(""" - ref part PartUsage1 : 'Part Def1' { + part PartUsage1 : 'Part Def1' { doc Annotating1 /* A body */ }""", partUsage); } diff --git a/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/models/sample/CameraModel.java b/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/models/sample/CameraModel.java index f51149914..3a1ca7a2e 100644 --- a/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/models/sample/CameraModel.java +++ b/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/models/sample/CameraModel.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -112,6 +112,7 @@ private void build() { this.builder.addSubsetting(this.takePicture, this.pictureTakingModel.getTakePicture()); this.focusingSubsystem = this.builder.createInWithName(PartUsage.class, this.camera, "focusingSubsystem"); + this.focusingSubsystem.setIsComposite(true); this.focusingSubsystemPerformAction = this.builder.createIn(PerformActionUsage.class, this.focusingSubsystem); @@ -120,6 +121,7 @@ private void build() { this.builder.addReferenceSubsetting(this.focusingSubsystemPerformAction, focusFeatureChain); this.imagingSubsystem = this.builder.createInWithName(PartUsage.class, this.camera, "imagingSubsystem"); + this.imagingSubsystem.setIsComposite(true); this.imagingSubsystemPerformAction = this.builder.createIn(PerformActionUsage.class, this.imagingSubsystem); diff --git a/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/models/sample/ItemTest.java b/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/models/sample/ItemTest.java index b354c8aec..04eb1e804 100644 --- a/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/models/sample/ItemTest.java +++ b/backend/metamodel/syson-sysml-metamodel/src/test/java/org/eclipse/syson/sysml/textual/models/sample/ItemTest.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2024, 2025 Obeo. + * Copyright (c) 2024, 2026 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -101,6 +101,7 @@ private void build() { this.builder.setType(this.f, this.aDef); this.b = this.builder.createInWithName(ItemUsage.class, this.aDef, "b"); + this.b.setIsComposite(true); this.c = this.builder.createInWithName(PartUsage.class, this.aDef, "c"); this.c.getOwningMembership().setVisibility(VisibilityKind.PROTECTED); diff --git a/backend/services/syson-diagram-services/src/main/java/org/eclipse/syson/diagram/services/DiagramQueryLabelService.java b/backend/services/syson-diagram-services/src/main/java/org/eclipse/syson/diagram/services/DiagramQueryLabelService.java index fa711911f..059a292bb 100644 --- a/backend/services/syson-diagram-services/src/main/java/org/eclipse/syson/diagram/services/DiagramQueryLabelService.java +++ b/backend/services/syson-diagram-services/src/main/java/org/eclipse/syson/diagram/services/DiagramQueryLabelService.java @@ -15,6 +15,7 @@ import static java.util.stream.Collectors.joining; import java.util.Objects; +import java.util.Optional; import java.util.function.BinaryOperator; import org.eclipse.emf.common.util.EList; @@ -38,6 +39,7 @@ import org.eclipse.syson.sysml.FeatureValue; import org.eclipse.syson.sysml.LiteralExpression; import org.eclipse.syson.sysml.MultiplicityRange; +import org.eclipse.syson.sysml.Namespace; import org.eclipse.syson.sysml.OwningMembership; import org.eclipse.syson.sysml.Redefinition; import org.eclipse.syson.sysml.ReferenceSubsetting; @@ -50,6 +52,7 @@ import org.eclipse.syson.sysml.Type; import org.eclipse.syson.sysml.Usage; import org.eclipse.syson.sysml.VariantMembership; +import org.eclipse.syson.sysml.helper.EMFUtils; import org.eclipse.syson.sysml.helper.LabelConstants; import org.eclipse.syson.sysml.textual.SysMLElementSerializer; import org.eclipse.syson.sysml.textual.utils.Appender; @@ -202,13 +205,37 @@ public String getTypingLabel(Element element) { label.append(LabelConstants.SPACE); label.append(LabelConstants.COLON); label.append(LabelConstants.SPACE); - label.append(this.getDeclaredNameLabel(type)); + label.append(this.buildImportContextRelativeQualifiedName(type, element)); } } } return label.toString(); } + private String buildImportContextRelativeQualifiedName(Element element, Element from) { + String qualifiedName = Optional.ofNullable(element.getQualifiedName()).orElse(""); + Element commonAncestor = EMFUtils.getLeastCommonContainer(Element.class, element, from); + if (commonAncestor != null) { + String prefix = commonAncestor.getQualifiedName() + "::"; + if (qualifiedName.startsWith(prefix)) { + qualifiedName = qualifiedName.substring(prefix.length()); + } + } + var namespaces = EMFUtils.getAncestors(Namespace.class, from, null); + if (namespaces.stream().anyMatch(ns -> this.nsImports(ns, element))) { + qualifiedName = Optional.ofNullable(element.getDeclaredName()).orElse(""); + } + return qualifiedName; + } + + private boolean nsImports(Namespace ns, Element element) { + return ns.getImportedMembership().stream() + .filter(OwningMembership.class::isInstance) + .map(OwningMembership.class::cast) + .map(OwningMembership::getMemberElement) + .anyMatch(importedMemeber -> importedMemeber == element); + } + @Override public String getValueLabel(Usage usage) { return this.getValueStringRepresentation(usage, false); diff --git a/backend/services/syson-diagram-services/src/test/java/org/eclipse/syson/diagram/services/DiagramQueryLabelServiceTest.java b/backend/services/syson-diagram-services/src/test/java/org/eclipse/syson/diagram/services/DiagramQueryLabelServiceTest.java index 19cf66027..3ec5393e2 100644 --- a/backend/services/syson-diagram-services/src/test/java/org/eclipse/syson/diagram/services/DiagramQueryLabelServiceTest.java +++ b/backend/services/syson-diagram-services/src/test/java/org/eclipse/syson/diagram/services/DiagramQueryLabelServiceTest.java @@ -19,19 +19,25 @@ import org.eclipse.syson.sysml.AttributeUsage; import org.eclipse.syson.sysml.ConstraintUsage; +import org.eclipse.syson.sysml.DataType; import org.eclipse.syson.sysml.Dependency; +import org.eclipse.syson.sysml.Element; import org.eclipse.syson.sysml.Feature; import org.eclipse.syson.sysml.FeatureChainExpression; import org.eclipse.syson.sysml.FeatureChaining; import org.eclipse.syson.sysml.FeatureDirectionKind; import org.eclipse.syson.sysml.FeatureReferenceExpression; +import org.eclipse.syson.sysml.FeatureTyping; import org.eclipse.syson.sysml.FeatureValue; import org.eclipse.syson.sysml.InterfaceUsage; import org.eclipse.syson.sysml.LiteralInteger; import org.eclipse.syson.sysml.Membership; +import org.eclipse.syson.sysml.NamespaceImport; import org.eclipse.syson.sysml.OperatorExpression; import org.eclipse.syson.sysml.OwningMembership; +import org.eclipse.syson.sysml.Package; import org.eclipse.syson.sysml.ParameterMembership; +import org.eclipse.syson.sysml.PartDefinition; import org.eclipse.syson.sysml.ReferenceUsage; import org.eclipse.syson.sysml.RequirementConstraintMembership; import org.eclipse.syson.sysml.ResultExpressionMembership; @@ -426,6 +432,51 @@ public void testGetEdgeLabelOfInterfaceWithNameAndShortName() { assertThat(this.labelService.getEdgeLabel(interfaceUsage)).isEqualTo(SHORT_NAME_LABEL + " interface"); } + @DisplayName("GIVEN a namespace imported, WHEN the label of an attribute whose type is from the imported namespace, THEN the attribute's type should be shortened in its label") + @Test + public void testAttributeTypeShortenedIfNamespaceImported() { + String customTypeName = "CustomType"; + + Package parentPackage = SysmlFactory.eINSTANCE.createPackage(); + parentPackage.setDeclaredName("Parent"); + + Package definition = SysmlFactory.eINSTANCE.createPackage(); + definition.setDeclaredName("TypeDefinition"); + this.addOwnedMember(parentPackage, definition); + + DataType customDataType = SysmlFactory.eINSTANCE.createDataType(); + customDataType.setDeclaredName(customTypeName); + this.addOwnedMember(definition, customDataType); + + Package usage = SysmlFactory.eINSTANCE.createPackage(); + usage.setDeclaredName("TypeUsage"); + this.addOwnedMember(parentPackage, usage); + + NamespaceImport nsImport = SysmlFactory.eINSTANCE.createNamespaceImport(); + nsImport.setImportedNamespace(definition); + this.addOwnedMember(usage, nsImport); + + PartDefinition partDef = SysmlFactory.eINSTANCE.createPartDefinition(); + partDef.setDeclaredName("PartDef1"); + this.addOwnedMember(usage, partDef); + + AttributeUsage attribute = SysmlFactory.eINSTANCE.createAttributeUsage(); + attribute.setDeclaredName("x1"); + this.addOwnedMember(partDef, attribute); + + FeatureTyping typing = SysmlFactory.eINSTANCE.createFeatureTyping(); + typing.setType(customDataType); + attribute.getOwnedRelationship().add(typing); + + assertThat(this.labelService.getCompartmentItemLabel(attribute)).isEqualTo("x1 : CustomType"); + } + + private void addOwnedMember(Element parent, Element child) { + OwningMembership owningMembership = SysmlFactory.eINSTANCE.createOwningMembership(); + owningMembership.getOwnedRelatedElement().add(child); + parent.getOwnedRelationship().add(owningMembership); + } + /** * Creates an {@link OperatorExpression} in the given {@code constraintUsage}. *

diff --git a/doc/content/modules/developer-guide/assets/images/elasticsearch-discover-view.png b/doc/content/modules/developer-guide/assets/images/elasticsearch-discover-view.png new file mode 100644 index 000000000..0aa0ecf5c Binary files /dev/null and b/doc/content/modules/developer-guide/assets/images/elasticsearch-discover-view.png differ diff --git a/doc/content/modules/developer-guide/assets/images/elasticsearch-shell.png b/doc/content/modules/developer-guide/assets/images/elasticsearch-shell.png new file mode 100644 index 000000000..b5e92208e Binary files /dev/null and b/doc/content/modules/developer-guide/assets/images/elasticsearch-shell.png differ diff --git a/doc/content/modules/developer-guide/pages/elasticsearch-integration.adoc b/doc/content/modules/developer-guide/pages/elasticsearch-integration.adoc new file mode 100644 index 000000000..8e470f061 --- /dev/null +++ b/doc/content/modules/developer-guide/pages/elasticsearch-integration.adoc @@ -0,0 +1,334 @@ += Elasticsearch integration + +include::user-manual:partial$before-you-start-experimental-all.adoc[] + +{product} can be configured to index models in Elasticsearch. +When configured, this feature allows to transparently index models created and modified by the users. +These indices can be queried through the xref:user-manual:features/cross-project-search.adoc[cross project search] to find an element regardless of its containing project. + +Let's consider that you are trying to debug an issue related to the indexing of models in Elasticsearch. +Elasticsearch comes with Kibana, a browser-based analytics and search dashboard that lets you visualize and manage your indices (among other features we won't cover here). + +== Install Elasticsearch + +You can run the following command to install a development instance of Elasticsearch on your computer. +This will install Elasticsearch (the index engine), and Kibana, a browser-based analytics and search dashboard that lets you visualize and manage your indices (among other features we won't cover here). + +[source, bash] +---- +curl -fsSL https://elastic.co/start-local | sh +---- + +If you don't want to install Elasticsearch directly on your computer, you can use the instructions below. + +Start by creating a folder for the data: + +[source, bash] +---- +mkdir -p VOLUME_PATH_ON_YOUR_COMPUTER +chmod 776 VOLUME_PATH_ON_YOUR_COMPUTER -R +---- + +Then start Elasticsearch: + +[source, bash] +---- +docker pull elasticsearch:VERSION +docker network create elastic +docker run -d --name elasticsearch -m 2g --net elastic -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" -v VOLUME_PATH_ON_YOUR_COMPUTER elasticsearch:VERSION +---- + +Note the `-m 2g` parameter to limit the memory available to Elasticsearch; otherwise, at least under Linux, it tends to use a significant portion of the system's memory. +Of course, you can adjust the actual value depending on your needs, but 2G should be enough for simple local development and testing. + +Reset the password for the account `elastic`: + +[source, bash] +---- +docker exec -it elasticsearch /usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic +---- + +The password will be written in the console. +Now retrieve the enrollment token for Kibana: + +[source, bash] +---- +docker exec -it elasticsearch /usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s kibana +---- + +Copy the Elasticsearch certificate from the Docker container: + +[source, bash] +---- +docker cp elasticsearch:/usr/share/elasticsearch/config/certs/http_ca.crt . +---- + +Put this certificate somewhere in your computer and use it to test the connexion to Elasticsearch + +[source, bash] +---- +curl --cacert PATH_TO_THE_CERTIFICATE/http_ca.crt -u "elastic:PASSWORD" https://localhost:9200 +---- + +Now let's start Kibana: + +[source, bash] +---- +docker pull kibana:VERSION +docker run -d --name kibana --net elastic -p 5601:5601 kibana:VERSION +---- + +Retrieve the Kibana verification code: + +[source, bash] +---- +docker exec -it kibana /usr/share/kibana/bin/kibana-verification-code +---- + +And go to `http://localhost:5601/` in a web browser to use both the enrollment token and the verification code to connect Kibana and Elasticsearch. +After all that, you may need to add the Elasticsearch certificate to the keystore of your JVM to allow Sirius Web to communicate with Elasticsearch: + +[source, bash] +---- +sudo keytool -J-Duser.language=en -import -trustcacerts -alias elasticsearch-siriusweb -keystore PATH_OF_THE_JVM/lib/security/cacerts -file PATH_TO_THE_CERTIFICATE/http_ca.crt +---- + +TIP: `keytool` will ask you for a password for the JVM's keystore. +If you never set a custom password, https://docs.oracle.com/en/java/javase/17/docs//specs/man/keytool.html[the default is `changeit`]. + +== Configure {product} to use Elasticsearch + +You can then configure your `application.properties` as follows + +[source, properties] +---- +spring.elasticsearch.uris=https://localhost:9200 +spring.elasticsearch.username=elastic +spring.elasticsearch.password= +---- + +Or add the following program arguments to your launch configuration: + +[source, bash] +---- +--spring.elasticsearch.uris=https://localhost:9200 +--spring.elasticsearch.username=elastic +--spring.elasticsearch.password= +---- + +Note that the Elasticsearch integration in Spring performs a scan of all the repositories on startup to detect Elasticsearch-compatible repositories. +This scan happens regardless of whether Elasticsearch is configured with the properties above. +{product} doesn't define such repositories, so the scan won't have an impact on the application, but it generates messages that can pollute the backend logs. +You can disable this scan with the following property: + +[source, properties] +---- +spring.data.elasticsearch.repositories.enabled=false +---- + +== How to see what is in an index + +Navigate to http://localhost:5601/ to open Kibana, open the left-side menu and select `Elasticsearch > Index Management`. + +The page displays the list of indexes currently managed by Elasticsearch. +Each {product}'s editing context (i.e. projectfootnote:[In some cases projects can have multiple editing contexts, but we won't cover it here.]) has its own index, named `editing-context-`. + +Click on an index to get high-level information (like the size of the index, the number of documents, etc). +Click on the _Discover index_ button to open the index and see the documents currently stored in it. + +image::elasticsearch-discover-view.png[Discover view in Elasticsearch] + +[NOTE] +==== +The discover view can only display 500 documents, use the fields on the left panel to filter the results, or check the next section to run queries on an index. +==== + +== How to run queries on an index + +On most of the pages you'll have access to the _Console_ at the bottom of the workbench. +Clicking on it opens a shell you can use to query your indices. + +The shell supports various types of queries, such as full-text search, a Json-based query DSL, or ES|QL. +The right panel displays the documents matching the last executed query. + +image::elasticsearch-shell.png[Elasticsearch Shell] + +For example, you can perform a full-text search on a given index with the following query: + +---- +// Get all the documents matching the term "Batmobile". +GET editing-context-/_search?q=Batmobile +---- + +You can use the query DSL to define more complex queries with a Json-based DSL, for example, you can get all the instances of of a given type that also have a name matching a given pattern: + +---- +// Get all the components with "Compo" at the beginning of their name +GET /editing-context-/_search +{ + "query": { + "bool": { + "must": [ + { + "wildcard": { + "name.keyword": "Batm*" + } + }, + { + "match": { + "@type": "Part" + } + } + ] + } + } +} +---- + +You can find more information on the https://www.elastic.co/docs/explore-analyze/query-filter/languages/querydsl[Query DSL documentation]. + + +Alternatively, you can define such complex queries with ES|QL, for example, you can get all the instances of a given type with the following query: + +---- +// Find all the instances of "Part", and show their name, id, and the index storing them. +POST /_query?format=txt +{ + "query": """ + FROM editing-context-* metadata _index + | where @type == "Part" + | keep name, @id, _index + """ +} +---- + +Note that query above uses a _wildcard_ in the name of the index it queries, meaning that all the indexes starting with "project-" are queried. + +ES|QL allows to define complex queries, including https://www.elastic.co/blog/esql-lookup-join-elasticsearch[joins]: + +---- +// Find the name of all the elements that are targeted by a ReferencingLink in a given editing context +POST /_query?format=txt +{ + "query": """ +FROM editing-context- + | where @type == "Subsetting" + // Rename because left and right columns in a join must have the same name. So we rename the column we want to lookup for. + | rename general.id.keyword as id.keyword + | lookup join editing-context- on id.keyword + | keep name + """ +} +---- + +Note that join queries do not support wildcards in their `FROM` clause. + +You can find more information on the https://www.elastic.co/docs/reference/query-languages/esql[ES|QL documentation]. + +[#data-structure] +== Data structure of indexed documents + +{product} indexes the following fields for each `Namespace` element contained in user models: + +- `@id` +- `@editingContextId` +- `@type` +- `@label` +- `@iconURLs` +- name +- shortName +- qualifiedName +- owner* +- ownedElement* +- ownedSpecialization* (only for `Type` elements, since `Namespace` elements can't have owned specializations) + +Note that some fields are prefixed with `@` to avoid clashes with specific SysML attributes we could index, but they can be used as any attribute in the queries. + +Fields with a * are _nested fields_: they contain sub-fields based on the actual element they represent. +Plain `Element` objects contain the following sub-fields: + +- `@id` +- `@type` +- `@label` +- name +- shortName +- qualifiedName + +`Specialization` objects contain the following sub-fields: + +- `@id` +- `@type` +- `@label` +- name +- shortName +- qualifiedName +- general* + +Since nested `Element` do not contain sub-fields for their own nested element, the maximum field depth of a given element is 2 for nested elements (e.g. `ownedElement.name`), and 3 for nested specializations (e.g. `ownedSpecialization.general.name`). + +The example below shows a document representing a {sysml} `Part` "part1" which is typed by a `PartDefinition`, and contains an `Attribute`. + +[source, json] +---- +{ + "@editingContextId": "13eee2c7-59b5-4ccd-a2b7-8ee5d93689eb", + "@id": "6b251d9b-d605-4ece-9870-fd43e0688a31", + "@type": "Part", + "@label": "part1", + "@iconURLs": [ + "/icons/full/obj16/PartUsage.svg" + ], + "name": "part1", + "qualifiedName": "Package1::part1", + "owner": { + "@id": "8c9499d1-c77f-435d-8d67-f26bed834c84", + "@type": "Package", + "@label": "Package1", + "name": "Package1", + "qualifiedName": "Package1" + }, + "ownedSpecialization": [ + { + "@id": "c1c2f2cc-5c4a-4a22-a6de-3188237ce4c2", + "@type": "FeatureTyping", + "@label": "FeatureTyping", + "general": { + "@id": "c010b813-dc52-4be0-b37f-9f3765f2aaa9", + "@type": "PartDefinition", + "@label": "PartDefinition1", + "name": "PartDefinition1", + "qualifiedName": "Package1::PartDefinition1" + } + }, + { + "@id": "1692b1a2-73a2-4d09-97ce-568f109a93f4", + "@type": "Subsetting", + "@label": "subsets (implicit)", + "name": "subsets (implicit)", + "qualifiedName": "'subsets (implicit)'", + "general": { + "@id": "0c6a9942-4bb9-58da-8344-57ecd220d4de", + "@type": "Part", + "@label": "parts", + "name": "parts", + "qualifiedName": "Parts::parts" + } + } + ], + "ownedElement": [ + { + "@id": "fd2f51a8-b868-41c1-95e4-818c206a2818", + "@type": "Attribute", + "@label": "attribute1", + "name": "attribute1", + "qualifiedName": "Package1::part1::attribute1" + } + ] +} +---- + + +[NOTE] +==== +{sysml} and {kerml} standard libraries are not indexed at the moment for performance reasons. +==== diff --git a/doc/content/modules/user-manual/assets/images/cross-project-search.png b/doc/content/modules/user-manual/assets/images/cross-project-search.png new file mode 100644 index 000000000..168e77341 Binary files /dev/null and b/doc/content/modules/user-manual/assets/images/cross-project-search.png differ diff --git a/doc/content/modules/user-manual/assets/images/homepage-toolbar.png b/doc/content/modules/user-manual/assets/images/homepage-toolbar.png index e74d0be8c..27dc3ec51 100644 Binary files a/doc/content/modules/user-manual/assets/images/homepage-toolbar.png and b/doc/content/modules/user-manual/assets/images/homepage-toolbar.png differ diff --git a/doc/content/modules/user-manual/assets/images/homepage.png b/doc/content/modules/user-manual/assets/images/homepage.png index 49dd28fb1..167d4acda 100644 Binary files a/doc/content/modules/user-manual/assets/images/homepage.png and b/doc/content/modules/user-manual/assets/images/homepage.png differ diff --git a/doc/content/modules/user-manual/pages/features/cross-project-search.adoc b/doc/content/modules/user-manual/pages/features/cross-project-search.adoc new file mode 100644 index 000000000..3c0b62d30 --- /dev/null +++ b/doc/content/modules/user-manual/pages/features/cross-project-search.adoc @@ -0,0 +1,37 @@ += Cross project search + +include::user-manual:partial$before-you-start-experimental-all.adoc[] + +[IMPORTANT] +==== +This feature requires {product} to be configured to xref:developer-guide:elasticsearch-integration.adoc[work with Elasticsearch]. +==== + +The _cross project search_ is a feature accessible in the Projects browser's command palette that allows to search for elements in any {product} project. +It is based on https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-query-string-query#query-string-syntax[Elasticsearch's query string], which allows to find elements by matching its indexed fields. + +image:cross-project-search.png[Cross project search dialog] + +{product} stores each element of a {sysml} model following a xref:developer-guide:elasticsearch-integration.adoc#data-structure[data structure]. +The fields defined in this data structure can be used to match elements in the cross project search, for example: + +---- +// Get all the elements with a name starting with "Batmo" +name:Batmo* + +// Get all the Part elements with a name starting with "part" followed by a digit +name:/part<0-9>/ AND @type:Part + +// Get all the elements owned by "Package1" +owner.name:Package1 + +// Get all the elements typed by "PartDefinition1" +ownedSpecialization.@type:FeatureTyping AND ownedSpecialization.general.name:PartDefinition1 +---- + +Note that the data structure only allows to query nested fields on 2 levels for `Element` instances (e.g. `owner.name`), and 3 levels for `Specialization` instances (e.g. `ownedSpecialization.general.name`). + +[NOTE] +==== +{sysml} and {kerml} standard libraries are not indexed at the moment for performance reasons. +==== \ No newline at end of file diff --git a/doc/content/modules/user-manual/pages/features/homepage.adoc b/doc/content/modules/user-manual/pages/features/homepage.adoc index 2ac85f7b2..bbe799def 100644 --- a/doc/content/modules/user-manual/pages/features/homepage.adoc +++ b/doc/content/modules/user-manual/pages/features/homepage.adoc @@ -20,12 +20,18 @@ image::homepage.png[{homepage}] === Toolbar -The toolbar consists of two groups: _Homepage_ and _Links_. +The toolbar consists of three groups: _Homepage_, _Command Palette_, and _Links_. image::homepage-toolbar.png[{homepage} Toolbar] include::user-manual:partial$homepage-action.adoc[leveloffset=+3] +[#command-palette] +==== Command Palette + +The _Command Palette_ in the {homepage} offers commands to perform actions on projects. +For example, it provides a command to create a _blank project_, or a xref:user-manual:features/cross-project-search.adoc[command to search elements across projects]. + include::user-manual:partial$projects-action.adoc[leveloffset=+3] include::user-manual:partial$libraries-action.adoc[leveloffset=+3] diff --git a/doc/content/modules/user-manual/pages/release-notes/2026.1.0.adoc b/doc/content/modules/user-manual/pages/release-notes/2026.1.0.adoc index db231cc83..18f88ea29 100644 --- a/doc/content/modules/user-manual/pages/release-notes/2026.1.0.adoc +++ b/doc/content/modules/user-manual/pages/release-notes/2026.1.0.adoc @@ -1,4 +1,4 @@ -= 2026.1.0 (work in progress) += 2026.1.0 == Key highlights diff --git a/doc/content/modules/user-manual/pages/release-notes/2026.3.0.adoc b/doc/content/modules/user-manual/pages/release-notes/2026.3.0.adoc new file mode 100644 index 000000000..b245e1da6 --- /dev/null +++ b/doc/content/modules/user-manual/pages/release-notes/2026.3.0.adoc @@ -0,0 +1,27 @@ += 2026.3.0 (work in progress) + +== Key highlights + +== New features + +- SysON can now be connected with Elasticsearch to support cross-project search. +The Elasticsearch integration uses its own indexing logic instead of the default one provided by Sirius Web. +This allows to keep indices compact, and ensures information stored in the indices are useful to perform cross-project search. +You can find more information on how to setup Elasticsearch, how elements are mapped to index documents, and how to query them in the documentation. + +This feature is currently considered experimental. +Try it out and give feedback by reporting bugs and suggesting new features. +It's not recommended for production use. + +== Bug fixes + +* Fix the textual export of `OccurrenceUsage` to avoid duplication of the _abstract_ keyword. +* Fix the textual export to properly escape names used in qualified names in some references. + +== Improvements + +* Implement the textual export for `StateUsage` and `StateDefinition`. + +== Technical details + +* For technical details on this {product} release (including breaking changes), please refer to https://github.com/eclipse-syson/syson/blob/main/CHANGELOG.adoc[changelog]. \ No newline at end of file diff --git a/doc/content/modules/user-manual/pages/release-notes/release-notes.adoc b/doc/content/modules/user-manual/pages/release-notes/release-notes.adoc index 04e9a4179..0f54b9cd5 100644 --- a/doc/content/modules/user-manual/pages/release-notes/release-notes.adoc +++ b/doc/content/modules/user-manual/pages/release-notes/release-notes.adoc @@ -2,6 +2,7 @@ :sectnums!: +include::user-manual:release-notes/2026.3.0.adoc[leveloffset=+1] include::user-manual:release-notes/2026.1.0.adoc[leveloffset=+1] include::user-manual:release-notes/2025.12.0.adoc[leveloffset=+1] include::user-manual:release-notes/2025.10.0.adoc[leveloffset=+1] diff --git a/doc/content/modules/user-manual/partials/nav-features.adoc b/doc/content/modules/user-manual/partials/nav-features.adoc index 937f62f81..95039da14 100644 --- a/doc/content/modules/user-manual/partials/nav-features.adoc +++ b/doc/content/modules/user-manual/partials/nav-features.adoc @@ -8,6 +8,7 @@ * Diagramming Tools ** Projects Management *** xref:user-manual:features/homepage.adoc[] +*** xref:user-manual:features/cross-project-search.adoc[] ** Editor *** xref:user-manual:features/editor.adoc[] *** xref:user-manual:features/details.adoc[] @@ -42,4 +43,5 @@ ** xref:user-manual:features/scaling-limits.adoc[] ** xref:developer-guide:extend.adoc[] ** xref:user-manual:features/security.adoc[] +** xref:developer-guide:elasticsearch-integration.adoc[] diff --git a/doc/docs-site/antora-playbook.yml b/doc/docs-site/antora-playbook.yml index 97d6cce46..6fd3369eb 100644 --- a/doc/docs-site/antora-playbook.yml +++ b/doc/docs-site/antora-playbook.yml @@ -1,6 +1,6 @@ site: title: SysON Docs - start_page: v2025.12.0@syson::index.adoc + start_page: v2026.1.0@syson::index.adoc output: clean: true diff --git a/doc/shapes/2026.6.1/customize_elasticsearch_index_to_work_with_sysml.adoc b/doc/shapes/2026.6.1/customize_elasticsearch_index_to_work_with_sysml.adoc index 0c7110628..16f2b49fa 100644 --- a/doc/shapes/2026.6.1/customize_elasticsearch_index_to_work_with_sysml.adoc +++ b/doc/shapes/2026.6.1/customize_elasticsearch_index_to_work_with_sysml.adoc @@ -31,6 +31,7 @@ All the `Element` instances are stored in the index. The `name`, `shortName`, and `qualifiedName` attributes can be used to find elements. The following references can be navigated as part of the query: + - `Element#owner` - `Element#ownedElement` - `Type#ownedSpecialization` @@ -40,6 +41,7 @@ Navigation has a maximum depth of 1: for example, it is possible to access the o This limitation is necessary to limit the number of fields stored in the index (by default the maximum number of fields is 1000), and to avoid false positive matches on unbounded queries (e.g. an indexed document can match a term if its n-th owner's name contains the term). In addition, Sirius Web provides the following attributes that can be used to find elements: + - `@id`: the ID of the element - `@editingContextId`: the ID of the editing context containing the element (this is a technical field, it is usually not useful to write queries) - `@type`: the type of the element (by default its EClass)