diff --git a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/DeleteRESTController.java b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/DeleteRESTController.java new file mode 100644 index 00000000..8a9f7f60 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/DeleteRESTController.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.api.controller.datasets.graphs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; + +import lombok.RequiredArgsConstructor; + +import org.rdfarchitect.api.dto.delete.ResourceDeleteRequest; +import org.rdfarchitect.api.dto.delete.relations.AffectedResource; +import org.rdfarchitect.database.GraphIdentifier; +import org.rdfarchitect.services.ExpandURIUseCase; +import org.rdfarchitect.services.delete.DeleteResourcesUseCase; +import org.rdfarchitect.services.delete.FindDeleteDependenciesUseCase; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("api/datasets/{datasetName}/graphs/{graphURI}") +@RequiredArgsConstructor +public class DeleteRESTController { + + private static final Logger logger = LoggerFactory.getLogger(DeleteRESTController.class); + + private final ExpandURIUseCase expandURIUseCase; + private final FindDeleteDependenciesUseCase findDeleteDependenciesUseCase; + private final DeleteResourcesUseCase deleteResourcesUseCase; + + @Operation( + summary = "Get deletion impact", + description = + "Returns a tree of affected resources for deleting the resource with the given UUID.", + tags = {"graph"}, + responses = {@ApiResponse(responseCode = "200")}) + @GetMapping("/uuid/{uuid}/deletion-impact") + public AffectedResource getDeletionImpact( + @Parameter(description = "The name/url of the inquirer.") + @RequestHeader( + value = HttpHeaders.ORIGIN, + required = false, + defaultValue = "unknown") + String originURL, + @Parameter(description = "The literal name of the dataset.") @PathVariable + String datasetName, + @Parameter( + description = + "The url encoded uri of the graph, or \"default\" to access the default graph.") + @PathVariable + String graphURI, + @Parameter(description = "The url encoded iri identifier of the cim resource.") + @PathVariable + String uuid) { + logger.info( + "Received GET request: \"/api/datasets/{{}}/graphs/{{}}/uuid/{{}/deletion-impact\" from \"{}\".", + datasetName, + graphURI, + uuid, + originURL); + + var extendedGraphURI = expandURIUseCase.expandUri(datasetName, graphURI); + + var resultObj = + findDeleteDependenciesUseCase.getDeleteDependencies( + new GraphIdentifier(datasetName, extendedGraphURI), UUID.fromString(uuid)); + + logger.info( + "Sending response to GET request: \"/api/datasets/{{}}/graphs/{{}}/uuid/{{}/deletion-impact\" from \"{}\".", + datasetName, + graphURI, + uuid, + originURL); + return resultObj; + } + + @Operation( + summary = "Delete resources", + description = + "Processes a list of delete requests, each specifying a resource UUID and the desired action.", + tags = {"graph"}, + responses = {@ApiResponse(responseCode = "200")}) + @PostMapping("/delete-requests") + public String deleteResources( + @Parameter(description = "The name/url of the inquirer.") + @RequestHeader( + value = HttpHeaders.ORIGIN, + required = false, + defaultValue = "unknown") + String originURL, + @Parameter(description = "The literal name of the dataset.") @PathVariable + String datasetName, + @Parameter( + description = + "The url encoded uri of the graph, or \"default\" to access the default graph.") + @PathVariable + String graphURI, + @Parameter( + description = + "A list of resource delete requests, each containing the uuid of the resource to delete and the type of deletion") + @RequestBody + List deleteRequests) { + logger.info( + "Received POST request: \"/api/datasets/{{}}/graphs/{{}}/delete\" from \"{}\".", + datasetName, + graphURI, + originURL); + + var extendedGraphURI = expandURIUseCase.expandUri(datasetName, graphURI); + + deleteResourcesUseCase.executeDeleteRequests( + new GraphIdentifier(datasetName, extendedGraphURI), deleteRequests); + + logger.info( + "Sending response to POST request: \"/api/datasets/{{}}/graphs/{{}}/delete\" from \"{}\".", + datasetName, + graphURI, + originURL); + return "success"; + } +} diff --git a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/classes/AllClassesRESTController.java b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/classes/AllClassesRESTController.java index d3fbb40e..bcede28f 100644 --- a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/classes/AllClassesRESTController.java +++ b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/classes/AllClassesRESTController.java @@ -41,6 +41,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -155,7 +156,10 @@ public List getClassList( description = "The url encoded uri of the graph, or \"default\" to access the default graph.") @PathVariable - String graphURI) { + String graphURI, + @Parameter(description = "Whether to include external classes.") + @RequestParam(required = false, defaultValue = "false") + boolean includeExternalClasses) { logger.info( "Received GET request: \"/api/datasets/{{}}/graphs/{{}}/classes\" from \"{}\".", datasetName, @@ -166,7 +170,7 @@ public List getClassList( var cimClassList = getClassListUseCase.getClassList( - new GraphIdentifier(datasetName, extendedGraphURI)); + new GraphIdentifier(datasetName, extendedGraphURI), includeExternalClasses); logger.info( "Sending response to GET request: \"/api/datasets/{{}}/graphs/{{}}/classes\" to \"{}\".", diff --git a/backend/src/main/java/org/rdfarchitect/api/dto/delete/DeleteAction.java b/backend/src/main/java/org/rdfarchitect/api/dto/delete/DeleteAction.java new file mode 100644 index 00000000..e19be9e1 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/api/dto/delete/DeleteAction.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.api.dto.delete; + +public enum DeleteAction { + + /** Delete the affected resource entirely. Applicable to all resource types. */ + DELETE, + + /** + * Keep the affected resource as-is, even though it references a deleted resource. E.g. a class + * that extends a deleted class — keep the class but accept that the parent reference becomes + * invalid. + */ + KEEP, + + /** + * Remove the {@code cims:belongsToCategory} triple from a class whose package is being deleted. + */ + REMOVE_PACKAGE_REFERENCE, + + /** + * Remove the {@code rdfs:subClassOf} triple from a class whose parent class is being deleted. + */ + REMOVE_SUBCLASS_REFERENCE; +} diff --git a/backend/src/main/java/org/rdfarchitect/api/dto/delete/ResourceDeleteRequest.java b/backend/src/main/java/org/rdfarchitect/api/dto/delete/ResourceDeleteRequest.java new file mode 100644 index 00000000..52e5b468 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/api/dto/delete/ResourceDeleteRequest.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.api.dto.delete; + +import lombok.Data; + +import java.util.UUID; + +@Data +public class ResourceDeleteRequest { + + private UUID uuid; + + private DeleteAction action; +} diff --git a/backend/src/main/java/org/rdfarchitect/api/dto/delete/ResourceIdentifier.java b/backend/src/main/java/org/rdfarchitect/api/dto/delete/ResourceIdentifier.java new file mode 100644 index 00000000..08aa66a3 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/api/dto/delete/ResourceIdentifier.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.api.dto.delete; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.UUID; + +@Data +@Accessors(chain = true) +public class ResourceIdentifier { + private UUID uuid; + private String label; + private String namespace; +} diff --git a/backend/src/main/java/org/rdfarchitect/api/dto/delete/relations/AffectedAssociation.java b/backend/src/main/java/org/rdfarchitect/api/dto/delete/relations/AffectedAssociation.java new file mode 100644 index 00000000..efbfb8f4 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/api/dto/delete/relations/AffectedAssociation.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.api.dto.delete.relations; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +import org.rdfarchitect.api.dto.delete.ResourceIdentifier; +import org.rdfarchitect.models.cim.relations.model.CIMResourceTypeIdentifyingUtils; + +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class AffectedAssociation extends AffectedOwnedResource { + + private ResourceIdentifier target; + + public AffectedAssociation( + ResourceIdentifier resourceIdentifier, + CIMResourceTypeIdentifyingUtils.CimResourceType type, + AffectedResourceReason reason, + ResourceIdentifier domain, + ResourceIdentifier target) { + super(resourceIdentifier, type, reason, domain); + this.target = target; + } +} diff --git a/backend/src/main/java/org/rdfarchitect/api/dto/delete/relations/AffectedOwnedResource.java b/backend/src/main/java/org/rdfarchitect/api/dto/delete/relations/AffectedOwnedResource.java new file mode 100644 index 00000000..bc637a19 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/api/dto/delete/relations/AffectedOwnedResource.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.api.dto.delete.relations; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +import org.rdfarchitect.api.dto.delete.ResourceIdentifier; +import org.rdfarchitect.models.cim.relations.model.CIMResourceTypeIdentifyingUtils; + +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class AffectedOwnedResource extends AffectedResource { + + private ResourceIdentifier domain; + + public AffectedOwnedResource( + ResourceIdentifier resourceIdentifier, + CIMResourceTypeIdentifyingUtils.CimResourceType type, + AffectedResourceReason reason, + ResourceIdentifier domain) { + super(resourceIdentifier, type, reason); + this.domain = domain; + } +} diff --git a/backend/src/main/java/org/rdfarchitect/api/dto/delete/relations/AffectedResource.java b/backend/src/main/java/org/rdfarchitect/api/dto/delete/relations/AffectedResource.java new file mode 100644 index 00000000..c34ff513 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/api/dto/delete/relations/AffectedResource.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.api.dto.delete.relations; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +import org.rdfarchitect.api.dto.delete.DeleteAction; +import org.rdfarchitect.api.dto.delete.ResourceIdentifier; +import org.rdfarchitect.models.cim.relations.model.CIMResourceTypeIdentifyingUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +@Accessors(chain = true) +public class AffectedResource { + + private ResourceIdentifier resourceIdentifier; + + private CIMResourceTypeIdentifyingUtils.CimResourceType type; + + private AffectedResourceReason reason; + + private List actions = new ArrayList<>(); + + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private List children = new ArrayList<>(); + + private Map context = new HashMap<>(); + + public AffectedResource( + ResourceIdentifier resourceIdentifier, + CIMResourceTypeIdentifyingUtils.CimResourceType type, + AffectedResourceReason reason) { + this.resourceIdentifier = resourceIdentifier; + this.type = type; + this.reason = reason; + } + + public List getChildren() { + return new ArrayList<>(children); + } + + public AffectedResource setChildren(List children) { + this.children = (children == null) ? new ArrayList<>() : new ArrayList<>(children); + return this; + } + + public enum AffectedResourceReason { + CONTAINED_IN_PACKAGE, + USES_DELETED_CLASS_AS_DATATYPE, + REFENCES_DELETED_CLASS_VIA_ASSOCIATION, + CHILD_OF, + USES_DELETED_CLASS_AS_DEFAULT_VALUE, + USES_DELETED_CLASS_AS_FIXED_VALUE, + DELETION_REQUESTED_BY_USER, + } +} diff --git a/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMClassUtils.java b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMClassUtils.java index 1719468b..b7509ece 100644 --- a/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMClassUtils.java +++ b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMClassUtils.java @@ -28,6 +28,7 @@ import java.util.HashSet; import java.util.LinkedList; +import java.util.List; import java.util.Set; @UtilityClass @@ -61,13 +62,26 @@ public Set findDerivingClasses(Resource classResource) { * @param classResource class resource to find deriving classes for * @return a set of directly deriving classes */ - private Set findDirectlyDerivingClasses(Resource classResource) { + public Set findDirectlyDerivingClasses(Resource classResource) { var ontology = classResource.getModel(); return ontology.listResourcesWithProperty(RDFS.subClassOf, classResource) .mapWith(RDFNode::asResource) .toSet(); } + /** + * Lists all directly deriving classes as a list. + * + * @param classResource class resource to find deriving classes for + * @return a list of directly deriving classes + */ + public List listDirectlyDerivingClasses(Resource classResource) { + return classResource + .getModel() + .listSubjectsWithProperty(RDFS.subClassOf, classResource) + .toList(); + } + /** * returns a list of all superClasses of a specified class. * diff --git a/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMPackageUtils.java b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMPackageUtils.java new file mode 100644 index 00000000..669e7e4f --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMPackageUtils.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.models.cim.relations.model; + +import lombok.experimental.UtilityClass; + +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.rdfarchitect.models.cim.rdf.resources.CIMS; + +import java.util.List; +import java.util.UUID; + +@UtilityClass +public class CIMPackageUtils { + + /** + * Lists all classes contained in a given package. + * + * @param model the RDF model + * @param packageUuid the UUID of the package + * @return a list of class resources belonging to the package + * @throws IllegalStateException if the resource with the given UUID is not a package + */ + public List listClassesInPackage(Model model, UUID packageUuid) { + var packageResource = CIMResourceTypeIdentifyingUtils.findUniqueSubject(model, packageUuid); + if (!packageResource.hasProperty(RDF.type, CIMS.classCategory)) { + throw new IllegalStateException( + "Resource with UUID " + packageUuid + " is not a package."); + } + return model.listSubjectsWithProperty(CIMS.belongsToCategory, packageResource) + .filterKeep(cls -> cls.hasProperty(RDF.type, RDFS.Class)) + .toList(); + } +} diff --git a/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMResourceTypeIdentifyingUtils.java b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMResourceTypeIdentifyingUtils.java new file mode 100644 index 00000000..c7c8fe1a --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMResourceTypeIdentifyingUtils.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.models.cim.relations.model; + +import lombok.experimental.UtilityClass; + +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.rdfarchitect.models.cim.rdf.resources.CIMS; +import org.rdfarchitect.models.cim.rdf.resources.CIMStereotypes; +import org.rdfarchitect.models.cim.rdf.resources.RDFA; +import org.rdfarchitect.models.cim.relations.model.properties.CIMPropertyUtils; + +import java.util.List; +import java.util.UUID; +import java.util.function.Predicate; + +@UtilityClass +public class CIMResourceTypeIdentifyingUtils { + + public enum CimResourceType { + PACKAGE, + CLASS, + ATTRIBUTE, + ASSOCIATION, + ENUM_ENTRY, + ONTOLOGY, + UNKNOWN, + } + + private record TypeRule(Predicate matches, CimResourceType type) {} + + private static final List TYPE_RULES = + List.of( + new TypeRule( + s -> s.hasProperty(RDF.type, CIMS.classCategory), + CimResourceType.PACKAGE), + new TypeRule(s -> s.hasProperty(RDF.type, RDFS.Class), CimResourceType.CLASS), + new TypeRule( + s -> s.hasProperty(RDF.type, OWL2.Ontology), CimResourceType.ONTOLOGY), + new TypeRule(CIMPropertyUtils::isAttribute, CimResourceType.ATTRIBUTE), + new TypeRule(CIMPropertyUtils::isAssociation, CimResourceType.ASSOCIATION), + new TypeRule( + CIMResourceTypeIdentifyingUtils::isEnumEntry, + CimResourceType.ENUM_ENTRY)); + + public CimResourceType getType(Model model, UUID uuid) { + var subject = findUniqueSubject(model, uuid); + + return TYPE_RULES.stream() + .filter(rule -> rule.matches().test(subject)) + .map(TypeRule::type) + .findFirst() + .orElse(CimResourceType.UNKNOWN); + } + + public Resource findUniqueSubject(Model model, UUID uuid) { + var subjects = model.listSubjectsWithProperty(RDFA.uuid, uuid.toString()).toList(); + if (subjects.size() != 1) { + throw new IllegalArgumentException( + "Expected exactly one subject with UUID " + + uuid + + ", but found " + + subjects.size()); + } + return subjects.getFirst(); + } + + public boolean isEnumEntry(Resource subject) { + var types = subject.listProperties(RDF.type).toList(); + if (types.size() != 1 || !types.getFirst().getObject().isURIResource()) { + return false; + } + return types.getFirst() + .getObject() + .asResource() + .hasProperty(CIMS.stereotype, CIMStereotypes.enumeration); + } +} diff --git a/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMResourceUtils.java b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMResourceUtils.java new file mode 100644 index 00000000..012d8412 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/CIMResourceUtils.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.models.cim.relations.model; + +import lombok.experimental.UtilityClass; + +import org.apache.jena.rdf.model.Resource; +import org.rdfarchitect.models.cim.rdf.resources.RDFA; + +import java.util.UUID; + +@UtilityClass +public class CIMResourceUtils { + + /** + * Checks whether a resource is external/referenced only. In our model this would mean it only + * has a {@link RDFA uuid} property, but no other properties. + * + * @param resource The resource to check for. + * @return True if the resource is an external resource, false otherwise. + */ + public boolean isExternalResource(Resource resource) { + return !resource.listProperties() + .filterDrop(stmt -> stmt.getPredicate().equals(RDFA.uuid)) + .hasNext(); + } + + /** + * Finds the uuid of a resource. + * + * @param resource The resource to finde the uuid for. + * @return The uuid of the resource. + */ + public UUID findUuidForResource(Resource resource) { + if (!resource.hasProperty(RDFA.uuid)) { + throw new IllegalStateException("Resource " + resource + " does not have a UUID."); + } + return UUID.fromString(resource.getProperty(RDFA.uuid).getString()); + } +} diff --git a/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/properties/CIMAssociationUtils.java b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/properties/CIMAssociationUtils.java index 80935eeb..c26b88a6 100644 --- a/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/properties/CIMAssociationUtils.java +++ b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/properties/CIMAssociationUtils.java @@ -24,6 +24,7 @@ import org.rdfarchitect.models.cim.rdf.resources.CIMS; import org.rdfarchitect.models.cim.relations.model.CIMClassUtils; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -55,4 +56,40 @@ public Set listAssociationDatatypes(Resource property) { instantiableDerivingClasses.add(targetClass); // include the class itself return instantiableDerivingClasses; } + + /** + * Lists all associations that reference a given class via {@code rdfs:domain}. + * + * @param classResource the class resource to find referencing associations for + * @return a list of association resources referencing the class + */ + public List listAssociationsReferencingClass(Resource classResource) { + return classResource + .getModel() + .listSubjectsWithProperty(RDFS.domain, classResource) + .filterKeep(CIMPropertyUtils::isAssociation) + .toList(); + } + + /** + * Returns the target (range) resource of an association. + * + * @param associationResource the association resource + * @return the range resource of the association + * @throws IllegalStateException if the association has no range or has a literal as range + */ + public Resource getAssociationTarget(Resource associationResource) { + var rangeStatement = associationResource.getProperty(RDFS.range); + if (rangeStatement == null) { + throw new IllegalStateException( + "Association " + associationResource + " does not have a range."); + } + if (rangeStatement.getObject().isLiteral()) { + throw new IllegalStateException( + "Association " + + associationResource + + " has a literal as range, which is not supported."); + } + return rangeStatement.getObject().asResource(); + } } diff --git a/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/properties/CIMAttributeUtils.java b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/properties/CIMAttributeUtils.java index 0eda6c21..1473d2e5 100644 --- a/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/properties/CIMAttributeUtils.java +++ b/backend/src/main/java/org/rdfarchitect/models/cim/relations/model/properties/CIMAttributeUtils.java @@ -30,6 +30,7 @@ import org.rdfarchitect.models.cim.rdf.resources.CIMStereotypes; import org.rdfarchitect.shacl.XSDDatatypeMapper; +import java.util.List; import java.util.Set; @UtilityClass @@ -175,4 +176,24 @@ public Set listEnumDatatypeEntries(Resource attribute) { var enumClass = attribute.getProperty(RDFS.range).getResource(); return ontology.listResourcesWithProperty(RDF.type, enumClass).toSet(); } + + /** + * Lists all attributes that use a given class as their datatype (via {@code cims:datatype} or + * {@code rdfs:range}). + * + * @param classResource the class resource used as datatype + * @return a list of attribute resources that reference the class as their datatype + */ + public List listAttributesWithClassAsDatatype(Resource classResource) { + var model = classResource.getModel(); + var byDatatype = model.listSubjectsWithProperty(CIMS.datatype, classResource); + var byRange = model.listSubjectsWithProperty(RDFS.range, classResource); + return byDatatype + .andThen(byRange) + .filterKeep(CIMPropertyUtils::isAttribute) + .toList() + .stream() + .distinct() + .toList(); + } } diff --git a/backend/src/main/java/org/rdfarchitect/services/delete/DeleteResourcesService.java b/backend/src/main/java/org/rdfarchitect/services/delete/DeleteResourcesService.java new file mode 100644 index 00000000..1333189c --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/services/delete/DeleteResourcesService.java @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.services.delete; + +import lombok.RequiredArgsConstructor; + +import org.apache.jena.query.TxnType; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.rdfarchitect.api.dto.delete.DeleteAction; +import org.rdfarchitect.api.dto.delete.ResourceDeleteRequest; +import org.rdfarchitect.database.DatabasePort; +import org.rdfarchitect.database.GraphIdentifier; +import org.rdfarchitect.models.cim.rdf.resources.CIMS; +import org.rdfarchitect.models.cim.rdf.resources.RDFA; +import org.rdfarchitect.models.cim.relations.model.CIMResourceTypeIdentifyingUtils; +import org.rdfarchitect.models.cim.relations.model.CIMResourceTypeIdentifyingUtils.CimResourceType; +import org.rdfarchitect.models.cim.relations.model.CIMResourceUtils; +import org.rdfarchitect.models.cim.relations.model.properties.CIMPropertyUtils; +import org.rdfarchitect.rdf.graph.wrapper.GraphRewindable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class DeleteResourcesService implements DeleteResourcesUseCase { + + private static final Logger logger = LoggerFactory.getLogger(DeleteResourcesService.class); + + private final DatabasePort databasePort; + + /** + * Internal record that holds the pre-resolved resource, its CIM type, and the requested action. + * This avoids redundant model lookups during deletion and allows for upfront validation of + * unsupported actions. + */ + private record ResolvedDeleteRequest( + Resource resource, CimResourceType type, DeleteAction action) {} + + @Override + public void executeDeleteRequests( + GraphIdentifier graphIdentifier, List deleteRequests) { + GraphRewindable graph = null; + try { + graph = databasePort.getGraphWithContext(graphIdentifier).getRdfGraph(); + graph.begin(TxnType.WRITE); + deleteResources(ModelFactory.createModelForGraph(graph), deleteRequests); + graph.commit(); + } finally { + if (graph != null) { + graph.end(); + } + } + } + + private void deleteResources(Model model, List deleteRequests) { + var resolvedRequests = resolveAll(model, deleteRequests); + for (var resolved : resolvedRequests) { + try { + deleteResource(resolved); + } catch (UnsupportedOperationException | IllegalArgumentException e) { + logger.warn( + "Skipping deletion of resource {} due to unsupported action: {} : {}", + resolved.resource(), + resolved.action(), + e.getMessage()); + } catch (IllegalStateException e) { + logger.warn( + "Skipping deletion of resource {} due to illegal state: {}", + resolved.resource(), + e.getMessage()); + } + } + } + + /** + * Resolves all delete requests up front: looks up the resource and its CIM type once per UUID. + * Requests that cannot be resolved (unknown type, missing resource) are logged and skipped. + */ + private List resolveAll( + Model model, List deleteRequests) { + return deleteRequests.stream() + .map(req -> resolve(model, req)) + .filter(Objects::nonNull) + .toList(); + } + + private ResolvedDeleteRequest resolve(Model model, ResourceDeleteRequest req) { + try { + var resource = CIMResourceTypeIdentifyingUtils.findUniqueSubject(model, req.getUuid()); + var type = CIMResourceTypeIdentifyingUtils.getType(model, req.getUuid()); + if (type == CimResourceType.UNKNOWN) { + logger.warn( + "Skipping deletion of resource with UUID {} : unknown resource type", + req.getUuid()); + return null; + } + return new ResolvedDeleteRequest(resource, type, req.getAction()); + } catch (Exception e) { + logger.warn( + "Skipping deletion of resource with UUID {} : could not resolve: {}", + req.getUuid(), + e.getMessage()); + return null; + } + } + + private void deleteResource(ResolvedDeleteRequest resolved) { + switch (resolved.type()) { + case PACKAGE -> deletePackage(resolved); + case CLASS -> deleteClass(resolved); + case ATTRIBUTE -> deleteAttribute(resolved); + case ASSOCIATION -> deleteAssociation(resolved); + case ENUM_ENTRY -> deleteEnumEntry(resolved); + case ONTOLOGY -> deleteOntology(resolved); + case UNKNOWN -> + throw new IllegalArgumentException( + "Unknown resource type for resource: " + resolved.resource()); + } + } + + /** + * Validates the given action against the supported actions for a resource type. + * + * @return {@code true} if the action is {@link DeleteAction#KEEP} and should be skipped, {@code + * false} if the action is supported and deletion should proceed. + * @throws IllegalArgumentException if the action is {@code null} + * @throws UnsupportedOperationException if the action is not in the supported set + */ + private boolean shouldSkipOrThrow( + DeleteAction action, CimResourceType type, DeleteAction... supported) { + if (action == null) { + throw new IllegalArgumentException("Action must not be null"); + } + if (action == DeleteAction.KEEP) { + return true; + } + if (!Set.of(supported).contains(action)) { + var typeString = type.name().toLowerCase(Locale.ROOT).replace("_", " "); + throw new UnsupportedOperationException( + "Action " + action + " is not supported for " + typeString + "."); + } + return false; + } + + /** + * Removes a resource and all its transitively reachable blank nodes from the model. Nodes that + * are still referenced by other resources are not fully deleted; instead, only their {@code + * rdfa:uuid} triple is preserved to maintain referential integrity. + * + * @param resource the root resource to delete + */ + private void removeResource(Resource resource) { + var queue = new ArrayDeque(); + queue.add(resource); + var model = resource.getModel(); + + while (!queue.isEmpty()) { + var current = queue.poll(); + current.listProperties().toList().stream() + .filter(stmt -> stmt.getObject().isAnon()) + .forEach(stmt -> queue.add(stmt.getObject().asResource())); + + // Delete inverse association + var inverseStmt = current.getProperty(CIMS.inverseRoleName); + if (inverseStmt != null && inverseStmt.getObject().isResource()) { + var inverse = inverseStmt.getObject().asResource(); + model.remove(inverse, CIMS.inverseRoleName, current); + queue.add(inverse); + } + + if (isReferencedElsewhere(model, current)) { + var uuidStmt = current.getProperty(RDFA.uuid); + current.listProperties().forEach(model::remove); + if (uuidStmt != null) { + model.add(uuidStmt); + } + } else { + current.listProperties().forEach(model::remove); + } + } + } + + private boolean isReferencedElsewhere(Model model, Resource resource) { + return model.listStatements(null, null, resource) + .filterDrop(stmt -> stmt.getPredicate().equals(RDFA.uuid)) + .hasNext(); + } + + private void deletePackage(ResolvedDeleteRequest resolved) { + if (shouldSkipOrThrow(resolved.action(), CimResourceType.PACKAGE, DeleteAction.DELETE)) { + return; + } + removeResource(resolved.resource()); + } + + /** + * Deletes a class and all its owned resources (attributes, associations, and enum entries). If + * the action is {@link DeleteAction#REMOVE_SUBCLASS_REFERENCE}, only the {@code + * rdfs:subClassOf} triple is removed, leaving the class itself intact. + */ + private void deleteClass(ResolvedDeleteRequest resolved) { + if (shouldSkipOrThrow( + resolved.action(), + CimResourceType.CLASS, + DeleteAction.DELETE, + DeleteAction.REMOVE_SUBCLASS_REFERENCE, + DeleteAction.REMOVE_PACKAGE_REFERENCE)) { + return; + } + var resource = resolved.resource(); + var model = resource.getModel(); + + if (resolved.action() == DeleteAction.REMOVE_SUBCLASS_REFERENCE) { + resource.listProperties(RDFS.subClassOf).forEach(model::remove); + return; + } + + if (resolved.action() == DeleteAction.REMOVE_PACKAGE_REFERENCE) { + resource.listProperties(CIMS.belongsToCategory).forEach(model::remove); + return; + } + + // Delete attributes + model.listSubjectsWithProperty(RDFS.domain, resource) + .filterKeep(CIMPropertyUtils::isAttribute) + .toList() + .forEach(this::removeResource); + + // Delete associations only if they reference an external resource + model.listSubjectsWithProperty(RDFS.domain, resource) + .filterKeep(CIMPropertyUtils::isAssociation) + .filterKeep( + assoc -> + CIMResourceUtils.isExternalResource( + assoc.getProperty(RDFS.range).getObject().asResource())) + .toList() + .forEach(this::removeResource); + + // Delete enum entries + model.listSubjectsWithProperty(RDF.type, resource) + .filterKeep(CIMResourceTypeIdentifyingUtils::isEnumEntry) + .toList() + .forEach(this::removeResource); + + removeResource(resource); + } + + private void deleteAttribute(ResolvedDeleteRequest resolved) { + if (shouldSkipOrThrow(resolved.action(), CimResourceType.ATTRIBUTE, DeleteAction.DELETE)) { + return; + } + removeResource(resolved.resource()); + } + + /** + * Deletes an association and its inverse. Since associations reference each other via {@code + * cims:inverseRoleName}, the mutual references are removed first to prevent {@link + * #removeResource} from preserving stale UUID triples due to the circular reference. + */ + private void deleteAssociation(ResolvedDeleteRequest resolved) { + if (shouldSkipOrThrow( + resolved.action(), CimResourceType.ASSOCIATION, DeleteAction.DELETE)) { + return; + } + var resource = resolved.resource(); + var model = resource.getModel(); + + var inverseStmt = resource.getProperty(CIMS.inverseRoleName); + if (inverseStmt != null && inverseStmt.getObject().isResource()) { + var inverse = inverseStmt.getObject().asResource(); + // Break circular reference before deleting + model.remove(inverse, CIMS.inverseRoleName, resource); + model.remove(resource, CIMS.inverseRoleName, inverse); + removeResource(inverse); + } + + removeResource(resource); + } + + private void deleteEnumEntry(ResolvedDeleteRequest resolved) { + if (shouldSkipOrThrow(resolved.action(), CimResourceType.ENUM_ENTRY, DeleteAction.DELETE)) { + return; + } + removeResource(resolved.resource()); + } + + private void deleteOntology(ResolvedDeleteRequest resolved) { + if (shouldSkipOrThrow(resolved.action(), CimResourceType.ONTOLOGY, DeleteAction.DELETE)) { + return; + } + removeResource(resolved.resource()); + } +} diff --git a/backend/src/main/java/org/rdfarchitect/services/delete/DeleteResourcesUseCase.java b/backend/src/main/java/org/rdfarchitect/services/delete/DeleteResourcesUseCase.java new file mode 100644 index 00000000..7bfaa6cd --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/services/delete/DeleteResourcesUseCase.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.services.delete; + +import org.rdfarchitect.api.dto.delete.ResourceDeleteRequest; +import org.rdfarchitect.database.GraphIdentifier; + +import java.util.List; + +public interface DeleteResourcesUseCase { + + /** + * Executes a list of delete requests on a specified graph. + * + * @param graphIdentifier The identifier of the graph where the change occurred. + * @param deleteRequests The List of deleteRequests. + */ + void executeDeleteRequests( + GraphIdentifier graphIdentifier, List deleteRequests); +} diff --git a/backend/src/main/java/org/rdfarchitect/services/delete/FindDeleteDependenciesService.java b/backend/src/main/java/org/rdfarchitect/services/delete/FindDeleteDependenciesService.java new file mode 100644 index 00000000..8243b55b --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/services/delete/FindDeleteDependenciesService.java @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.services.delete; + +import lombok.RequiredArgsConstructor; + +import org.apache.jena.graph.Graph; +import org.apache.jena.query.TxnType; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.RDFS; +import org.rdfarchitect.api.dto.delete.DeleteAction; +import org.rdfarchitect.api.dto.delete.ResourceIdentifier; +import org.rdfarchitect.api.dto.delete.relations.AffectedAssociation; +import org.rdfarchitect.api.dto.delete.relations.AffectedOwnedResource; +import org.rdfarchitect.api.dto.delete.relations.AffectedResource; +import org.rdfarchitect.api.dto.delete.relations.AffectedResource.AffectedResourceReason; +import org.rdfarchitect.database.DatabasePort; +import org.rdfarchitect.database.GraphIdentifier; +import org.rdfarchitect.models.cim.relations.model.CIMClassUtils; +import org.rdfarchitect.models.cim.relations.model.CIMPackageUtils; +import org.rdfarchitect.models.cim.relations.model.CIMResourceTypeIdentifyingUtils; +import org.rdfarchitect.models.cim.relations.model.CIMResourceTypeIdentifyingUtils.CimResourceType; +import org.rdfarchitect.models.cim.relations.model.CIMResourceUtils; +import org.rdfarchitect.models.cim.relations.model.properties.CIMAssociationUtils; +import org.rdfarchitect.models.cim.relations.model.properties.CIMAttributeUtils; +import org.rdfarchitect.rdf.graph.GraphUtils; +import org.rdfarchitect.rdf.graph.wrapper.GraphRewindable; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class FindDeleteDependenciesService implements FindDeleteDependenciesUseCase { + + private final DatabasePort databasePort; + + @Override + public AffectedResource getDeleteDependencies(GraphIdentifier graphIdentifier, UUID uuid) { + var model = ModelFactory.createModelForGraph(getCopyOfDatabaseGraph(graphIdentifier)); + var resourceType = CIMResourceTypeIdentifyingUtils.getType(model, uuid); + var defaultActions = List.of(DeleteAction.DELETE); + var reason = AffectedResourceReason.DELETION_REQUESTED_BY_USER; + return switch (resourceType) { + case PACKAGE -> findAffectedRelationsForPackage(model, uuid, reason, defaultActions); + case CLASS -> findAffectedRelationsForClass(model, uuid, reason, defaultActions); + case ATTRIBUTE -> + new AffectedResource( + createResourceIdentifier(model, uuid), + CimResourceType.ATTRIBUTE, + reason) + .setActions(defaultActions); + case ASSOCIATION -> + new AffectedResource( + createResourceIdentifier(model, uuid), + CimResourceType.ASSOCIATION, + reason) + .setActions(defaultActions); + case ENUM_ENTRY -> + new AffectedResource( + createResourceIdentifier(model, uuid), + CimResourceType.ENUM_ENTRY, + reason) + .setActions(defaultActions); + case ONTOLOGY -> + new AffectedResource( + createResourceIdentifier(model, uuid), + CimResourceType.ONTOLOGY, + reason) + .setActions(defaultActions); + case UNKNOWN -> + new AffectedResource( + createResourceIdentifier(model, uuid), + CimResourceType.UNKNOWN, + reason) + .setActions(defaultActions); + }; + } + + private AffectedResource findAffectedRelationsForPackage( + Model model, + UUID uuid, + AffectedResourceReason reason, + List deleteActions) { + var classesInPackage = CIMPackageUtils.listClassesInPackage(model, uuid); + var affectedResources = new ArrayList(); + var clsDeleteActions = + List.of( + DeleteAction.DELETE, + DeleteAction.KEEP, + DeleteAction.REMOVE_PACKAGE_REFERENCE); + for (var cls : classesInPackage) { + var clsUuid = CIMResourceUtils.findUuidForResource(cls); + var affectedClassResource = + findAffectedRelationsForClass( + model, + clsUuid, + AffectedResourceReason.CONTAINED_IN_PACKAGE, + clsDeleteActions); + affectedResources.add(affectedClassResource); + } + return new AffectedResource( + createResourceIdentifier(model, uuid), CimResourceType.PACKAGE, reason) + .setActions(deleteActions) + .setChildren(affectedResources); + } + + private AffectedResource findAffectedRelationsForClass( + Model model, + UUID uuid, + AffectedResourceReason reason, + List deleteActions) { + var classResource = CIMResourceTypeIdentifyingUtils.findUniqueSubject(model, uuid); + + var classResourceId = createResourceIdentifier(model, uuid); + var affectedResources = new ArrayList(); + affectedResources.addAll(findAffectedAttributesForClass(classResource)); + affectedResources.addAll(findAffectedAssociationsForClass(classResource, classResourceId)); + affectedResources.addAll(findAffectedChildClassesForClass(classResource)); + + return new AffectedResource(classResourceId, CimResourceType.CLASS, reason) + .setActions(deleteActions) + .setChildren(affectedResources); + } + + private List findAffectedAttributesForClass(Resource classResource) { + var childActions = List.of(DeleteAction.DELETE, DeleteAction.KEEP); + return CIMAttributeUtils.listAttributesWithClassAsDatatype(classResource).stream() + .map( + attr -> + new AffectedOwnedResource( + createResourceIdentifier(attr), + CimResourceType.ATTRIBUTE, + AffectedResourceReason + .USES_DELETED_CLASS_AS_DATATYPE, + createResourceIdentifier( + attr.getProperty(RDFS.domain) + .getObject() + .asResource())) + .setActions(childActions)) + .toList(); + } + + private List findAffectedAssociationsForClass( + Resource classResource, ResourceIdentifier classResourceId) { + return CIMAssociationUtils.listAssociationsReferencingClass(classResource).stream() + .map( + assoc -> { + var childActions = new ArrayList(); + childActions.add(DeleteAction.DELETE); + if (!CIMResourceUtils.isExternalResource( + assoc.getProperty(RDFS.range).getObject().asResource())) { + childActions.add(DeleteAction.KEEP); + } + var targetResource = CIMAssociationUtils.getAssociationTarget(assoc); + return new AffectedAssociation( + createResourceIdentifier(assoc), + CimResourceType.ASSOCIATION, + AffectedResourceReason + .REFENCES_DELETED_CLASS_VIA_ASSOCIATION, + classResourceId, + createResourceIdentifier(targetResource)) + .setActions(childActions); + }) + .toList(); + } + + private List findAffectedChildClassesForClass(Resource classResource) { + var childClassActions = + List.of( + DeleteAction.DELETE, + DeleteAction.KEEP, + DeleteAction.REMOVE_SUBCLASS_REFERENCE); + return buildAffectedChildClassTree(classResource, childClassActions); + } + + private List buildAffectedChildClassTree( + Resource classResource, List childClassActions) { + var visited = new HashSet(); + visited.add(classResource); + + var resourceMap = new HashMap(); + var rootChildren = new ArrayList(); + + var queue = initializeQueue(classResource, visited); + + while (!queue.isEmpty()) { + var entry = queue.poll(); + var current = entry.getKey(); + var parent = entry.getValue(); + + var affectedResource = createAffectedChildClass(current, childClassActions); + resourceMap.put(current, affectedResource); + + attachToParentOrRoot( + affectedResource, parent, classResource, resourceMap, rootChildren); + enqueueChildren(current, visited, queue); + } + + return rootChildren; + } + + private LinkedList> initializeQueue( + Resource classResource, Set visited) { + var queue = new LinkedList>(); + for (var directChild : CIMClassUtils.listDirectlyDerivingClasses(classResource)) { + if (visited.add(directChild)) { + queue.add(Map.entry(directChild, classResource)); + } + } + return queue; + } + + private AffectedResource createAffectedChildClass( + Resource classResource, List actions) { + var resourceId = createResourceIdentifier(classResource); + var children = new ArrayList(); + children.addAll(findAffectedAttributesForClass(classResource)); + children.addAll(findAffectedAssociationsForClass(classResource, resourceId)); + + return new AffectedResource( + resourceId, CimResourceType.CLASS, AffectedResourceReason.CHILD_OF) + .setActions(actions) + .setChildren(children); + } + + private void attachToParentOrRoot( + AffectedResource affectedResource, + Resource parent, + Resource rootClass, + Map resourceMap, + List rootChildren) { + if (parent.equals(rootClass)) { + rootChildren.add(affectedResource); + return; + } + var parentAffected = resourceMap.get(parent); + if (parentAffected != null) { + var existingChildren = parentAffected.getChildren(); + existingChildren.add(affectedResource); + parentAffected.setChildren(existingChildren); + } + } + + private void enqueueChildren( + Resource current, + Set visited, + LinkedList> queue) { + for (var child : CIMClassUtils.listDirectlyDerivingClasses(current)) { + if (visited.add(child)) { + queue.add(Map.entry(child, current)); + } + } + } + + private ResourceIdentifier createResourceIdentifier(Model model, UUID uuid) { + var resource = CIMResourceTypeIdentifyingUtils.findUniqueSubject(model, uuid); + return createResourceIdentifier(resource); + } + + private ResourceIdentifier createResourceIdentifier(Resource resource) { + var uuid = CIMResourceUtils.findUuidForResource(resource); + var label = resource.getLocalName(); + if (resource.hasProperty(RDFS.label)) { + label = resource.getProperty(RDFS.label).getString(); + } + return new ResourceIdentifier() + .setUuid(uuid) + .setLabel(label) + .setNamespace(resource.getNameSpace()); + } + + private Graph getCopyOfDatabaseGraph(GraphIdentifier graphIdentifier) { + GraphRewindable graph = null; + try { + graph = databasePort.getGraphWithContext(graphIdentifier).getRdfGraph(); + graph.begin(TxnType.READ); + return GraphUtils.deepCopy(graph); + } finally { + if (graph != null) { + graph.end(); + } + } + } +} diff --git a/backend/src/main/java/org/rdfarchitect/services/delete/FindDeleteDependenciesUseCase.java b/backend/src/main/java/org/rdfarchitect/services/delete/FindDeleteDependenciesUseCase.java new file mode 100644 index 00000000..5b393b33 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/services/delete/FindDeleteDependenciesUseCase.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.services.delete; + +import org.rdfarchitect.api.dto.delete.relations.AffectedResource; +import org.rdfarchitect.database.GraphIdentifier; + +import java.util.UUID; + +public interface FindDeleteDependenciesUseCase { + + /** + * Finds the resources that would be affected by deleting a specified resource. + * + * @param graphIdentifier The identifier of the graph. + * @param uuid The resource to find dependencies for. + * @return An {@link AffectedResource} containing the affected resources with and their + * relations. + */ + AffectedResource getDeleteDependencies(GraphIdentifier graphIdentifier, UUID uuid); +} diff --git a/backend/src/main/java/org/rdfarchitect/services/select/GetClassListUseCase.java b/backend/src/main/java/org/rdfarchitect/services/select/GetClassListUseCase.java index b7f1e69a..ebb6d421 100644 --- a/backend/src/main/java/org/rdfarchitect/services/select/GetClassListUseCase.java +++ b/backend/src/main/java/org/rdfarchitect/services/select/GetClassListUseCase.java @@ -24,5 +24,13 @@ public interface GetClassListUseCase { - List getClassList(GraphIdentifier graphIdentifier); + /** + * Gets the list of classes in the graph. + * + * @param graphIdentifier The graph to getClassDefinition. + * @param includeExternalClasses Whether to include external classes in the result. + * @return The list of classes in the graph. + */ + List getClassList( + GraphIdentifier graphIdentifier, boolean includeExternalClasses); } diff --git a/backend/src/main/java/org/rdfarchitect/services/select/QueryGraphService.java b/backend/src/main/java/org/rdfarchitect/services/select/QueryGraphService.java index 051737be..93791a96 100644 --- a/backend/src/main/java/org/rdfarchitect/services/select/QueryGraphService.java +++ b/backend/src/main/java/org/rdfarchitect/services/select/QueryGraphService.java @@ -25,6 +25,7 @@ import org.apache.jena.arq.querybuilder.SelectBuilder; import org.apache.jena.graph.Node; +import org.apache.jena.query.QueryFactory; import org.apache.jena.query.TxnType; import org.apache.jena.rdf.model.ModelFactory; import org.apache.jena.riot.RDFFormat; @@ -85,32 +86,65 @@ public class QueryGraphService private final PackageMapper packageMapper; @Override - public List getClassList(GraphIdentifier graphIdentifier) { - // build query - var baseQuery = - new CIMBaseQueryBuilder() - .setGraph(graphIdentifier.getGraphUri()) - .addPrefixes( - databasePort.getPrefixMapping(graphIdentifier.getDatasetName())) - .setOrder() - .setDistinct() - .setType(RDFS.Class) - .build(); + public List getClassList( + GraphIdentifier graphIdentifier, boolean includeExternalClasses) { + var classFilter = + includeExternalClasses + ? """ + { + ?uri rdf:type rdfs:Class . + } + UNION + { + ?_any rdfs:domain ?uri . + } + """ + : "?uri rdf:type rdfs:Class ."; + var query = - new CIMQueryBuilder(baseQuery) - .appendUUIDQuery(OPTIONAL) - .appendLabelQuery(OPTIONAL) - .appendPackageQuery(OPTIONAL) - .appendCommentQuery(OPTIONAL) - .appendSuperClassQuery(OPTIONAL) - .build(); + """ + PREFIX cims: + PREFIX rdf: + PREFIX owl: + PREFIX cim: + PREFIX xsd: + PREFIX rdfs: + PREFIX dc: + + SELECT DISTINCT ?uri ?uuid ?label ?packageURI ?packageLabel ?packageUUID ?comment ?superClassURI ?superClassLabel + WHERE + { + """ + + classFilter + + """ + ?uri ?uuid + OPTIONAL + { ?uri rdfs:label ?label} + OPTIONAL + { ?uri cims:belongsToCategory ?packageURI + OPTIONAL + { ?packageURI rdfs:label ?packageLabel} + OPTIONAL + { ?packageURI ?packageUUID} + } + OPTIONAL + { ?uri rdfs:comment ?comment} + OPTIONAL + { ?uri rdfs:subClassOf ?superClassURI + OPTIONAL + { ?superClassURI + rdfs:label ?superClassLabel} + } + } + ORDER BY ?uri + """; // execute query var queryResultSet = InMemorySparqlExecutor.executeSingleQuery( databasePort.getGraphWithContext(graphIdentifier).getRdfGraph(), - query, - graphIdentifier.getGraphUri()); + QueryFactory.create(query), + null); // format results var cimClassList = CIMUMLObjectFactory.createCIMClassUMLAdaptedList(queryResultSet); diff --git a/backend/src/test/java/org/rdfarchitect/services/delete/DeleteResourcesServiceTest.java b/backend/src/test/java/org/rdfarchitect/services/delete/DeleteResourcesServiceTest.java new file mode 100644 index 00000000..c2e7af86 --- /dev/null +++ b/backend/src/test/java/org/rdfarchitect/services/delete/DeleteResourcesServiceTest.java @@ -0,0 +1,413 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.services.delete; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import org.apache.jena.query.TxnType; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFDataMgr; +import org.apache.jena.sparql.graph.GraphFactory; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.rdfarchitect.api.dto.delete.DeleteAction; +import org.rdfarchitect.api.dto.delete.ResourceDeleteRequest; +import org.rdfarchitect.database.DatabasePort; +import org.rdfarchitect.database.GraphIdentifier; +import org.rdfarchitect.database.inmemory.GraphWithContext; +import org.rdfarchitect.models.cim.rdf.resources.CIMS; +import org.rdfarchitect.models.cim.rdf.resources.RDFA; +import org.rdfarchitect.rdf.graph.wrapper.GraphRewindableWithUUIDs; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; + +@ExtendWith(MockitoExtension.class) +class DeleteResourcesServiceTest { + + private static final String TEST_DATA_PATH = + "src/test/java/org/rdfarchitect/services/delete/testdata.ttl"; + + private static final GraphIdentifier GRAPH_IDENTIFIER = + new GraphIdentifier("default", "default"); + + private static final String CIM_NS = "http://iec.ch/TC57/CIM100#"; + + // UUIDs from the TTL test data + private static final UUID PARENT_CLASS_UUID = + UUID.fromString("05131eaf-a7dd-4ac4-8624-9665990985ab"); + private static final UUID CHILD_CLASS_UUID = + UUID.fromString("93ee2f31-5ddd-4b25-b119-e90a5ed327b0"); + private static final UUID ASSOCIATED_CLASS_UUID = + UUID.fromString("f6d92056-c469-40d4-add1-1d6adf2fa7a6"); + private static final UUID PACKAGE_UUID = + UUID.fromString("0351f9b6-5e91-4059-9d8b-169d28f1b2c8"); + private static final UUID DATATYPE_CLASS_UUID = + UUID.fromString("db520255-95ef-40d4-b328-e1631e4683a4"); + private static final UUID ONTOLOGY_UUID = + UUID.fromString("dba8f8e3-bfb3-4e62-9ca5-b0136ed186b2"); + private static final UUID ATTR1_UUID = UUID.fromString("dc934c7b-6c4d-4177-9832-bcb955f25414"); + private static final UUID ASSOC_NONEXISTING_UUID = + UUID.fromString("b2a306f1-4789-4a12-b045-04a014e7a937"); + private static final UUID ASSOC_TEMP_ASSOCIATED_UUID = + UUID.fromString("c3b417f2-5890-5b23-c156-15b015e8b048"); + private static final UUID ASSOC_ASSOCIATED_CHILD_UUID = + UUID.fromString("abfab117-e4bc-4814-9b5a-3aed72de8a2d"); + private static final UUID ASSOC_CHILD_ASSOCIATED_UUID = + UUID.fromString("bc0bc228-f5cd-5925-ab6b-4bfe83ef9b3e"); + + @Mock private DatabasePort databasePort; + + @InjectMocks private DeleteResourcesService service; + + private GraphRewindableWithUUIDs wrappedGraph; + + @BeforeEach + void setUp() throws IOException { + var graph = GraphFactory.createDefaultGraph(); + InputStream in = Files.newInputStream(Path.of(TEST_DATA_PATH)); + RDFDataMgr.read(graph, in, Lang.TTL); + in.close(); + + wrappedGraph = new GraphRewindableWithUUIDs(graph, 5, 5); + var wrappedContext = new GraphWithContext(wrappedGraph); + + when(databasePort.getGraphWithContext(any(GraphIdentifier.class))) + .thenReturn(wrappedContext); + } + + private Model readModel() { + wrappedGraph.begin(TxnType.READ); + return ModelFactory.createModelForGraph(wrappedGraph); + } + + private void endRead() { + wrappedGraph.end(); + } + + private ResourceDeleteRequest request(UUID uuid, DeleteAction action) { + var req = new ResourceDeleteRequest(); + req.setUuid(uuid); + req.setAction(action); + return req; + } + + /** + * Asserts that the resource has at most its UUID triple left (all other properties removed). + */ + private void assertResourceRemovedOrOnlyUuid(String localName) { + var model = readModel(); + try { + var resource = ResourceFactory.createResource(CIM_NS + localName); + var nonUuidStatements = + model.listStatements(resource, null, (RDFNode) null) + .filterDrop(stmt -> stmt.getPredicate().equals(RDFA.uuid)) + .toList(); + assertThat(nonUuidStatements).isEmpty(); + } finally { + endRead(); + } + } + + /** Asserts that the resource still has properties beyond just UUID. */ + private void assertResourceFullyPresent(String localName) { + var model = readModel(); + try { + var resource = ResourceFactory.createResource(CIM_NS + localName); + var nonUuidStatements = + model.listStatements(resource, null, (RDFNode) null) + .filterDrop(stmt -> stmt.getPredicate().equals(RDFA.uuid)) + .toList(); + assertThat(nonUuidStatements).isNotEmpty(); + } finally { + endRead(); + } + } + + // ==================== Delete ontology ==================== + + @Test + void executeDeleteRequests_deleteOntology_removesOntology() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(ONTOLOGY_UUID, DeleteAction.DELETE))); + + assertResourceRemovedOrOnlyUuid("Ontology"); + } + + // ==================== Delete package ==================== + + @Test + void executeDeleteRequests_deletePackage_removesPackagePropertiesButPreservesUuid() { + // Package is still referenced by classes via belongsToCategory, so UUID is preserved + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(PACKAGE_UUID, DeleteAction.DELETE))); + + assertResourceRemovedOrOnlyUuid("Package_Package"); + } + + @Test + void executeDeleteRequests_keepPackage_doesNotRemovePackage() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(PACKAGE_UUID, DeleteAction.KEEP))); + + assertResourceFullyPresent("Package_Package"); + } + + // ==================== Delete class ==================== + + @Test + void executeDeleteRequests_deleteClass_removesClassAndOwnedAttributes() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(PARENT_CLASS_UUID, DeleteAction.DELETE))); + + // ParentClass is referenced via subClassOf, so UUID may be preserved + assertResourceRemovedOrOnlyUuid("ParentClass"); + // ParentClass.attr1 is owned by ParentClass and should be removed + assertResourceRemovedOrOnlyUuid("ParentClass.attr1"); + } + + @Test + void executeDeleteRequests_deleteClass_removesClassProperties() { + // ChildClass is referenced by AssociatedClass via subClassOf + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(CHILD_CLASS_UUID, DeleteAction.DELETE))); + + assertResourceRemovedOrOnlyUuid("ChildClass"); + } + + @Test + void executeDeleteRequests_keepClass_doesNotRemoveClass() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(PARENT_CLASS_UUID, DeleteAction.KEEP))); + + assertResourceFullyPresent("ParentClass"); + } + + @Test + void executeDeleteRequests_removeSubclassReference_removesOnlySubClassOfTriple() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, + List.of(request(CHILD_CLASS_UUID, DeleteAction.REMOVE_SUBCLASS_REFERENCE))); + + var model = readModel(); + try { + var childClassResource = ResourceFactory.createResource(CIM_NS + "ChildClass"); + assertThat( + model.listStatements( + childClassResource, RDFS.subClassOf, (RDFNode) null) + .hasNext()) + .isFalse(); + assertThat(childClassResource.inModel(model).hasProperty(RDF.type, RDFS.Class)) + .isTrue(); + assertThat(childClassResource.inModel(model).hasProperty(RDFS.label)).isTrue(); + } finally { + endRead(); + } + } + + @Test + void executeDeleteRequests_removePackageReference_removesOnlyBelongsToCategoryTriple() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, + List.of(request(CHILD_CLASS_UUID, DeleteAction.REMOVE_PACKAGE_REFERENCE))); + + var model = readModel(); + try { + var childClassResource = ResourceFactory.createResource(CIM_NS + "ChildClass"); + assertThat( + model.listStatements( + childClassResource, + CIMS.belongsToCategory, + (RDFNode) null) + .hasNext()) + .isFalse(); + assertThat(childClassResource.inModel(model).hasProperty(RDF.type, RDFS.Class)) + .isTrue(); + assertThat(childClassResource.inModel(model).hasProperty(RDFS.label)).isTrue(); + } finally { + endRead(); + } + } + + // ==================== Delete attribute ==================== + + @Test + void executeDeleteRequests_deleteAttribute_removesAttribute() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(ATTR1_UUID, DeleteAction.DELETE))); + + assertResourceRemovedOrOnlyUuid("ParentClass.attr1"); + } + + @Test + void executeDeleteRequests_keepAttribute_doesNotRemoveAttribute() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(ATTR1_UUID, DeleteAction.KEEP))); + + assertResourceFullyPresent("ParentClass.attr1"); + } + + // ==================== Delete association ==================== + + @Test + void executeDeleteRequests_deleteAssociation_removesAssociationAndInverse() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(ASSOC_NONEXISTING_UUID, DeleteAction.DELETE))); + + // Both the association and its inverse should have their properties removed + assertResourceRemovedOrOnlyUuid("AssociatedClass.NonExisting"); + assertResourceRemovedOrOnlyUuid("Temp.AssociatedClass"); + } + + @Test + void executeDeleteRequests_keepAssociation_doesNotRemoveAssociation() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(ASSOC_NONEXISTING_UUID, DeleteAction.KEEP))); + + assertResourceFullyPresent("AssociatedClass.NonExisting"); + } + + @Test + void executeDeleteRequests_deleteAssociationOtherDirection_removesAssociationAndInverse() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, + List.of(request(ASSOC_ASSOCIATED_CHILD_UUID, DeleteAction.DELETE))); + + assertResourceRemovedOrOnlyUuid("AssociatedClass.ChildClass"); + assertResourceRemovedOrOnlyUuid("ChildClass.AssociatedClass"); + } + + // ==================== Delete class with external associations ==================== + + @Test + void executeDeleteRequests_deleteClassWithExternalAssociation_removesExternalAssociations() { + // AssociatedClass has an association to Temp (external, not in any package) + // Deleting AssociatedClass should remove associations referencing external resources + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(ASSOCIATED_CLASS_UUID, DeleteAction.DELETE))); + + assertResourceRemovedOrOnlyUuid("AssociatedClass"); + assertResourceRemovedOrOnlyUuid("AssociatedClass.NonExisting"); + } + + @Test + void executeDeleteRequests_deleteClass_doesNotRemoveInternalAssociations() { + // AssociatedClass.ChildClass points to ChildClass (internal, in same package) + // Deleting AssociatedClass should NOT remove internal associations + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(ASSOCIATED_CLASS_UUID, DeleteAction.DELETE))); + + assertResourceFullyPresent("AssociatedClass.ChildClass"); + } + + // ==================== Multiple delete requests ==================== + + @Test + void executeDeleteRequests_multipleRequests_executesAllInOrder() { + var requests = + List.of( + request(ATTR1_UUID, DeleteAction.DELETE), + request(CHILD_CLASS_UUID, DeleteAction.REMOVE_SUBCLASS_REFERENCE), + request(ONTOLOGY_UUID, DeleteAction.DELETE)); + + service.executeDeleteRequests(GRAPH_IDENTIFIER, requests); + + // Attribute should be removed + assertResourceRemovedOrOnlyUuid("ParentClass.attr1"); + + // ChildClass should still exist but without subClassOf + var model = readModel(); + try { + var childClassResource = ResourceFactory.createResource(CIM_NS + "ChildClass"); + assertThat(childClassResource.inModel(model).hasProperty(RDF.type, RDFS.Class)) + .isTrue(); + assertThat( + model.listStatements( + childClassResource, RDFS.subClassOf, (RDFNode) null) + .hasNext()) + .isFalse(); + } finally { + endRead(); + } + + // Ontology should be removed + assertResourceRemovedOrOnlyUuid("Ontology"); + } + + // ==================== Invalid actions are skipped gracefully ==================== + + @Test + void executeDeleteRequests_unsupportedActionForPackage_skipsWithoutException() { + service.executeDeleteRequests( + GRAPH_IDENTIFIER, + List.of(request(PACKAGE_UUID, DeleteAction.REMOVE_SUBCLASS_REFERENCE))); + + assertResourceFullyPresent("Package_Package"); + } + + @Test + void executeDeleteRequests_nullAction_skipsWithoutException() { + service.executeDeleteRequests(GRAPH_IDENTIFIER, List.of(request(PARENT_CLASS_UUID, null))); + + assertResourceFullyPresent("ParentClass"); + } + + // ==================== Referenced resources preserve UUID ==================== + + @Test + void executeDeleteRequests_deleteReferencedResource_preservesUuidTriple() { + // DatatypeClass is referenced by ParentClass.attr1 via cims:dataType + service.executeDeleteRequests( + GRAPH_IDENTIFIER, List.of(request(DATATYPE_CLASS_UUID, DeleteAction.DELETE))); + + var model = readModel(); + try { + var datatypeResource = ResourceFactory.createResource(CIM_NS + "DatatypeClass"); + + // UUID triple should be preserved since it's referenced elsewhere + var uuidStatements = + model.listStatements(datatypeResource, RDFA.uuid, (RDFNode) null).toList(); + assertThat(uuidStatements).isNotEmpty(); + + // All other properties should be removed + var otherStatements = + model.listStatements(datatypeResource, null, (RDFNode) null) + .filterDrop(stmt -> stmt.getPredicate().equals(RDFA.uuid)) + .toList(); + assertThat(otherStatements).isEmpty(); + } finally { + endRead(); + } + } +} diff --git a/backend/src/test/java/org/rdfarchitect/services/delete/FindDeleteDependenciesServiceTest.java b/backend/src/test/java/org/rdfarchitect/services/delete/FindDeleteDependenciesServiceTest.java new file mode 100644 index 00000000..af496552 --- /dev/null +++ b/backend/src/test/java/org/rdfarchitect/services/delete/FindDeleteDependenciesServiceTest.java @@ -0,0 +1,367 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.rdfarchitect.services.delete; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFDataMgr; +import org.apache.jena.sparql.graph.GraphFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.rdfarchitect.api.dto.delete.DeleteAction; +import org.rdfarchitect.api.dto.delete.relations.AffectedAssociation; +import org.rdfarchitect.api.dto.delete.relations.AffectedResource; +import org.rdfarchitect.api.dto.delete.relations.AffectedResource.AffectedResourceReason; +import org.rdfarchitect.database.DatabasePort; +import org.rdfarchitect.database.GraphIdentifier; +import org.rdfarchitect.database.inmemory.GraphWithContext; +import org.rdfarchitect.models.cim.relations.model.CIMResourceTypeIdentifyingUtils.CimResourceType; +import org.rdfarchitect.rdf.graph.wrapper.GraphRewindableWithUUIDs; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@ExtendWith(MockitoExtension.class) +class FindDeleteDependenciesServiceTest { + + private static final String TEST_DATA_PATH = + "src/test/java/org/rdfarchitect/services/delete/testdata.ttl"; + + private static final GraphIdentifier GRAPH_IDENTIFIER = + new GraphIdentifier("default", "default"); + + // UUIDs from the TTL test data + private static final UUID PARENT_CLASS_UUID = + UUID.fromString("05131eaf-a7dd-4ac4-8624-9665990985ab"); + private static final UUID CHILD_CLASS_UUID = + UUID.fromString("93ee2f31-5ddd-4b25-b119-e90a5ed327b0"); + private static final UUID ASSOCIATED_CLASS_UUID = + UUID.fromString("f6d92056-c469-40d4-add1-1d6adf2fa7a6"); + private static final UUID PACKAGE_UUID = + UUID.fromString("0351f9b6-5e91-4059-9d8b-169d28f1b2c8"); + private static final UUID DATATYPE_CLASS_UUID = + UUID.fromString("db520255-95ef-40d4-b328-e1631e4683a4"); + private static final UUID ONTOLOGY_UUID = + UUID.fromString("dba8f8e3-bfb3-4e62-9ca5-b0136ed186b2"); + + @Mock private DatabasePort databasePort; + + @InjectMocks private FindDeleteDependenciesService service; + + @BeforeEach + void setUp() throws IOException { + var graph = GraphFactory.createDefaultGraph(); + InputStream in = Files.newInputStream(Path.of(TEST_DATA_PATH)); + RDFDataMgr.read(graph, in, Lang.TTL); + in.close(); + + var wrappedGraph = new GraphRewindableWithUUIDs(graph, 5, 5); + var wrappedContext = new GraphWithContext(wrappedGraph); + + when(databasePort.getGraphWithContext(any(GraphIdentifier.class))) + .thenReturn(wrappedContext); + } + + // ==================== Simple resource types ==================== + + @Test + void getDeleteDependencies_ontology_returnsAffectedResourceWithDeleteAction() { + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, ONTOLOGY_UUID); + + assertThat(result.getResourceIdentifier().getUuid()).isEqualTo(ONTOLOGY_UUID); + assertThat(result.getType()).isEqualTo(CimResourceType.ONTOLOGY); + assertThat(result.getReason()).isEqualTo(AffectedResourceReason.DELETION_REQUESTED_BY_USER); + assertThat(result.getActions()).containsExactly(DeleteAction.DELETE); + } + + @Test + void getDeleteDependencies_datatypeClass_returnsClassWithAttributesDependingOnIt() { + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, DATATYPE_CLASS_UUID); + + assertThat(result.getResourceIdentifier().getUuid()).isEqualTo(DATATYPE_CLASS_UUID); + assertThat(result.getType()).isEqualTo(CimResourceType.CLASS); + assertThat(result.getActions()).containsExactly(DeleteAction.DELETE); + } + + // ==================== Class with child classes (inheritance) ==================== + + @Test + void getDeleteDependencies_classWithDirectChild_returnsChildAsAffectedResource() { + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, PARENT_CLASS_UUID); + + assertThat(result.getResourceIdentifier().getUuid()).isEqualTo(PARENT_CLASS_UUID); + assertThat(result.getType()).isEqualTo(CimResourceType.CLASS); + + var childClasses = + result.getChildren().stream() + .filter(c -> c.getType() == CimResourceType.CLASS) + .toList(); + + assertThat(childClasses) + .isNotEmpty() + .anyMatch( + c -> + c.getResourceIdentifier().getUuid().equals(CHILD_CLASS_UUID) + && c.getReason() == AffectedResourceReason.CHILD_OF); + } + + @Test + void getDeleteDependencies_classWithTransitiveChildren_returnsNestedHierarchy() { + // AssociatedClass -> ParentClass -> ChildClass (-> AssociatedClass = cycle) + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, ASSOCIATED_CLASS_UUID); + + assertThat(result.getResourceIdentifier().getUuid()).isEqualTo(ASSOCIATED_CLASS_UUID); + + // ParentClass should be a direct child + var parentClassChild = + result.getChildren().stream() + .filter( + c -> + c.getType() == CimResourceType.CLASS + && c.getResourceIdentifier() + .getUuid() + .equals(PARENT_CLASS_UUID)) + .findFirst(); + assertThat(parentClassChild).isPresent(); + + // ChildClass should be nested under ParentClass, not flat + var childClassNested = + parentClassChild.get().getChildren().stream() + .filter( + c -> + c.getType() == CimResourceType.CLASS + && c.getResourceIdentifier() + .getUuid() + .equals(CHILD_CLASS_UUID)) + .findFirst(); + assertThat(childClassNested).isPresent(); + } + + @Test + void getDeleteDependencies_cyclicInheritance_doesNotCauseInfiniteLoop() { + // AssociatedClass -> ChildClass -> ParentClass -> AssociatedClass = cycle + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, ASSOCIATED_CLASS_UUID); + + assertThat(result).isNotNull(); + assertThat(result.getResourceIdentifier().getUuid()).isEqualTo(ASSOCIATED_CLASS_UUID); + + // Each class must appear only once in the tree + var allChildClasses = flattenChildClasses(result); + var classUuids = + allChildClasses.stream().map(c -> c.getResourceIdentifier().getUuid()).toList(); + assertThat(classUuids).doesNotHaveDuplicates(); + } + + @Test + void getDeleteDependencies_childClassActions_containDeleteKeepAndRemoveSubclassReference() { + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, ASSOCIATED_CLASS_UUID); + + var childClasses = + result.getChildren().stream() + .filter(c -> c.getType() == CimResourceType.CLASS) + .toList(); + + assertThat(childClasses) + .isNotEmpty() + .allSatisfy( + child -> + assertThat(child.getActions()) + .containsExactlyInAnyOrder( + DeleteAction.DELETE, + DeleteAction.KEEP, + DeleteAction.REMOVE_SUBCLASS_REFERENCE)); + } + + // ==================== Class with associations ==================== + + @Test + void getDeleteDependencies_classWithAssociations_returnsAssociationsAsAffectedResources() { + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, ASSOCIATED_CLASS_UUID); + + var associations = + result.getChildren().stream() + .filter(c -> c.getType() == CimResourceType.ASSOCIATION) + .toList(); + + assertThat(associations) + .isNotEmpty() + .allSatisfy( + assoc -> + assertThat(assoc.getReason()) + .isEqualTo( + AffectedResourceReason + .REFENCES_DELETED_CLASS_VIA_ASSOCIATION)); + } + + @Test + void getDeleteDependencies_associationWithTarget_returnsAffectedAssociationWithTarget() { + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, ASSOCIATED_CLASS_UUID); + + var affectedAssociations = + result.getChildren().stream() + .filter(AffectedAssociation.class::isInstance) + .map(c -> (AffectedAssociation) c) + .toList(); + + assertThat(affectedAssociations) + .isNotEmpty() + .allSatisfy(assoc -> assertThat(assoc.getTarget()).isNotNull()); + } + + // ==================== Child classes have their own dependencies ==================== + + @Test + void getDeleteDependencies_childClassWithAssociations_childHasAssociationsAsChildren() { + // When ParentClass is deleted, ChildClass should appear as a child + // and ChildClass should have its own associations as children + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, PARENT_CLASS_UUID); + + var childClassAffected = + result.getChildren().stream() + .filter( + c -> + c.getType() == CimResourceType.CLASS + && c.getResourceIdentifier() + .getUuid() + .equals(CHILD_CLASS_UUID)) + .findFirst(); + assertThat(childClassAffected).isPresent(); + + var childAssociations = + childClassAffected.get().getChildren().stream() + .filter(c -> c.getType() == CimResourceType.ASSOCIATION) + .toList(); + assertThat(childAssociations).isNotEmpty(); + } + + // ==================== Class used as datatype ==================== + + @Test + void getDeleteDependencies_classUsedAsDatatype_returnsAttributeAsAffectedResource() { + // DatatypeClass is used as datatype in ParentClass.attr1 + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, DATATYPE_CLASS_UUID); + + var attributes = + result.getChildren().stream() + .filter(c -> c.getType() == CimResourceType.ATTRIBUTE) + .toList(); + + assertThat(attributes) + .isNotEmpty() + .allSatisfy( + attr -> + assertThat(attr.getReason()) + .isEqualTo( + AffectedResourceReason + .USES_DELETED_CLASS_AS_DATATYPE)); + } + + // ==================== Delete package ==================== + + @Test + void getDeleteDependencies_package_returnsAllClassesInPackageAsChildren() { + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, PACKAGE_UUID); + + assertThat(result.getResourceIdentifier().getUuid()).isEqualTo(PACKAGE_UUID); + assertThat(result.getType()).isEqualTo(CimResourceType.PACKAGE); + assertThat(result.getReason()).isEqualTo(AffectedResourceReason.DELETION_REQUESTED_BY_USER); + + var childClasses = + result.getChildren().stream() + .filter(c -> c.getType() == CimResourceType.CLASS) + .toList(); + + // Package_Package contains: ParentClass, ChildClass, AssociatedClass + assertThat(childClasses).hasSize(3); + + var childUuids = + childClasses.stream().map(c -> c.getResourceIdentifier().getUuid()).toList(); + assertThat(childUuids) + .containsExactlyInAnyOrder( + PARENT_CLASS_UUID, CHILD_CLASS_UUID, ASSOCIATED_CLASS_UUID); + } + + @Test + void getDeleteDependencies_package_classesHaveCorrectReasonAndActions() { + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, PACKAGE_UUID); + + var childClasses = + result.getChildren().stream() + .filter(c -> c.getType() == CimResourceType.CLASS) + .toList(); + + assertThat(childClasses) + .isNotEmpty() + .allSatisfy( + cls -> { + assertThat(cls.getReason()) + .isEqualTo(AffectedResourceReason.CONTAINED_IN_PACKAGE); + assertThat(cls.getActions()) + .containsExactlyInAnyOrder( + DeleteAction.DELETE, + DeleteAction.KEEP, + DeleteAction.REMOVE_PACKAGE_REFERENCE); + }); + } + + @Test + void getDeleteDependencies_package_classesInPackageHaveTheirOwnDependencies() { + var result = service.getDeleteDependencies(GRAPH_IDENTIFIER, PACKAGE_UUID); + + var associatedClassAffected = + result.getChildren().stream() + .filter( + c -> + c.getResourceIdentifier() + .getUuid() + .equals(ASSOCIATED_CLASS_UUID)) + .findFirst(); + assertThat(associatedClassAffected).isPresent(); + + // AssociatedClass has associations and child classes + assertThat(associatedClassAffected.get().getChildren()).isNotEmpty(); + } + + // ==================== Helper methods ==================== + + private List flattenChildClasses(AffectedResource root) { + var result = new ArrayList(); + if (root.getChildren() == null) { + return result; + } + for (var child : root.getChildren()) { + if (child.getType() == CimResourceType.CLASS) { + result.add(child); + result.addAll(flattenChildClasses(child)); + } + } + return result; + } +} diff --git a/backend/src/test/java/org/rdfarchitect/services/delete/testdata.ttl b/backend/src/test/java/org/rdfarchitect/services/delete/testdata.ttl new file mode 100644 index 00000000..a67455fa --- /dev/null +++ b/backend/src/test/java/org/rdfarchitect/services/delete/testdata.ttl @@ -0,0 +1,153 @@ + + a ; + + "Datatypes"@en; + "a5ede240-396a-44a6-91c6-4fbd588df864" . + + + a ; + + "Package"@en; + "0351f9b6-5e91-4059-9d8b-169d28f1b2c8" . + + + a ; + "dba8f8e3-bfb3-4e62-9ca5-b0136ed186b2"; + + "2026-04-20T14:19:03.9589539"^^; + + "2026-04-20"^^ . + + + a ; + + ; + + "NonExisting"@en; + + ; + "b2a306f1-4789-4a12-b045-04a014e7a937"; + + "No"; + + ; + + . + + + a ; + + ; + + "AssociatedClass"@en; + + ; + "c3b417f2-5890-5b23-c156-15b015e8b048"; + + "No"; + + ; + + . + + + "45168d29-0a45-42f1-bb51-1a8efc03bac1" . + + + a ; + + ; + + "ChildClass"@en; + + ; + "abfab117-e4bc-4814-9b5a-3aed72de8a2d"; + + "No"; + + ; + + . + + + a ; + + ; + + "AssociatedClass"@en; + + ; + "bc0bc228-f5cd-5925-ab6b-4bfe83ef9b3e"; + + "No"; + + ; + + . + + + a ; + + "AssociatedClass"@en; + + ; + "f6d92056-c469-40d4-add1-1d6adf2fa7a6"; + + . + + + a ; + + ; + + "attr1"@en; + "dc934c7b-6c4d-4177-9832-bcb955f25414"; + + ; + + ; + + . + + + "3b04c3fc-44bf-46fc-a0b2-fd3e799ac35e" . + + + "0998527c-7e84-4442-ac48-02cad69e1342" . + + + "569fb7d4-0e27-4ef8-8465-65bb9358b7be" . + + + a ; + + "DatatypeClass"@en; + "db520255-95ef-40d4-b328-e1631e4683a4"; + + ; + + "Primitive" . + + + a ; + + "ChildClass"@en; + + ; + "93ee2f31-5ddd-4b25-b119-e90a5ed327b0"; + + ; + + . + + + a ; + + "ParentClass"@en; + + ; + "05131eaf-a7dd-4ac4-8624-9665990985ab"; + + ; + + . \ No newline at end of file diff --git a/frontend/src/lib/api/backend.js b/frontend/src/lib/api/backend.js index d4e5e173..6d0363ff 100644 --- a/frontend/src/lib/api/backend.js +++ b/frontend/src/lib/api/backend.js @@ -95,8 +95,8 @@ export class BackendConnection { }); } - async getClasses(datasetName, graphURI) { - const url = `${PUBLIC_BACKEND_URL}/datasets/${encodeURIComponent(datasetName)}/graphs/${encodeURIComponent(graphURI)}/classes`; + async getClasses(datasetName, graphURI, includeExternalClasses = false) { + const url = `${PUBLIC_BACKEND_URL}/datasets/${encodeURIComponent(datasetName)}/graphs/${encodeURIComponent(graphURI)}/classes?includeExternalClasses=${includeExternalClasses}`; return fetch(url, { method: "GET", mode: "cors", @@ -190,6 +190,14 @@ export class BackendConnection { }); } + async getDeleteRelation(datasetName, graphURI, resourceUuid) { + let url = `${PUBLIC_BACKEND_URL}/datasets/${encodeURIComponent(datasetName)}/graphs/${encodeURIComponent(graphURI)}/uuid/${encodeURIComponent(resourceUuid)}/deletion-impact`; + return await fetch(url, { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }), + credentials: "include", + }); + } async deleteClass(datasetName, graphURI, classUUID) { let url = `${PUBLIC_BACKEND_URL}/datasets/${encodeURIComponent(datasetName)}/graphs/${encodeURIComponent(graphURI)}/classes/${encodeURIComponent(classUUID)}`; return await fetch(url, { @@ -199,6 +207,16 @@ export class BackendConnection { }); } + async deleteResources(datasetName, graphURI, deleteRequests) { + let url = `${PUBLIC_BACKEND_URL}/datasets/${encodeURIComponent(datasetName)}/graphs/${encodeURIComponent(graphURI)}/delete-requests`; + return await fetch(url, { + method: "POST", + headers: new Headers({ "Content-Type": "application/json" }), + body: JSON.stringify(deleteRequests), + credentials: "include", + }); + } + async postEnumEntry(datasetName, graphURI, classUUID, enumEntry) { let url = `${PUBLIC_BACKEND_URL}/datasets/${encodeURIComponent(datasetName)}/graphs/${encodeURIComponent(graphURI)}/classes/${encodeURIComponent(classUUID)}/enumentries`; return await fetch(url, { diff --git a/frontend/src/lib/components/bitsui/tabs/Tabs.svelte b/frontend/src/lib/components/bitsui/tabs/Tabs.svelte new file mode 100644 index 00000000..a14c0144 --- /dev/null +++ b/frontend/src/lib/components/bitsui/tabs/Tabs.svelte @@ -0,0 +1,44 @@ + + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/bitsui/tabs/TabsList.svelte b/frontend/src/lib/components/bitsui/tabs/TabsList.svelte new file mode 100644 index 00000000..32adbd93 --- /dev/null +++ b/frontend/src/lib/components/bitsui/tabs/TabsList.svelte @@ -0,0 +1,28 @@ + + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/bitsui/tabs/TabsTrigger.svelte b/frontend/src/lib/components/bitsui/tabs/TabsTrigger.svelte new file mode 100644 index 00000000..ccf1065a --- /dev/null +++ b/frontend/src/lib/components/bitsui/tabs/TabsTrigger.svelte @@ -0,0 +1,67 @@ + + + + + + {#if text} + {text} + {/if} + {#if icon} + + {/if} + + diff --git a/frontend/src/lib/dialog/ActionDialog.svelte b/frontend/src/lib/dialog/ActionDialog.svelte index 6e632a05..ab388758 100644 --- a/frontend/src/lib/dialog/ActionDialog.svelte +++ b/frontend/src/lib/dialog/ActionDialog.svelte @@ -101,15 +101,18 @@ -
-
-
-
+
+
+
+

{#if titleIcon} - + {/if} {#if title} {title} @@ -128,7 +131,7 @@ />

-
+
{@render children?.()}
{#if !readonly && (secondaryButtonExists || primaryButtonExists)} diff --git a/frontend/src/lib/dialog/DialogBase.svelte b/frontend/src/lib/dialog/DialogBase.svelte index 5c2bccd6..32e64af5 100644 --- a/frontend/src/lib/dialog/DialogBase.svelte +++ b/frontend/src/lib/dialog/DialogBase.svelte @@ -73,7 +73,7 @@ - diff --git a/frontend/src/lib/rendering/svelteflow/svelteFlowWrapper.svelte b/frontend/src/lib/rendering/svelteflow/svelteFlowWrapper.svelte index b6fc5226..76f69b96 100644 --- a/frontend/src/lib/rendering/svelteflow/svelteFlowWrapper.svelte +++ b/frontend/src/lib/rendering/svelteflow/svelteFlowWrapper.svelte @@ -25,7 +25,7 @@ useSvelteFlow, } from "@xyflow/svelte"; import ElkWorkerURL from "elkjs/lib/elk-worker.js?url"; - import ELK from "elkjs/lib/elk.bundled.js"; + import ELK from "elkjs/lib/elk.bundled.js"; //keep this import! the 'elkjs' import has a bug import { onMount, untrack } from "svelte"; import { BackendConnection } from "$lib/api/backend.js"; @@ -42,6 +42,8 @@ import InheritanceEdge from "./components/InheritanceEdge.svelte"; import SvelteFlowClassContextMenu from "./components/SvelteFlowClassContextMenu.svelte"; import SvelteFlowPaneContextMenu from "./components/SvelteFlowPaneContextMenu.svelte"; + import DeleteDependenciesDialog from "../../../routes/delete-relations-dialog/DeleteDependenciesDialog.svelte"; + import NewClassDialog from "../../../routes/NewClassDialog.svelte"; let { nodes: inputNodes, @@ -68,6 +70,9 @@ let paneContextMenuRequest = $state(null); let classContextMenuRequest = $state(null); let contextMenuClass = $state(null); + let deleteClassTarget = $state(null); + let showDeleteDependenciesDialog = $state(false); + let showNewClassDialog = $state(false); let pendingNewClassPlacement = null; // Ordered list of node IDs from back (index 0) to front (index n-1). @@ -500,6 +505,9 @@ // All indices up to and including original idx shifted changedIds = next.slice(0, idx + 1); } + deleteClassTarget = contextMenuClass; + showDeleteDependenciesDialog = true; + closeContextMenus(); nodeOrder = next; nodes = applyZIndicesFromOrder(nodes); @@ -710,3 +718,17 @@ onPersistLayer={handlePersistLayer} />
+ + + + diff --git a/frontend/src/routes/DeleteClassConfirmDialog.svelte b/frontend/src/routes/DeleteClassConfirmDialog.svelte deleted file mode 100644 index 93341ffb..00000000 --- a/frontend/src/routes/DeleteClassConfirmDialog.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - - - -
-

- The class will be removed from the model. -
- References to this class will remain. -

-
-
diff --git a/frontend/src/routes/NewClassDialog.svelte b/frontend/src/routes/NewClassDialog.svelte index 249ade37..5b0b84c8 100644 --- a/frontend/src/routes/NewClassDialog.svelte +++ b/frontend/src/routes/NewClassDialog.svelte @@ -107,10 +107,10 @@ } untrack( () => - (className = new ReactiveValueWrapper(className.value, label => + (className = new ReactiveValueWrapper(className?.value, label => isInvalidClassLabel( label, - classURINamespace.value, + classURINamespace?.value, compareClasses, ), )), @@ -125,7 +125,11 @@ classURINamespace = new ReactiveValueWrapper(null); className = new ReactiveValueWrapper("", label => - isInvalidClassLabel(label, classURINamespace.value, compareClasses), + isInvalidClassLabel( + label, + classURINamespace?.value, + compareClasses, + ), ); if (!datasetName) { @@ -209,13 +213,12 @@ }), body: JSON.stringify({ packageDTO, - classURIPrefix: classURINamespaceLocal.value, - className: classNameLocal.value, + classURIPrefix: classURINamespaceLocal?.value, + className: classNameLocal?.value, }), credentials: "include", }, ); - if (res.ok) { const uuid = await res.text(); console.log("successfully added class"); @@ -283,7 +286,7 @@ - {#if className} + {#if className && classURINamespace} + + + + +
+

+ Select how affected resources should be handled when deleting this + {type}. +

+ + {#if deleteDependencies} +
+ +
+ {:else} +
+ Loading dependencies... +
+ {/if} +
+
diff --git a/frontend/src/routes/delete-relations-dialog/DeleteDependencyNode.svelte b/frontend/src/routes/delete-relations-dialog/DeleteDependencyNode.svelte new file mode 100644 index 00000000..ce77c7f8 --- /dev/null +++ b/frontend/src/routes/delete-relations-dialog/DeleteDependencyNode.svelte @@ -0,0 +1,373 @@ + + + + +
+ +
0 && !disabled} + class:opacity-40={disabled} + style="padding-left: {depth * 1.25 + 0.75}rem;" + > + + + + + + {typeBadge} + + + +
+ {#if isAssociation && node.domain && node.target} + + {node.target.label} + + + + {node.domain.label} + + {:else} + + {node.resourceIdentifier.label} + {#if typeBadge === "ATTRIBUTE" && node.domain} + + ({node.domain.label}) + + {/if} + + {/if} + {#if node.reason && !isRoot} + + {reasonLabels[node.reason] ?? node.reason} + + {/if} +
+ + +
+ + + {#each availableActions as action} + {@const config = actionConfig[action]} + {#if action === ACTIONS.REMOVE_SUBCLASS_REFERENCE && availableActions.includes(ACTIONS.REMOVE_PACKAGE_REFERENCE)} + + {:else if action === ACTIONS.REMOVE_PACKAGE_REFERENCE} + {@const refAction = node.actions.find( + a => + a === ACTIONS.REMOVE_PACKAGE_REFERENCE || + a === ACTIONS.REMOVE_SUBCLASS_REFERENCE, + )} +
+ {#if refAction} + {@const refConfig = actionConfig[refAction]} + + {/if} +
+ {:else} +
+ {#if node.actions.includes(action)} + + {/if} +
+ {/if} + {/each} +
+
+
+
+ + + {#if hasChildren && expanded} +
+ {#if isRoot} + +
+ + Set all: + +
+ (bulkValue = null)} + {disabled} + > + + {#each availableActions as action} + {@const config = actionConfig[action]} + {#if action === ACTIONS.REMOVE_SUBCLASS_REFERENCE && availableActions.includes(ACTIONS.REMOVE_PACKAGE_REFERENCE)} + + {:else if action === ACTIONS.REMOVE_PACKAGE_REFERENCE} + {@const refAction = [ + ACTIONS.REMOVE_PACKAGE_REFERENCE, + ACTIONS.REMOVE_SUBCLASS_REFERENCE, + ].find(a => + bulkApplicableActions.has(a), + )} +
+ {#if refAction} + {@const refConfig = + actionConfig[refAction]} + + handleBulkChange( + refAction, + )} + /> + {/if} +
+ {:else} +
+ {#if bulkApplicableActions.has(action)} + + handleBulkChange( + action, + )} + /> + {/if} +
+ {/if} + {/each} +
+
+
+
+ {/if} + + {#each node.children as child, i (child.resourceIdentifier.uuid)} + {#if i > 0 && (node.children[i - 1]?.children?.length > 0 || child.children?.length > 0)} +
+ {/if} + + {/each} +
+ {/if} +
diff --git a/frontend/src/routes/delete-relations-dialog/deleteDependencyDefaults.js b/frontend/src/routes/delete-relations-dialog/deleteDependencyDefaults.js new file mode 100644 index 00000000..20824d82 --- /dev/null +++ b/frontend/src/routes/delete-relations-dialog/deleteDependencyDefaults.js @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/** + * Default action rules per reason (and optionally type). + * Evaluated top-to-bottom, first match wins. + * Add, remove, or reorder entries to adjust defaults. + */ +const defaultActionRules = [ + { reason: "CONTAINED_IN_PACKAGE", action: "DELETE" }, + { + reason: "REFENCES_DELETED_CLASS_VIA_ASSOCIATION", + type: "ASSOCIATION", + action: "KEEP", + }, + { reason: "USES_DELETED_CLASS_AS_DATATYPE", action: "KEEP" }, + { reason: "CHILD_OF", action: "KEEP" }, +]; + +/** + * Resolves the default action for a node based on the rules. + * Falls back to the first available action if no rule matches. + */ +export function getDefaultAction(node) { + for (const rule of defaultActionRules) { + if (node.reason !== rule.reason) continue; + if (rule.type && node.type !== rule.type) continue; + if (node.actions.includes(rule.action)) { + return rule.action; + } + } + return node.actions[0]; +} diff --git a/frontend/src/routes/layout/menu-bar/Edit.svelte b/frontend/src/routes/layout/menu-bar/Edit.svelte index eb0f1904..8fe20f5f 100644 --- a/frontend/src/routes/layout/menu-bar/Edit.svelte +++ b/frontend/src/routes/layout/menu-bar/Edit.svelte @@ -43,10 +43,10 @@ forceReloadTrigger, } from "$lib/sharedState.svelte.js"; + import DeleteDependenciesDialog from "../../delete-relations-dialog/DeleteDependenciesDialog.svelte"; import FilterViewDialog from "../../FilterViewDialog.svelte"; import PackageEditorDialog from "../../mainpage/packageEditorDialog.svelte"; import OntologyDialog from "../../mainpage/packageNavigation/ontology-editor-dialog/OntologyDialog.svelte"; - import PackageDeleteDialog from "../../mainpage/packageNavigation/PackageDeleteDialog.svelte"; import NamespacesDialog from "../../NamespacesDialog.svelte"; import NewClassDialog from "../../NewClassDialog.svelte"; import NewGraphDialog from "../../NewGraphDialog.svelte"; @@ -60,7 +60,8 @@ let showNewGraphDialog = $state(false); let showNewPackageDialog = $state(false); let showFilterViewDialog = $state(false); - let showPackageDeleteDialog = $state(false); + let ShowPackageDeleteDependenciesDialog = $state(false); + let showOntologyDeleteDependenciesDialog = $state(false); let showPackageEditorDialog = $state(false); let showNamespaceDialog = $state(false); let showEditOntologyDialog = $state(false); @@ -154,7 +155,7 @@ packageDialogTarget = { ...selectedPackageDetails }; packageDialogDataset = selectedDataset; packageDialogGraph = selectedGraph; - showPackageDeleteDialog = true; + ShowPackageDeleteDependenciesDialog = true; } async function getPackages() { @@ -171,8 +172,14 @@ } const packagesJSON = await response.json(); return [ - ...(packagesJSON.internalPackageList ?? []), - ...(packagesJSON.externalPackageList ?? []), + ...(packagesJSON.internalPackageList ?? []).map(p => ({ + ...p, + external: false, + })), + ...(packagesJSON.externalPackageList ?? []).map(p => ({ + ...p, + external: true, + })), ]; } catch (error) { console.error("Failed to fetch packages", error); @@ -228,6 +235,7 @@ async function disableEditing(datasetName) { await bec.disableEditing(datasetName); } + $inspect("selectedPackageDetails: ", selectedPackageDetails); @@ -337,12 +345,8 @@ { - await bec.deleteOntology( - selectedDataset, - selectedGraph, - ); - reload(); + onSelect={() => { + showOntologyDeleteDependenciesDialog = true; }} disabled={!hasGraphSelected || !graphHasOntology} faIcon={faTrash} @@ -376,16 +380,26 @@ readonly={isDatasetReadOnly} /> {/if} -{#if packageDialogTarget && showPackageDeleteDialog} - {/if} +{#if ontology} + +{/if} + {#if showEditOntologyDialog}
(showClassDeleteDialog = true)} + callOnClick={() => (showDeleteDependenciesDialog = true)} icon={faTrash} variant="danger" text="Delete" title="Delete class" /> -
{/if} diff --git a/frontend/src/routes/mainpage/classEditor/fetch-class-editor-context.js b/frontend/src/routes/mainpage/classEditor/fetch-class-editor-context.js index 9273ee10..67667dec 100644 --- a/frontend/src/routes/mainpage/classEditor/fetch-class-editor-context.js +++ b/frontend/src/routes/mainpage/classEditor/fetch-class-editor-context.js @@ -97,7 +97,7 @@ export async function getDataTypes(datasetName, graphUri) { } export async function getClasses(datasetName, graphUri) { - const res = await bec.getClasses(datasetName, graphUri); + const res = await bec.getClasses(datasetName, graphUri, true); let classesDto = await res.json(); let classes = classesDto.map(cls => new Class(cls)); console.log("CLASSES:", classes); diff --git a/frontend/src/routes/mainpage/packageNavigation/ClassEntry.svelte b/frontend/src/routes/mainpage/packageNavigation/ClassEntry.svelte index e23b4626..1ec523a0 100644 --- a/frontend/src/routes/mainpage/packageNavigation/ClassEntry.svelte +++ b/frontend/src/routes/mainpage/packageNavigation/ClassEntry.svelte @@ -30,7 +30,7 @@ import { shortenIri } from "$lib/utils/iri.js"; import { isSelectedClass } from "./packageNavigationUtils.svelte.js"; - import DeleteClassConfirmDialog from "../../DeleteClassConfirmDialog.svelte"; + import DeleteDependenciesDialog from "../../delete-relations-dialog/DeleteDependenciesDialog.svelte"; import SHACLClassSpecificPopUp from "../../shacl/shaclclassspecific/SHACLClassSpecificPopUp.svelte"; let { @@ -42,7 +42,7 @@ onPackChange = () => {}, } = $props(); - let showDeleteDialog = $state(false); + let showDeleteDependenciesDialog = $state(false); let showSHACLDialog = $state(false); const highlightLabel = $derived(shortenIri(namespaces, classNavEntry.id)); @@ -123,7 +123,7 @@ { selectClass(); - showDeleteDialog = true; + showDeleteDependenciesDialog = true; }} disabled={readonly} faIcon={faTrash} @@ -134,13 +134,13 @@ - + { - bec.deleteOntology( - datasetNavEntry.id, - graphNavEntry.id, - ); - initialize(); + showDeleteDependenciesDialog = true; }} variant="danger" faIcon={faTrash} @@ -376,3 +374,11 @@ {readonly} onSubmit={initialize} /> + + diff --git a/frontend/src/routes/mainpage/packageNavigation/PackageButton.svelte b/frontend/src/routes/mainpage/packageNavigation/PackageButton.svelte index cff6dc50..6a32d563 100644 --- a/frontend/src/routes/mainpage/packageNavigation/PackageButton.svelte +++ b/frontend/src/routes/mainpage/packageNavigation/PackageButton.svelte @@ -34,8 +34,8 @@ import { shortenIri } from "$lib/utils/iri.js"; import ClassEntry from "./ClassEntry.svelte"; - import PackageDeleteDialog from "./PackageDeleteDialog.svelte"; import { isSelectedPackage } from "./packageNavigationUtils.svelte.js"; + import DeleteDependenciesDialog from "../../delete-relations-dialog/DeleteDependenciesDialog.svelte"; import NewClassDialog from "../../NewClassDialog.svelte"; import PackageEditorDialog from "../packageEditorDialog.svelte"; @@ -48,7 +48,7 @@ } = $props(); let showNewClassDialog = $state(false); let showPackageEditorDialog = $state(false); - let showDeletePackageDialog = $state(false); + let showDeleteDependenciesDialog = $state(false); let wasPackageSelected = false; @@ -155,7 +155,7 @@ { - showDeletePackageDialog = true; + showDeleteDependenciesDialog = true; }} disabled={readonly || isProtectedPackage} faIcon={faTrash} @@ -197,9 +197,9 @@ {readonly} /> -