diff --git a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationScriptRESTController.java b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationArtifactsRESTController.java similarity index 60% rename from backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationScriptRESTController.java rename to backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationArtifactsRESTController.java index 83889dd5..6574ffe9 100644 --- a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationScriptRESTController.java +++ b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationArtifactsRESTController.java @@ -26,7 +26,8 @@ import org.apache.jena.riot.RDFFormat; import org.rdfarchitect.context.MigrationSessionStore; -import org.rdfarchitect.services.schemamigration.scriptgeneration.GenerateMigrationScriptUseCase; +import org.rdfarchitect.services.schemamigration.artifacts.GenerateMigrationReportUseCase; +import org.rdfarchitect.services.schemamigration.artifacts.GenerateMigrationScriptUseCase; import org.rdfarchitect.services.shacl.SHACLExportUseCase; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,6 +36,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.io.ByteArrayOutputStream; @@ -46,14 +48,15 @@ @RestController @RequiredArgsConstructor -public class MigrationScriptRESTController { +public class MigrationArtifactsRESTController { private static final Logger logger = - LoggerFactory.getLogger(MigrationScriptRESTController.class); + LoggerFactory.getLogger(MigrationArtifactsRESTController.class); private final GenerateMigrationScriptUseCase generateMigrationScriptUseCase; private final SHACLExportUseCase shaclExportUseCase; private final MigrationSessionStore migrationSessionStore; + private final GenerateMigrationReportUseCase generateMigrationReportUseCase; @Operation( summary = "generate migration script", @@ -102,4 +105,55 @@ public ResponseEntity generateMigrationScript( return response; } + + @Operation( + summary = "generate migration script", + description = + "Generates a migration script based on the previously computed migration actions and the shacl shapes of the new schema.", + tags = {"migration"}, + responses = { + @ApiResponse( + responseCode = "200", + content = @Content(mediaType = "application/zip")) + }) + @GetMapping("/api/migrations/report") + public ResponseEntity generateMigrationReport( + @Parameter(description = "The name/url of the inquirer.") + @RequestHeader(value = "origin", required = false, defaultValue = "unknown") + String originURL, + @Parameter( + description = + "Type of the migration report to generate. Can be either SUMMARY or DETAILED.") + @RequestParam(value = "reportType", defaultValue = "SUMMARY") + MigrationReportType reportType) { + logger.info("Received GET request: \"/api/migrations/report\" from \"{}\".", originURL); + + var report = + switch (reportType) { + case SUMMARY -> generateMigrationReportUseCase.generateSummaryMigrationReport(); + case DETAILED -> + generateMigrationReportUseCase.generateDetailedMigrationReport(); + }; + + var body = report.getBytes(StandardCharsets.UTF_8); + var headers = new HttpHeaders(); + headers.setAccessControlExposeHeaders(List.of("Content-disposition")); + var response = + ResponseEntity.ok() + .headers(headers) + .header(HttpHeaders.CONTENT_DISPOSITION, "migration_report.md") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(body); + + logger.info( + "Sending response to GET request: \"/api/migrations/report\" from \"{}\".", + originURL); + + return response; + } + + public enum MigrationReportType { + SUMMARY, + DETAILED + } } diff --git a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationChangesRESTController.java b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationChangesRESTController.java new file mode 100644 index 00000000..e310b691 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationChangesRESTController.java @@ -0,0 +1,106 @@ +/* + * 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.migration; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; + +import lombok.RequiredArgsConstructor; + +import org.rdfarchitect.models.changes.semanticchanges.SemanticClassChange; +import org.rdfarchitect.models.changes.semanticchanges.SemanticResourceChange; +import org.rdfarchitect.services.schemamigration.MigrationChangesUseCase; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RestController +@RequestMapping("/api/migrations/changes") +@RequiredArgsConstructor +public class MigrationChangesRESTController { + + private static final Logger logger = + LoggerFactory.getLogger(MigrationChangesRESTController.class); + + private final MigrationChangesUseCase migrationChangesUseCase; + + @Operation( + summary = "migration changes overview", + description = "returns final changes in migration to be displayed for review", + tags = {"migration"}, + responses = { + @ApiResponse( + responseCode = "200", + content = + @Content( + mediaType = "application/json", + array = + @ArraySchema( + schema = + @Schema( + implementation = + SemanticResourceChange + .class)))) + }) + @GetMapping + public List getMigrationChanges( + @Parameter(description = "The name/url of the inquirer.") + @RequestHeader(value = "origin", required = false, defaultValue = "unknown") + String originURL) { + logger.info("Received GET request: \"/api/migrations/changes\" from \"{}\".", originURL); + + var classes = migrationChangesUseCase.getMigrationChanges(); + + logger.info( + "Sending response to GET request: \"/api/migrations/changes\" from \"{}\".", + originURL); + + return classes; + } + + @Operation( + summary = "confirm migration changes", + description = "Confirms the migration changes including potentially added comments", + tags = {"migration"}) + @PostMapping + public void confirmMigrationChanges( + @Parameter(description = "The name/url of the inquirer.") + @RequestHeader(value = "origin", required = false, defaultValue = "unknown") + String originURL, + @Parameter(description = "The updated class renamings") @RequestBody + List changes) { + logger.info("Received POST request: \"/api/migrations/changes\" from \"{}\".", originURL); + + migrationChangesUseCase.confirmMigrationChanges(changes); + + logger.info( + "Sending response to POST request: \"/api/migrations/changes\" from \"{}\".", + originURL); + } +} diff --git a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationContextRESTController.java b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationContextRESTController.java index 59189772..32a5ec31 100644 --- a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationContextRESTController.java +++ b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/migration/MigrationContextRESTController.java @@ -89,12 +89,17 @@ public void computeMigrationContext( description = "URI of graph B. Required together with dataset_b when not uploading graph B as a file.") @RequestParam(required = false) - String graphB) { + String graphB, + @Parameter( + description = + "Whether to ignore prefixes in the comparison and rename detection. Defaults to false.") + @RequestParam(required = false, defaultValue = "false") + boolean ignorePrefixes) { logger.info("Received POST request: \"/api/migrations/context\" from \"{}\".", originURL); // file to file if (fileA != null && fileB != null) { - setMigrationContextUseCase.setMigrationContext(fileA, fileB); + setMigrationContextUseCase.setMigrationContext(fileA, fileB, ignorePrefixes); } // stored to stored else if (datasetA != null && datasetB != null && graphA != null && graphB != null) { @@ -102,19 +107,20 @@ else if (datasetA != null && datasetB != null && graphA != null && graphB != nul var extendedGraphURIB = expandURIUseCase.expandUri(datasetB, graphB); setMigrationContextUseCase.setMigrationContext( new GraphIdentifier(datasetA, extendedGraphURIA), - new GraphIdentifier(datasetB, extendedGraphURIB)); + new GraphIdentifier(datasetB, extendedGraphURIB), + ignorePrefixes); } // file to stored else if (fileA != null && datasetB != null && graphB != null) { var extendedGraphURIB = expandURIUseCase.expandUri(datasetB, graphB); setMigrationContextUseCase.setMigrationContext( - fileA, new GraphIdentifier(datasetB, extendedGraphURIB)); + fileA, new GraphIdentifier(datasetB, extendedGraphURIB), ignorePrefixes); } // stored to file else if (datasetA != null && graphA != null && fileA != null) { var extendedGraphURIA = expandURIUseCase.expandUri(datasetA, graphA); setMigrationContextUseCase.setMigrationContext( - new GraphIdentifier(datasetA, extendedGraphURIA), fileA); + new GraphIdentifier(datasetA, extendedGraphURIA), fileA, ignorePrefixes); } else { logger.warn( "Invalid request to POST \"/api/migrations/context\" from \"{}\". Missing required parameters.", diff --git a/backend/src/main/java/org/rdfarchitect/api/dto/migration/ResourceRenameOverview.java b/backend/src/main/java/org/rdfarchitect/api/dto/migration/ResourceRenameOverview.java index b2fcbe0d..bda3c65d 100644 --- a/backend/src/main/java/org/rdfarchitect/api/dto/migration/ResourceRenameOverview.java +++ b/backend/src/main/java/org/rdfarchitect/api/dto/migration/ResourceRenameOverview.java @@ -17,7 +17,6 @@ package org.rdfarchitect.api.dto.migration; -import lombok.AllArgsConstructor; import lombok.Data; import org.rdfarchitect.models.changes.RenameCandidate; @@ -29,13 +28,10 @@ import java.util.stream.Collectors; @Data -@AllArgsConstructor public class ResourceRenameOverview { private List added; - private List modified; - private List> deletedAndRenamed; public ResourceRenameOverview(List changes, List> renameCandidates) { @@ -46,13 +42,6 @@ public ResourceRenameOverview(List changes, List> renameCa change.getSemanticResourceChangeType() == SemanticResourceChangeType.ADD) .toList(); - this.modified = - changes.stream() - .filter( - change -> - change.getSemanticResourceChangeType() - == SemanticResourceChangeType.CHANGE) - .toList(); var deleted = changes.stream() .filter( diff --git a/backend/src/main/java/org/rdfarchitect/context/SchemaMigrationContext.java b/backend/src/main/java/org/rdfarchitect/context/SchemaMigrationContext.java index 6420f3d0..59ee1440 100644 --- a/backend/src/main/java/org/rdfarchitect/context/SchemaMigrationContext.java +++ b/backend/src/main/java/org/rdfarchitect/context/SchemaMigrationContext.java @@ -29,6 +29,9 @@ @Data public class SchemaMigrationContext { + // whether the comparison ignores prefixes in comparison and rename detection + private boolean ignorePrefixes; + // original graph of the schema private Graph originalSchema; diff --git a/backend/src/main/java/org/rdfarchitect/models/changes/semanticchanges/SemanticFieldChangeType.java b/backend/src/main/java/org/rdfarchitect/models/changes/semanticchanges/SemanticFieldChangeType.java index 49794410..197d0490 100644 --- a/backend/src/main/java/org/rdfarchitect/models/changes/semanticchanges/SemanticFieldChangeType.java +++ b/backend/src/main/java/org/rdfarchitect/models/changes/semanticchanges/SemanticFieldChangeType.java @@ -22,6 +22,7 @@ public enum SemanticFieldChangeType { LABEL_CHANGE, COMMENT_CHANGE, SUPERCLASS_CHANGE, + SUPERCLASS_RENAME, BELONGS_TO_CATEGORY_CHANGE, DATATYPE_CHANGE, DATATYPE_RENAME, diff --git a/backend/src/main/java/org/rdfarchitect/models/changes/semanticchanges/SemanticResourceChange.java b/backend/src/main/java/org/rdfarchitect/models/changes/semanticchanges/SemanticResourceChange.java index efd8f9d5..afac4988 100644 --- a/backend/src/main/java/org/rdfarchitect/models/changes/semanticchanges/SemanticResourceChange.java +++ b/backend/src/main/java/org/rdfarchitect/models/changes/semanticchanges/SemanticResourceChange.java @@ -60,6 +60,8 @@ public sealed class SemanticResourceChange protected String label; + protected String comment = ""; + // only set in case of a rename protected String oldIRI; @@ -84,6 +86,7 @@ public SemanticResourceChange(SemanticResourceChange other) { this.iri = other.getIri(); this.semanticResourceChangeType = other.getSemanticResourceChangeType(); this.changes = other.getChanges(); + this.comment = other.getComment(); } public SemanticResourceChange(Resource resource, SemanticResourceChangeType changeType) { diff --git a/backend/src/main/java/org/rdfarchitect/services/compare/TripleChangeAnalyser.java b/backend/src/main/java/org/rdfarchitect/services/compare/TripleChangeAnalyser.java index 4ed0ce21..33dc698f 100644 --- a/backend/src/main/java/org/rdfarchitect/services/compare/TripleChangeAnalyser.java +++ b/backend/src/main/java/org/rdfarchitect/services/compare/TripleChangeAnalyser.java @@ -37,6 +37,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -215,50 +216,16 @@ private List compareResource( updatedGraph.find(NodeFactory.createURI(uri), Node.ANY, Node.ANY).toList(); var propertyChanges = new ArrayList(); - var originalValues = - originalTriples.stream() - .filter(triple -> !triple.getPredicate().equals(CIMS.stereotype.asNode())) - .collect( - Collectors.toMap( - Triple::getPredicate, Triple::getObject, (a, b) -> a)); - - var updatedValues = - updatedTriples.stream() - .filter(triple -> !triple.getPredicate().equals(CIMS.stereotype.asNode())) - .collect( - Collectors.toMap( - Triple::getPredicate, Triple::getObject, (a, b) -> a)); - - Set allPredicates = new HashSet<>(); - allPredicates.addAll(originalValues.keySet()); - allPredicates.addAll(updatedValues.keySet()); + propertyChanges.addAll(compareSimplePredicates(originalTriples, updatedTriples)); + propertyChanges.addAll(compareStereotypes(originalTriples, updatedTriples)); - for (Node predicate : allPredicates) { - // ignore uuid predicates, stereotypes are handled separately - if (predicate.equals(RDFA.uuid.asNode()) - || predicate.equals(CIMS.stereotype.asNode())) { - continue; - } + return propertyChanges; + } - var from = originalValues.get(predicate); - var to = updatedValues.get(predicate); - var valuesDiffer = !Objects.equals(from, to); - if (RDFS.comment.asNode().equals(predicate)) { - var normalizedFrom = normalizeCommentValue(from); - var normalizedTo = normalizeCommentValue(to); - valuesDiffer = !Objects.equals(normalizedFrom, normalizedTo); - } + private List compareStereotypes( + List originalTriples, List updatedTriples) { + var changes = new ArrayList(); - if (valuesDiffer) { - var change = new TriplePropertyChange(); - change.setPredicate(predicate.toString()); - change.setFrom(from != null ? from.toString() : null); - change.setTo(to != null ? to.toString() : null); - propertyChanges.add(change); - } - } - - // handle stereotypes separately var originalStereotypes = originalTriples.stream() .filter(triple -> triple.getPredicate().equals(CIMS.stereotype.asNode())) @@ -275,7 +242,7 @@ private List compareResource( var change = new TriplePropertyChange(); change.setPredicate(CIMS.stereotype.toString()); change.setFrom(originalStereotype); - propertyChanges.add(change); + changes.add(change); } else { updatedStereotypes.remove(originalStereotype); } @@ -285,10 +252,70 @@ private List compareResource( var change = new TriplePropertyChange(); change.setPredicate(CIMS.stereotype.toString()); change.setTo(updatedStereotype); - propertyChanges.add(change); + changes.add(change); } - return propertyChanges; + return changes; + } + + private List compareSimplePredicates( + List originalTriples, List updatedTriples) { + var changes = new ArrayList(); + + var originalValues = + originalTriples.stream() + .filter(triple -> !triple.getPredicate().equals(CIMS.stereotype.asNode())) + .collect( + Collectors.toMap( + Triple::getPredicate, Triple::getObject, (a, _) -> a)); + + var updatedValues = + updatedTriples.stream() + .filter(triple -> !triple.getPredicate().equals(CIMS.stereotype.asNode())) + .collect( + Collectors.toMap( + Triple::getPredicate, Triple::getObject, (a, _) -> a)); + + Set allPredicates = new HashSet<>(); + allPredicates.addAll(originalValues.keySet()); + allPredicates.addAll(updatedValues.keySet()); + + for (Node predicate : allPredicates) { + var change = comparePredicate(predicate, originalValues, updatedValues); + if (change != null) { + changes.add(change); + } + } + + return changes; + } + + private static TriplePropertyChange comparePredicate( + Node predicate, Map originalValues, Map updatedValues) { + // ignore uuid predicates, stereotypes are handled separately + if (predicate.equals(RDFA.uuid.asNode())) { + return null; + } + + var from = originalValues.get(predicate); + var to = updatedValues.get(predicate); + var valuesDiffer = !Objects.equals(from, to); + + if (RDFS.comment.asNode().equals(predicate)) { + var normalizedFrom = normalizeCommentValue(from); + var normalizedTo = normalizeCommentValue(to); + valuesDiffer = !Objects.equals(normalizedFrom, normalizedTo); + } + + if (valuesDiffer) { + var change = new TriplePropertyChange(); + change.setPredicate(predicate.toString()); + change.setFrom(from != null ? from.toString() : null); + change.setTo(to != null ? to.toString() : null); + return change; + } else { + return null; + } } private String normalizeCommentValue(Node value) { diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/MigrationChangesUseCase.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/MigrationChangesUseCase.java new file mode 100644 index 00000000..b1be62a9 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/MigrationChangesUseCase.java @@ -0,0 +1,38 @@ +/* + * 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.schemamigration; + +import org.rdfarchitect.models.changes.semanticchanges.SemanticClassChange; + +import java.util.List; + +public interface MigrationChangesUseCase { + /** + * lists all final changes of the migration to be displayed for review + * + * @return list of changes + */ + List getMigrationChanges(); + + /** + * updates list of final changes, including any potentially submitted comments + * + * @param changes the updated list of changes + */ + void confirmMigrationChanges(List changes); +} diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/SchemaMigrationService.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/SchemaMigrationService.java index 0efe0004..bf920ca8 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/SchemaMigrationService.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/SchemaMigrationService.java @@ -38,6 +38,10 @@ import org.rdfarchitect.rdf.graph.GraphUtils; import org.rdfarchitect.rdf.graph.source.builder.implementations.GraphFileSourceBuilderImpl; import org.rdfarchitect.services.compare.TripleChangeAnalyser; +import org.rdfarchitect.services.schemamigration.artifacts.GenerateMigrationReportUseCase; +import org.rdfarchitect.services.schemamigration.artifacts.GenerateMigrationScriptUseCase; +import org.rdfarchitect.services.schemamigration.artifacts.MigrationReportBuilder; +import org.rdfarchitect.services.schemamigration.artifacts.MigrationScriptBuilder; import org.rdfarchitect.services.schemamigration.defaults.DefaultValueAssigner; import org.rdfarchitect.services.schemamigration.defaults.GetDefaultValueViewsUseCase; import org.rdfarchitect.services.schemamigration.defaults.InheritanceChangeHandler; @@ -48,8 +52,6 @@ import org.rdfarchitect.services.schemamigration.renamings.GetPropertyRenamingsUseCase; import org.rdfarchitect.services.schemamigration.renamings.RenameDetector; import org.rdfarchitect.services.schemamigration.renamings.RenameObjectBuilder; -import org.rdfarchitect.services.schemamigration.scriptgeneration.GenerateMigrationScriptUseCase; -import org.rdfarchitect.services.schemamigration.scriptgeneration.MigrationScriptBuilder; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -68,16 +70,20 @@ public class SchemaMigrationService ConfirmPropertyRenamingsUseCase, ClearMigrationContextUseCase, GetDefaultValueViewsUseCase, - SubmitDefaultValuesUseCase { + SubmitDefaultValuesUseCase, + GenerateMigrationReportUseCase, + MigrationChangesUseCase { private final MigrationSessionStore migrationSessionStore; private final DatabasePort databasePort; private final MigrationScriptBuilder migrationScriptBuilder; + private final MigrationReportBuilder migrationReportBuilder; private static final String GRAPH_URI = "http://example.org/graph"; @Override - public void setMigrationContext(MultipartFile originalSchema, GraphIdentifier updatedSchema) { + public void setMigrationContext( + MultipartFile originalSchema, GraphIdentifier updatedSchema, boolean ignorePrefixes) { var originalGraph = new GraphFileSourceBuilderImpl() .setFile(originalSchema) @@ -91,11 +97,12 @@ public void setMigrationContext(MultipartFile originalSchema, GraphIdentifier up updatedGraph = GraphUtils.deepCopy(updatedCtx.getRdfGraph()); } - initContext(originalGraph, updatedGraph); + initContext(originalGraph, updatedGraph, ignorePrefixes); } @Override - public void setMigrationContext(GraphIdentifier originalSchema, GraphIdentifier updatedSchema) { + public void setMigrationContext( + GraphIdentifier originalSchema, GraphIdentifier updatedSchema, boolean ignorePrefixes) { Graph originalGraph; try (var originalCtx = databasePort.getGraphWithContext(originalSchema).begin(ReadWrite.READ)) { @@ -108,11 +115,12 @@ public void setMigrationContext(GraphIdentifier originalSchema, GraphIdentifier updatedGraph = GraphUtils.deepCopy(updatedCtx.getRdfGraph()); } - initContext(originalGraph, updatedGraph); + initContext(originalGraph, updatedGraph, ignorePrefixes); } @Override - public void setMigrationContext(GraphIdentifier originalSchema, MultipartFile updatedSchema) { + public void setMigrationContext( + GraphIdentifier originalSchema, MultipartFile updatedSchema, boolean ignorePrefixes) { Graph originalGraph; try (var originalCtx = databasePort.getGraphWithContext(originalSchema).begin(ReadWrite.READ)) { @@ -126,11 +134,12 @@ public void setMigrationContext(GraphIdentifier originalSchema, MultipartFile up .build() .graph(); - initContext(originalGraph, updatedGraph); + initContext(originalGraph, updatedGraph, ignorePrefixes); } @Override - public void setMigrationContext(MultipartFile originalSchema, MultipartFile updatedSchema) { + public void setMigrationContext( + MultipartFile originalSchema, MultipartFile updatedSchema, boolean ignorePrefixes) { var originalGraph = new GraphFileSourceBuilderImpl() .setFile(originalSchema) @@ -144,14 +153,15 @@ public void setMigrationContext(MultipartFile originalSchema, MultipartFile upda .build() .graph(); - initContext(originalGraph, updatedGraph); + initContext(originalGraph, updatedGraph, ignorePrefixes); } - private void initContext(Graph originalGraph, Graph updatedGraph) { + private void initContext(Graph originalGraph, Graph updatedGraph, boolean ignorePrefixes) { var context = migrationSessionStore.getContext(); context.clear(); context.setOriginalSchema(originalGraph); context.setUpdatedSchema(updatedGraph); + context.setIgnorePrefixes(ignorePrefixes); var tripleChanges = TripleChangeAnalyser.compareGraphsDisregardingPackages(originalGraph, updatedGraph); context.setTripleDiff(tripleChanges); @@ -222,15 +232,15 @@ public List getPropertyRenamings() { new ArrayList<>(migrationSessionStore.getContext().getDiffAfterClassConfirm()); var result = new ArrayList(); for (var cls : classes) { - if (cls.getAttributeRenameCandidates() == null) { + if (cls.getAttributeRenameCandidates().isEmpty()) { cls.setAttributeRenameCandidates( RenameDetector.detectPropertyRenames(cls.getAttributes())); } - if (cls.getAssociationRenameCandidates() == null) { + if (cls.getAssociationRenameCandidates().isEmpty()) { cls.setAssociationRenameCandidates( RenameDetector.detectPropertyRenames(cls.getAssociations())); } - if (cls.getEnumEntryRenameCandidates() == null) { + if (cls.getEnumEntryRenameCandidates().isEmpty()) { cls.setEnumEntryRenameCandidates( RenameDetector.detectPropertyRenames(cls.getEnumEntries())); } @@ -349,12 +359,50 @@ public void submitDefaultValues(List defaultValueViews) { migrationSessionStore.getContext().setDiffAfterDefaultValueConfirm(newChangeList); } + @Override + public List getMigrationChanges() { + return migrationSessionStore.getContext().getDiffAfterDefaultValueConfirm(); + } + + @Override + public void confirmMigrationChanges(List changes) { + migrationSessionStore.getContext().setDiffAfterDefaultValueConfirm(changes); + } + @Override public String generateMigrationScript() { var changes = migrationSessionStore.getContext().getDiffAfterDefaultValueConfirm(); return migrationScriptBuilder.generateMigrationScript(changes); } + @Override + public String generateDetailedMigrationReport() { + var graph = migrationSessionStore.getContext().getOriginalSchema(); + var changes = migrationSessionStore.getContext().getDiffAfterDefaultValueConfirm(); + var ignorePrefixes = migrationSessionStore.getContext().isIgnorePrefixes(); + + var newChangeList = new ArrayList(); + for (var change : changes) { + newChangeList.add(new SemanticClassChange(change)); + } + + return migrationReportBuilder.generateDetailedMigrationReport( + newChangeList, graph, ignorePrefixes); + } + + @Override + public String generateSummaryMigrationReport() { + var changes = migrationSessionStore.getContext().getDiffAfterDefaultValueConfirm(); + boolean ignorePrefixes = migrationSessionStore.getContext().isIgnorePrefixes(); + + var newChangeList = new ArrayList(); + for (var change : changes) { + newChangeList.add(new SemanticClassChange(change)); + } + + return migrationReportBuilder.generateSummaryMigrationReport(newChangeList, ignorePrefixes); + } + @Override public void clearMigrationContext() { migrationSessionStore.clearContext(); diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/SemanticChangeAnalyser.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/SemanticChangeAnalyser.java index 3e0d868a..235253a7 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/SemanticChangeAnalyser.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/SemanticChangeAnalyser.java @@ -69,14 +69,18 @@ private SemanticClassChange getSemanticChangesForClass(TripleClassChange tripleC if (tripleClassChange.getAttributes() != null) { for (var attribute : tripleClassChange.getAttributes()) { - semanticChangeObject.getAttributes().add(getSemanticChangesForAttribute(attribute)); + var attributeChange = getSemanticChangesForAttribute(attribute); + if (attributeChange != null) { + semanticChangeObject.getAttributes().add(attributeChange); + } } } if (tripleClassChange.getAssociations() != null) { for (var association : tripleClassChange.getAssociations()) { - semanticChangeObject - .getAssociations() - .add(getSemanticChangesForAssociation(association)); + var associationChange = getSemanticChangesForAssociation(association); + if (associationChange != null) { + semanticChangeObject.getAssociations().add(associationChange); + } } } if (tripleClassChange.getEnumEntries() != null) { @@ -109,7 +113,12 @@ private SemanticAttributeChange getSemanticChangesForAttribute(TripleResourceCha } } - return semanticChangeObject; + // reformatting of multiplicity can lead to change object with no semantic changes + if (!changes.isEmpty()) { + return semanticChangeObject; + } else { + return null; + } } private SemanticAssociationChange getSemanticChangesForAssociation( @@ -120,7 +129,7 @@ private SemanticAssociationChange getSemanticChangesForAssociation( semanticChangeObject.setSemanticResourceChangeType( getResourceChangeType(associationChanges)); - // parse properties for individual changes + var changes = semanticChangeObject.getChanges(); for (var propertyChange : associationChanges) { var action = new SemanticFieldChange(propertyChange); var mappedType = @@ -128,11 +137,16 @@ private SemanticAssociationChange getSemanticChangesForAssociation( "Association", propertyChange); if (mappedType != null) { action.setSemanticFieldChangeType(mappedType); - semanticChangeObject.getChanges().add(action); + changes.add(action); } } - return semanticChangeObject; + // reformatting of multiplicity can lead to change object with no semantic changes + if (!changes.isEmpty()) { + return semanticChangeObject; + } else { + return null; + } } private SemanticEnumEntryChange getSemanticChangesForEnumEntry(TripleResourceChange enumEntry) { diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/SemanticFieldChangeTypeMapper.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/SemanticFieldChangeTypeMapper.java index b40800f5..4d6d3c41 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/SemanticFieldChangeTypeMapper.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/SemanticFieldChangeTypeMapper.java @@ -84,7 +84,8 @@ private static SemanticFieldChangeType mapAttributePredicateToChangeType( new URI(propertyChange.getTo()).getSuffix()); // only syntactic change in multiplicity if (propertyChange.getFrom() != null - && CIMPropertyUtils.resolveMultiplicity(propertyChange.getFrom()) + && CIMPropertyUtils.resolveMultiplicity( + new URI(propertyChange.getFrom()).getSuffix()) .equals(newMultiplicity)) { return null; } diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/SetMigrationContextUseCase.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/SetMigrationContextUseCase.java index a44f37f3..da262380 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/SetMigrationContextUseCase.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/SetMigrationContextUseCase.java @@ -29,8 +29,10 @@ public interface SetMigrationContextUseCase { * * @param originalSchema the graph identifier of the edited schema loaded in memory * @param updatesSchema the uploaded old schema + * @param ignorePrefixes whether to ignore prefixes in comparison and rename detection */ - void setMigrationContext(MultipartFile originalSchema, GraphIdentifier updatesSchema); + void setMigrationContext( + MultipartFile originalSchema, GraphIdentifier updatesSchema, boolean ignorePrefixes); /** * Sets the migration context for the given graph identifier of an edited schema loaded in @@ -38,22 +40,28 @@ public interface SetMigrationContextUseCase { * * @param originalSchema the graph identifier of the edited schema loaded in memory * @param updatedSchema the graph identifier of the old schema loaded in memory + * @param ignorePrefixes whether to ignore prefixes in comparison and rename detection */ - void setMigrationContext(GraphIdentifier originalSchema, GraphIdentifier updatedSchema); + void setMigrationContext( + GraphIdentifier originalSchema, GraphIdentifier updatedSchema, boolean ignorePrefixes); /** * Sets the migration context for a stored original schema and an uploaded updated schema. * * @param originalSchema the graph identifier of the original schema loaded in memory * @param updatedSchema the uploaded updated schema + * @param ignorePrefixes whether to ignore prefixes in comparison and rename detection */ - void setMigrationContext(GraphIdentifier originalSchema, MultipartFile updatedSchema); + void setMigrationContext( + GraphIdentifier originalSchema, MultipartFile updatedSchema, boolean ignorePrefixes); /** * Sets the migration context for the two uploaded schema files. * * @param originalSchema the graph identifier of the edited schema loaded in memory * @param updatedSchema the graph identifier of the old schema loaded in memory + * @param ignorePrefixes whether to ignore prefixes in comparison and rename detection */ - void setMigrationContext(MultipartFile originalSchema, MultipartFile updatedSchema); + void setMigrationContext( + MultipartFile originalSchema, MultipartFile updatedSchema, boolean ignorePrefixes); } diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/GenerateMigrationReportUseCase.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/GenerateMigrationReportUseCase.java new file mode 100644 index 00000000..bfa8f780 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/GenerateMigrationReportUseCase.java @@ -0,0 +1,24 @@ +/* + * 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.schemamigration.artifacts; + +public interface GenerateMigrationReportUseCase { + String generateDetailedMigrationReport(); + + String generateSummaryMigrationReport(); +} diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/GenerateMigrationScriptUseCase.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/GenerateMigrationScriptUseCase.java similarity index 93% rename from backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/GenerateMigrationScriptUseCase.java rename to backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/GenerateMigrationScriptUseCase.java index e7eae31a..1bf25bfa 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/GenerateMigrationScriptUseCase.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/GenerateMigrationScriptUseCase.java @@ -15,7 +15,7 @@ * */ -package org.rdfarchitect.services.schemamigration.scriptgeneration; +package org.rdfarchitect.services.schemamigration.artifacts; /** Interface for generating the migration actions when migrating between two schemas. */ public interface GenerateMigrationScriptUseCase { diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/MarkdownMigrationReportBuilder.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/MarkdownMigrationReportBuilder.java new file mode 100644 index 00000000..10cc5369 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/MarkdownMigrationReportBuilder.java @@ -0,0 +1,480 @@ +/* + * 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.schemamigration.artifacts; + +import org.apache.jena.graph.Graph; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.shared.PrefixMapping; +import org.apache.jena.shared.impl.PrefixMappingImpl; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.rdfarchitect.config.SchemaConfig; +import org.rdfarchitect.models.changes.semanticchanges.SemanticClassChange; +import org.rdfarchitect.models.changes.semanticchanges.SemanticFieldChange; +import org.rdfarchitect.models.changes.semanticchanges.SemanticResourceChange; +import org.rdfarchitect.models.changes.semanticchanges.SemanticResourceChangeType; +import org.rdfarchitect.models.cim.data.dto.relations.uri.URI; +import org.rdfarchitect.models.cim.rdf.resources.CIMS; +import org.rdfarchitect.models.cim.rdf.resources.CIMStereotypes; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +public class MarkdownMigrationReportBuilder implements MigrationReportBuilder { + + private final PrefixMapping defaultPrefixes; + + public MarkdownMigrationReportBuilder(SchemaConfig schemaConfig) { + this.defaultPrefixes = new PrefixMappingImpl().setNsPrefixes(PrefixMapping.Standard); + schemaConfig.getNamespaces().forEach(this.defaultPrefixes::setNsPrefix); + this.defaultPrefixes.lock(); + } + + @Override + public String generateDetailedMigrationReport( + List classChanges, Graph originalGraph, boolean ignorePrefixes) { + var visibleChanges = ignorePrefixes ? applyPrefixRenameFilter(classChanges) : classChanges; + + var sb = new StringBuilder(); + sb.append("# Migration Report — Detailed View\n\n"); + + appendStatsSummary(sb, visibleChanges); + + sb.append("All affected concrete classes are listed alphabetically. "); + sb.append("Inherited changes are shown under each affected subclass.\n\n"); + + var model = ModelFactory.createModelForGraph(originalGraph); + var concreteClassIRIs = + model.listResourcesWithProperty(RDF.type, RDFS.Class) + .filterKeep(r -> r.hasProperty(CIMS.stereotype, CIMStereotypes.concrete)) + .filterDrop(r -> r.hasProperty(CIMS.stereotype, CIMStereotypes.enumeration)) + .filterDrop(r -> r.hasProperty(CIMS.stereotype, CIMStereotypes.cimDataType)) + .mapWith(Resource::getURI) + .toList(); + + var concreteChanges = + visibleChanges.stream() + .filter(c -> concreteClassIRIs.contains(c.getIri())) + .sorted(Comparator.comparing(SemanticResourceChange::getLabel)) + .toList(); + + for (var classChange : concreteChanges) { + appendClassSection(sb, classChange); + } + + return sb.toString(); + } + + @Override + public String generateSummaryMigrationReport( + List classChanges, boolean ignorePrefixes) { + var visibleChanges = ignorePrefixes ? applyPrefixRenameFilter(classChanges) : classChanges; + + var sb = new StringBuilder(); + sb.append("# Migration Report — Summary View\n\n"); + + appendStatsSummary(sb, visibleChanges); + + sb.append( + "Classes with direct changes. For parent classes, affected concrete subclasses are listed.\n\n"); + + var directlyChanged = + visibleChanges.stream() + .filter(this::hasDirectChange) + .sorted(Comparator.comparing(SemanticResourceChange::getLabel)) + .toList(); + + for (var classChange : directlyChanged) { + sb.append("## ") + .append(formatChangeType(classChange.getSemanticResourceChangeType())) + .append(" ") + .append(classChange.getLabel()) + .append("\n\n"); + sb.append("**IRI:** ").append(shorten(classChange.getIri())).append("\n\n"); + + if (classChange.getComment() != null && !classChange.getComment().isBlank()) { + sb.append("> ").append(classChange.getComment()).append("\n\n"); + } + + appendFieldChangesAsSentences(sb, classChange.getChanges()); + appendDirectProperties(sb, "Attributes", classChange.getAttributes()); + appendDirectProperties(sb, "Associations", classChange.getAssociations()); + appendDirectProperties(sb, "Enum Entries", classChange.getEnumEntries()); + + var affectedSubclasses = findAffectedSubclasses(classChange, classChanges); + if (!affectedSubclasses.isEmpty()) { + sb.append("**Affected concrete classes:**\n\n"); + for (var sub : affectedSubclasses) { + sb.append("- ").append(sub.getLabel()).append("\n"); + } + sb.append("\n"); + } + + sb.append("---\n\n"); + } + + return sb.toString(); + } + + private void appendStatsSummary(StringBuilder sb, List classChanges) { + int added = 0; + int deleted = 0; + int changed = 0; + + for (var change : classChanges) { + switch (change.getSemanticResourceChangeType()) { + case ADD -> added++; + case DELETE -> deleted++; + case CHANGE, RENAME -> changed++; + default -> { + // changes from inheritance should not be included in the stat summary + } + } + } + + sb.append("## Summary\n\n"); + sb.append("| Category | Count |\n"); + sb.append("|----------|------:|\n"); + sb.append("| Added | ").append(added).append(" |\n"); + sb.append("| Deleted | ").append(deleted).append(" |\n"); + sb.append("| Changed | ").append(changed).append(" |\n"); + sb.append("\n---\n\n"); + } + + private void appendClassSection(StringBuilder sb, SemanticClassChange classChange) { + if (classChange.getSemanticResourceChangeType() == SemanticResourceChangeType.RENAME) { + var oldLabel = new URI(classChange.getOldIRI()).getSuffix(); + sb.append("## Renamed from ") + .append(oldLabel) + .append(" to ") + .append(classChange.getLabel()) + .append("\n\n"); + sb.append("**Old IRI:** ").append(shorten(classChange.getOldIRI())).append("\n\n"); + sb.append("**IRI:** ").append(shorten(classChange.getIri())).append("\n\n"); + } else { + sb.append("## ") + .append(formatChangeType(classChange.getSemanticResourceChangeType())) + .append(" ") + .append(classChange.getLabel()) + .append("\n\n"); + sb.append("**IRI:** ").append(shorten(classChange.getIri())).append("\n\n"); + } + + if (classChange.getComment() != null && !classChange.getComment().isBlank()) { + sb.append("> ").append(classChange.getComment()).append("\n\n"); + } + + appendFieldChangesAsSentences(sb, classChange.getChanges()); + appendPropertySection(sb, "Attributes", classChange.getAttributes()); + appendPropertySection(sb, "Associations", classChange.getAssociations()); + appendPropertySection(sb, "Enum Entries", classChange.getEnumEntries()); + sb.append("\n---\n\n"); + } + + private void appendFieldChangesAsSentences( + StringBuilder sb, List changes) { + if (changes == null || changes.isEmpty()) { + return; + } + + for (var change : changes) { + sb.append("- ").append(fieldChangeToSentence(change)).append("\n"); + } + sb.append("\n"); + } + + private String fieldChangeToSentence(SemanticFieldChange change) { + var from = change.getFrom(); + var to = change.getTo(); + + return switch (change.getSemanticFieldChangeType()) { + case LABEL_CHANGE -> formatTransition("Label set", from, to); + case COMMENT_CHANGE -> "Comment was updated."; + case SUPERCLASS_CHANGE -> formatTransition("Superclass set", from, to); + case SUPERCLASS_RENAME -> formatTransition("Superclass renamed", from, to); + case BELONGS_TO_CATEGORY_CHANGE -> formatTransition("Package set", from, to); + case DATATYPE_CHANGE -> formatTransition("Datatype set", from, to); + case DATATYPE_RENAME -> formatTransition("Datatype renamed", from, to); + case MADE_OPTIONAL -> "Was marked optional."; + case MADE_REQUIRED -> "Was marked required."; + case MULTIPLICITY_CHANGE -> formatTransition("Multiplicity set", from, to); + case STEREOTYPE_ADDED -> "Stereotype added: `" + shorten(to) + "`."; + case STEREOTYPE_REMOVED -> "Stereotype removed: " + shorten(from) + "."; + case MADE_ABSTRACT -> "Was made abstract."; + case DOMAIN_CHANGE -> formatTransition("Domain set", from, to); + case DOMAIN_RENAME -> formatTransition("Domain renamed", from, to); + case TARGET_CHANGE -> formatTransition("Target set", from, to); + case ASSOCIATION_USED_CHANGE -> formatTransition("Association used set", from, to); + case DEFAULT_VALUE_CHANGE -> formatTransition("Default value set", from, to); + case FIXED_VALUE_CHANGE -> formatTransition("Fixed value set", from, to); + }; + } + + private String formatTransition(String description, String from, String to) { + var shortFrom = shorten(from); + var shortTo = shorten(to); + if (from == null && to != null) { + return description + " to `" + shortTo + "`."; + } else if (from != null && to == null) { + return description + " (removed `" + shortFrom + "`)."; + } else if (from != null) { + return description + " from `" + shortFrom + "` to `" + shortTo + "`."; + } + return description + "."; + } + + private void appendPropertySection( + StringBuilder sb, String title, List properties) { + if (properties == null || properties.isEmpty()) { + return; + } + + sb.append("### ").append(title).append("\n\n"); + + for (var prop : properties) { + if (prop.getSemanticResourceChangeType() == SemanticResourceChangeType.RENAME + && prop.getOldIRI() != null) { + var oldLabel = new URI(prop.getOldIRI()).getSuffix(); + sb.append("#### Renamed from ") + .append(oldLabel) + .append(" to ") + .append(prop.getLabel()) + .append("\n\n"); + } else { + sb.append("#### ") + .append(formatChangeType(prop.getSemanticResourceChangeType())) + .append(" ") + .append(prop.getLabel()) + .append("\n\n"); + } + + if (prop.getComment() != null && !prop.getComment().isBlank()) { + sb.append("> ").append(prop.getComment()).append("\n\n"); + } + + appendFieldChangesAsSentences(sb, prop.getChanges()); + } + } + + private void appendDirectProperties( + StringBuilder sb, String title, List properties) { + if (properties == null) { + return; + } + + var direct = + properties.stream() + .filter( + p -> + p.getSemanticResourceChangeType() + != SemanticResourceChangeType + .ADDED_FROM_INHERITANCE + && p.getSemanticResourceChangeType() + != SemanticResourceChangeType + .DELETED_FROM_INHERITANCE) + .toList(); + if (direct.isEmpty()) { + return; + } + appendPropertySection(sb, title, direct); + } + + private boolean hasDirectChange(SemanticClassChange c) { + if (!c.getChanges().isEmpty()) { + return true; + } + var allProperties = new ArrayList(c.getAssociations()); + allProperties.addAll(c.getAttributes()); + allProperties.addAll(c.getEnumEntries()); + + if (allProperties.isEmpty()) { + return false; + } + + return allProperties.stream() + .anyMatch( + p -> + p.getSemanticResourceChangeType() + != SemanticResourceChangeType.ADDED_FROM_INHERITANCE + && p.getSemanticResourceChangeType() + != SemanticResourceChangeType + .DELETED_FROM_INHERITANCE); + } + + private List findAffectedSubclasses( + SemanticClassChange parentChange, List allChanges) { + var directPropertyLabels = collectDirectPropertyLabels(parentChange); + return allChanges.stream() + .filter(c -> !c.getIri().equals(parentChange.getIri())) + .filter(c -> hasMatchingInheritedProperty(c, directPropertyLabels)) + .sorted(Comparator.comparing(SemanticResourceChange::getLabel)) + .toList(); + } + + private Set collectDirectPropertyLabels(SemanticClassChange classChange) { + var labels = new HashSet(); + addDirectLabels(labels, classChange.getAttributes()); + addDirectLabels(labels, classChange.getAssociations()); + addDirectLabels(labels, classChange.getEnumEntries()); + return labels; + } + + private void addDirectLabels( + Set labels, List properties) { + if (properties == null) { + return; + } + + properties.stream() + .filter( + p -> + p.getSemanticResourceChangeType() + != SemanticResourceChangeType.ADDED_FROM_INHERITANCE + && p.getSemanticResourceChangeType() + != SemanticResourceChangeType + .DELETED_FROM_INHERITANCE) + .forEach(p -> labels.add(p.getLabel())); + } + + private boolean hasMatchingInheritedProperty( + SemanticClassChange classChange, Set parentPropertyLabels) { + return matchesAny(classChange.getAttributes(), parentPropertyLabels) + || matchesAny(classChange.getAssociations(), parentPropertyLabels) + || matchesAny(classChange.getEnumEntries(), parentPropertyLabels); + } + + private boolean matchesAny( + List properties, Set labels) { + if (properties == null) { + return false; + } + + return properties.stream() + .filter( + p -> + p.getSemanticResourceChangeType() + == SemanticResourceChangeType.ADDED_FROM_INHERITANCE + || p.getSemanticResourceChangeType() + == SemanticResourceChangeType + .DELETED_FROM_INHERITANCE) + .anyMatch(p -> labels.contains(p.getLabel())); + } + + private String formatChangeType(SemanticResourceChangeType type) { + return switch (type) { + case ADD -> "[Added]"; + case DELETE -> "[Deleted]"; + case CHANGE -> "[Changed]"; + case RENAME -> "[Renamed]"; + case ADDED_FROM_INHERITANCE -> "[Added via inheritance"; + case DELETED_FROM_INHERITANCE -> "[Deleted via inheritance"; + }; + } + + private String shorten(String value) { + if (value == null) { + return null; + } + + var shortened = defaultPrefixes.shortForm(value); + if (shortened.equals(value)) { + return value; + } + return shortened; + } + + private List applyPrefixRenameFilter( + List classChanges) { + return classChanges.stream() + .map(this::downgradeIfPrefixOnlyRename) + .filter(this::hasVisibleChanges) + .toList(); + } + + private SemanticClassChange downgradeIfPrefixOnlyRename(SemanticClassChange classChange) { + var result = classChange; + if (isPrefixOnlyRename(classChange)) { + var copy = new SemanticClassChange(classChange); + copy.setSemanticResourceChangeType(SemanticResourceChangeType.CHANGE); + copy.setOldIRI(null); + result = copy; + } + + result.setAttributes(filterProperties(result.getAttributes())); + result.setAssociations(filterProperties(result.getAssociations())); + result.setEnumEntries(filterProperties(result.getEnumEntries())); + + return result; + } + + private List filterProperties(List properties) { + if (properties == null) { + return Collections.emptyList(); + } + + return properties.stream() + .map( + p -> { + if (isPrefixOnlyRename(p)) { + var copy = p.copy(); + copy.setSemanticResourceChangeType( + SemanticResourceChangeType.CHANGE); + copy.setOldIRI(null); + return (T) copy; + } + return p; + }) + .filter( + p -> + p.getSemanticResourceChangeType() + != SemanticResourceChangeType.CHANGE + || (p.getChanges() != null && !p.getChanges().isEmpty())) + .toList(); + } + + private boolean hasVisibleChanges(SemanticClassChange classChange) { + if (classChange.getSemanticResourceChangeType() == SemanticResourceChangeType.CHANGE) { + boolean hasFieldChanges = + classChange.getChanges() != null && !classChange.getChanges().isEmpty(); + boolean hasProperties = + !classChange.getAttributes().isEmpty() + || !classChange.getAssociations().isEmpty() + || !classChange.getEnumEntries().isEmpty(); + return hasFieldChanges || hasProperties; + } + return true; + } + + private boolean isPrefixOnlyRename(SemanticResourceChange change) { + if (change.getSemanticResourceChangeType() != SemanticResourceChangeType.RENAME) { + return false; + } + if (change.getOldIRI() == null || change.getIri() == null) { + return false; + } + + return new URI(change.getOldIRI()).getSuffix().equals(new URI(change.getIri()).getSuffix()); + } +} diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/MigrationReportBuilder.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/MigrationReportBuilder.java new file mode 100644 index 00000000..1235f871 --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/MigrationReportBuilder.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.services.schemamigration.artifacts; + +import org.apache.jena.graph.Graph; +import org.rdfarchitect.models.changes.semanticchanges.SemanticClassChange; + +import java.util.List; + +public interface MigrationReportBuilder { + String generateDetailedMigrationReport( + List classChanges, Graph originalGraph, boolean ignorePrefixes); + + String generateSummaryMigrationReport( + List classChanges, boolean ignorePrefixes); +} diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/MigrationScriptBuilder.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/MigrationScriptBuilder.java similarity index 92% rename from backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/MigrationScriptBuilder.java rename to backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/MigrationScriptBuilder.java index fa7350c6..7fd638fa 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/MigrationScriptBuilder.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/MigrationScriptBuilder.java @@ -15,7 +15,7 @@ * */ -package org.rdfarchitect.services.schemamigration.scriptgeneration; +package org.rdfarchitect.services.schemamigration.artifacts; import org.rdfarchitect.models.changes.semanticchanges.SemanticClassChange; diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlMigrationBuilder.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/SparqlMigrationBuilder.java similarity index 73% rename from backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlMigrationBuilder.java rename to backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/SparqlMigrationBuilder.java index bbd46748..84eb9836 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlMigrationBuilder.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/SparqlMigrationBuilder.java @@ -15,16 +15,16 @@ * */ -package org.rdfarchitect.services.schemamigration.scriptgeneration; +package org.rdfarchitect.services.schemamigration.artifacts; import lombok.RequiredArgsConstructor; -import org.apache.jena.update.UpdateRequest; import org.rdfarchitect.models.changes.semanticchanges.SemanticAssociationChange; import org.rdfarchitect.models.changes.semanticchanges.SemanticAttributeChange; import org.rdfarchitect.models.changes.semanticchanges.SemanticClassChange; import org.rdfarchitect.models.changes.semanticchanges.SemanticEnumEntryChange; import org.rdfarchitect.models.changes.semanticchanges.SemanticFieldChangeType; +import org.rdfarchitect.models.changes.semanticchanges.SemanticResourceChange; import org.rdfarchitect.models.changes.semanticchanges.SemanticResourceChangeType; import org.springframework.stereotype.Service; @@ -38,15 +38,20 @@ public class SparqlMigrationBuilder implements MigrationScriptBuilder { @Override public String generateMigrationScript(List classChanges) { - var script = new UpdateRequest(); + var sb = new StringBuilder(); for (var classChange : classChanges) { - script.add(generateUpdateForClass(classChange)); + var classBlock = generateUpdateForClass(classChange); + if (!classBlock.isBlank()) { + sb.append(classBlock); + } } - return script.toString(); + return sb.toString(); } private String generateUpdateForClass(SemanticClassChange classChange) { - var result = new UpdateRequest(); + var sb = new StringBuilder(); + + appendComment(sb, classChange); String update = switch (classChange.getSemanticResourceChangeType()) { @@ -55,47 +60,57 @@ private String generateUpdateForClass(SemanticClassChange classChange) { default -> null; }; - if (update != null) { - result.add(update); + if (update != null && !update.isBlank()) { + sb.append(update).append("\n"); } if (classChange.getSemanticResourceChangeType() != SemanticResourceChangeType.DELETE && classChange.getSemanticResourceChangeType() != SemanticResourceChangeType.ADD) { - processClassChanges(classChange, result); + processClassChanges(classChange, sb); } - processPropertyChanges(classChange, result); + processPropertyChanges(classChange, sb); - return result.toString(); + return sb.toString(); } - private void processClassChanges(SemanticClassChange classChange, UpdateRequest result) { + private void processClassChanges(SemanticClassChange classChange, StringBuilder sb) { for (var change : classChange.getChanges()) { if (change.getSemanticFieldChangeType() == SemanticFieldChangeType.MADE_ABSTRACT) { - result.add(updateGenerator.generateDeleteClassUpdate(classChange)); + sb.append(updateGenerator.generateDeleteClassUpdate(classChange)).append("\n"); } } } - private void processPropertyChanges(SemanticClassChange classChange, UpdateRequest result) { + private void processPropertyChanges(SemanticClassChange classChange, StringBuilder sb) { for (var attribute : classChange.getAttributes()) { - result.add(generateUpdateForAttribute(attribute, classChange.getIri())); + var block = generateUpdateForAttribute(attribute, classChange.getIri()); + if (!block.isBlank()) { + sb.append(block); + } } for (var association : classChange.getAssociations()) { - result.add(generateUpdateForAssociation(association)); + var block = generateUpdateForAssociation(association); + if (!block.isBlank()) { + sb.append(block); + } } if (classChange.getSemanticResourceChangeType() != SemanticResourceChangeType.DELETE) { for (var enumEntry : classChange.getEnumEntries()) { - result.add(generateUpdateForEnumEntry(enumEntry)); + var block = generateUpdateForEnumEntry(enumEntry); + if (!block.isBlank()) { + sb.append(block); + } } } } private String generateUpdateForAttribute( SemanticAttributeChange attributeChange, String classIri) { - var result = new UpdateRequest(); + var sb = new StringBuilder(); + appendComment(sb, attributeChange); var update = switch (attributeChange.getSemanticResourceChangeType()) { @@ -112,21 +127,21 @@ private String generateUpdateForAttribute( default -> null; }; - if (update != null) { - result.add(update); + if (update != null && !update.isBlank()) { + sb.append(update).append("\n"); } var type = attributeChange.getSemanticResourceChangeType(); if (type == SemanticResourceChangeType.CHANGE || type == SemanticResourceChangeType.RENAME) { - processAttributeFieldChanges(attributeChange, classIri, result); + processAttributeFieldChanges(attributeChange, classIri, sb); } - return result.toString(); + return sb.toString(); } private void processAttributeFieldChanges( - SemanticAttributeChange attributeChange, String classIri, UpdateRequest result) { + SemanticAttributeChange attributeChange, String classIri, StringBuilder sb) { for (var change : attributeChange.getChanges()) { var update = switch (change.getSemanticFieldChangeType()) { @@ -142,14 +157,15 @@ private void processAttributeFieldChanges( default -> null; }; - if (update != null) { - result.add(update); + if (update != null && !update.isBlank()) { + sb.append(update).append("\n"); } } } private String generateUpdateForEnumEntry(SemanticEnumEntryChange enumEntryChange) { - var result = new UpdateRequest(); + var sb = new StringBuilder(); + appendComment(sb, enumEntryChange); var update = switch (enumEntryChange.getSemanticResourceChangeType()) { @@ -158,15 +174,16 @@ private String generateUpdateForEnumEntry(SemanticEnumEntryChange enumEntryChang default -> null; }; - if (update != null) { - result.add(update); + if (update != null && !update.isBlank()) { + sb.append(update).append("\n"); } - return result.toString(); + return sb.toString(); } private String generateUpdateForAssociation(SemanticAssociationChange associationChange) { - var result = new UpdateRequest(); + var sb = new StringBuilder(); + appendComment(sb, associationChange); var update = switch (associationChange.getSemanticResourceChangeType()) { @@ -180,21 +197,21 @@ private String generateUpdateForAssociation(SemanticAssociationChange associatio default -> null; }; - if (update != null) { - result.add(update); + if (update != null && !update.isBlank()) { + sb.append(update).append("\n"); } var type = associationChange.getSemanticResourceChangeType(); if (type == SemanticResourceChangeType.CHANGE || type == SemanticResourceChangeType.RENAME) { - processAssociationFieldChanges(associationChange, result); + processAssociationFieldChanges(associationChange, sb); } - return result.toString(); + return sb.toString(); } private void processAssociationFieldChanges( - SemanticAssociationChange associationChange, UpdateRequest result) { + SemanticAssociationChange associationChange, StringBuilder sb) { for (var change : associationChange.getChanges()) { var update = switch (change.getSemanticFieldChangeType()) { @@ -208,8 +225,16 @@ private void processAssociationFieldChanges( default -> null; }; - if (update != null) { - result.add(update); + if (update != null && !update.isBlank()) { + sb.append(update).append("\n"); + } + } + } + + private void appendComment(StringBuilder sb, SemanticResourceChange resourceChange) { + if (resourceChange.getComment() != null && !resourceChange.getComment().isBlank()) { + for (var line : resourceChange.getComment().split("\\R")) { + sb.append("# ").append(line).append("\n"); } } } diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlUpdateGenerator.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/SparqlUpdateGenerator.java similarity index 99% rename from backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlUpdateGenerator.java rename to backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/SparqlUpdateGenerator.java index bca9e04f..c398be5b 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlUpdateGenerator.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/artifacts/SparqlUpdateGenerator.java @@ -15,7 +15,7 @@ * */ -package org.rdfarchitect.services.schemamigration.scriptgeneration; +package org.rdfarchitect.services.schemamigration.artifacts; import lombok.RequiredArgsConstructor; diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/defaults/InheritanceChangeHandler.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/defaults/InheritanceChangeHandler.java index bcddb55f..4ffe0ece 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/defaults/InheritanceChangeHandler.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/defaults/InheritanceChangeHandler.java @@ -241,13 +241,11 @@ private void addNewInheritedProperties( property, SemanticResourceChangeType.ADDED_FROM_INHERITANCE); propertyChange.setLabel(new URI(property.getURI()).getSuffix()); if (CIMPropertyUtils.isAttribute(property)) { - derivingClassChange - .getAttributes() - .add(new SemanticAttributeChange(propertyChange)); + var attributeChange = new SemanticAttributeChange(propertyChange); + derivingClassChange.getAttributes().add(attributeChange); } else if (CIMPropertyUtils.isAssociation(property)) { - derivingClassChange - .getAssociations() - .add(new SemanticAssociationChange(propertyChange)); + var associationChange = new SemanticAssociationChange(propertyChange); + derivingClassChange.getAssociations().add(associationChange); } } } diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/RenameDetector.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/RenameDetector.java index e4bbe6a3..6c4b1e3e 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/RenameDetector.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/RenameDetector.java @@ -80,11 +80,17 @@ private List> matchBySimil Set unmatchedAdded = new HashSet<>(added); for (T deletedItem : deleted) { - double bestScore = 0.0; + var bestScore = 0.; T bestMatch = null; for (T newItem : unmatchedAdded) { - double score = SimilarityCalculator.calculateSimilarity(newItem, deletedItem); + double score; + if (newItem.getLabel().equals(deletedItem.getLabel())) { + score = 1.; + } else { + score = SimilarityCalculator.calculateSimilarity(newItem, deletedItem); + } + if (score > bestScore) { bestScore = score; bestMatch = newItem; diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/RenameObjectBuilder.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/RenameObjectBuilder.java index 4ba636bf..0c7dd82c 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/RenameObjectBuilder.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/RenameObjectBuilder.java @@ -163,6 +163,18 @@ private SemanticFieldChange findMatchingChange( .orElse(null); } + if (target.getSemanticFieldChangeType() == SemanticFieldChangeType.STEREOTYPE_REMOVED + || target.getSemanticFieldChangeType() == SemanticFieldChangeType.MADE_ABSTRACT) { + return changes.stream() + .filter( + c -> + c.getSemanticFieldChangeType() + == SemanticFieldChangeType.STEREOTYPE_ADDED) + .filter(c -> Objects.equals(c.getTo(), target.getFrom())) + .findFirst() + .orElse(null); + } + return changes.stream() .filter( change -> diff --git a/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/SimilarityCalculator.java b/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/SimilarityCalculator.java index a9ab130b..11a3cff6 100644 --- a/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/SimilarityCalculator.java +++ b/backend/src/main/java/org/rdfarchitect/services/schemamigration/renamings/SimilarityCalculator.java @@ -27,6 +27,8 @@ import org.rdfarchitect.models.changes.semanticchanges.SemanticFieldChange; import org.rdfarchitect.models.changes.semanticchanges.SemanticFieldChangeType; import org.rdfarchitect.models.changes.semanticchanges.SemanticResourceChange; +import org.rdfarchitect.models.cim.data.dto.relations.uri.URI; +import org.rdfarchitect.models.cim.relations.model.properties.CIMPropertyUtils; import java.util.ArrayList; import java.util.HashMap; @@ -192,7 +194,18 @@ private double compareFieldValues( getValueForChangeType(added, changeType) .map(SemanticFieldChange::getTo) .orElse(null); - return Objects.equals(deletedValue, addedValue) ? 1.0 : 0.0; + + if (changeType == SemanticFieldChangeType.MULTIPLICITY_CHANGE + && deletedValue != null + && addedValue != null) { + var oldMultiplicity = + CIMPropertyUtils.resolveMultiplicity(new URI(deletedValue).getSuffix()); + var newMultiplicity = + CIMPropertyUtils.resolveMultiplicity(new URI(addedValue).getSuffix()); + return Objects.equals(oldMultiplicity, newMultiplicity) ? 1.0 : 0.0; + } else { + return Objects.equals(deletedValue, addedValue) ? 1.0 : 0.0; + } } private Optional getValueForChangeType( diff --git a/backend/src/test/java/org/rdfarchitect/services/schemamigration/renamings/SimilarityCalculatorTest.java b/backend/src/test/java/org/rdfarchitect/services/schemamigration/renamings/SimilarityCalculatorTest.java index ca678a02..7193ceca 100644 --- a/backend/src/test/java/org/rdfarchitect/services/schemamigration/renamings/SimilarityCalculatorTest.java +++ b/backend/src/test/java/org/rdfarchitect/services/schemamigration/renamings/SimilarityCalculatorTest.java @@ -26,6 +26,7 @@ import org.rdfarchitect.models.changes.semanticchanges.SemanticClassChange; import org.rdfarchitect.models.changes.semanticchanges.SemanticFieldChangeType; import org.rdfarchitect.models.changes.semanticchanges.SemanticResourceChangeType; +import org.rdfarchitect.models.cim.rdf.resources.CIMS; import org.rdfarchitect.services.schemamigration.ChangeObjectTestBuilder; import java.util.List; @@ -344,8 +345,8 @@ void calculateSimilarity_attributesWithSameMultiplicity_contributesToScore() { List.of( ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.MULTIPLICITY_CHANGE, - "0..1", - "0..1"))) + CIMS.namespace + "M:0..1", + CIMS.namespace + "M:0..1"))) .build(); var added = @@ -356,8 +357,8 @@ void calculateSimilarity_attributesWithSameMultiplicity_contributesToScore() { List.of( ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.MULTIPLICITY_CHANGE, - "0..1", - "0..1"))) + CIMS.namespace + "M:0..1", + CIMS.namespace + "M:0..1"))) .build(); var similarity = SimilarityCalculator.calculateSimilarity(added, deleted); @@ -410,12 +411,12 @@ void calculateSimilarity_attributesWithAllMatchingProperties_returnsVeryHighScor "Float"), ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.MULTIPLICITY_CHANGE, - "1..1", - "1..1"), + CIMS.namespace + "M:1..1", + CIMS.namespace + "M:1..1"), ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.DEFAULT_VALUE_CHANGE, - "0.0", - "0.0"))) + CIMS.namespace + "M:0.0", + CIMS.namespace + "M:0.0"))) .build(); var added = @@ -430,12 +431,12 @@ void calculateSimilarity_attributesWithAllMatchingProperties_returnsVeryHighScor "Float"), ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.MULTIPLICITY_CHANGE, - "1..1", - "1..1"), + CIMS.namespace + "M:1..1", + CIMS.namespace + "M:1..1"), ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.DEFAULT_VALUE_CHANGE, - "0.0", - "0.0"))) + CIMS.namespace + "M:0.0", + CIMS.namespace + "M:0.0"))) .build(); var similarity = SimilarityCalculator.calculateSimilarity(added, deleted); @@ -519,8 +520,8 @@ void calculateSimilarity_associationsWithSameMultiplicity_contributesToScore() { List.of( ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.MULTIPLICITY_CHANGE, - "0..*", - "0..*"))) + CIMS.namespace + "M:0..*", + CIMS.namespace + "M:0..*"))) .build(); var added = @@ -531,8 +532,8 @@ void calculateSimilarity_associationsWithSameMultiplicity_contributesToScore() { List.of( ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.MULTIPLICITY_CHANGE, - "0..*", - "0..*"))) + CIMS.namespace + "M:0..*", + CIMS.namespace + "M:0..*"))) .build(); var similarity = SimilarityCalculator.calculateSimilarity(added, deleted); @@ -554,8 +555,8 @@ void calculateSimilarity_associationsWithAllMatchingProperties_returnsVeryHighSc "Equipment"), ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.MULTIPLICITY_CHANGE, - "0..*", - "0..*"), + CIMS.namespace + "M:0..*", + CIMS.namespace + "M:0..*"), ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.ASSOCIATION_USED_CHANGE, "true", @@ -574,8 +575,8 @@ void calculateSimilarity_associationsWithAllMatchingProperties_returnsVeryHighSc "Equipment"), ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.MULTIPLICITY_CHANGE, - "0..*", - "0..*"), + CIMS.namespace + "M:0..*", + CIMS.namespace + "M:0..*"), ChangeObjectTestBuilder.fieldChange( SemanticFieldChangeType.ASSOCIATION_USED_CHANGE, "true", diff --git a/backend/src/test/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlUpdateGeneratorTest.java b/backend/src/test/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlUpdateGeneratorTest.java index 1e55e9ad..d410a8f6 100644 --- a/backend/src/test/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlUpdateGeneratorTest.java +++ b/backend/src/test/java/org/rdfarchitect/services/schemamigration/scriptgeneration/SparqlUpdateGeneratorTest.java @@ -36,6 +36,7 @@ import org.rdfarchitect.models.changes.semanticchanges.SemanticAttributeChange; import org.rdfarchitect.models.cim.rdf.resources.CIMS; import org.rdfarchitect.models.cim.rdf.resources.CIMStereotypes; +import org.rdfarchitect.services.schemamigration.artifacts.SparqlUpdateGenerator; @ExtendWith(MockitoExtension.class) class SparqlUpdateGeneratorTest { @@ -70,8 +71,9 @@ void generateAddAttributeUpdate_concreteClassWithSubclass_updatesClassAndAllSubc generator.generateAddAttributeUpdate(attributeChange, PREFIX + "DiagramObject"); // The declaring class itself must be migrated, not only its deriving subclass. - assertThat(script).contains(PREFIX + "DiagramObject>"); - assertThat(script).contains(PREFIX + "TextDiagramObject>"); + assertThat(script) + .contains(PREFIX + "DiagramObject>") + .contains(PREFIX + "TextDiagramObject>"); } @Test @@ -86,8 +88,7 @@ void generateAddAttributeUpdate_leafConcreteClass_updatesThatClass() { var script = generator.generateAddAttributeUpdate(attributeChange, PREFIX + "TextDiagramObject"); - assertThat(script).isNotBlank(); - assertThat(script).contains(PREFIX + "TextDiagramObject>"); + assertThat(script).isNotBlank().contains(PREFIX + "TextDiagramObject>"); } @Test @@ -103,8 +104,9 @@ void generateAddAttributeUpdate_abstractClass_onlyUpdatesConcreteSubclasses() { var script = generator.generateAddAttributeUpdate(attributeChange, PREFIX + "DiagramObject"); - assertThat(script).contains(PREFIX + "TextDiagramObject>"); - assertThat(script).doesNotContain(PREFIX + "DiagramObject>"); + assertThat(script) + .contains(PREFIX + "TextDiagramObject>") + .doesNotContain(PREFIX + "DiagramObject>"); } @Test diff --git a/frontend/src/lib/api/backend.js b/frontend/src/lib/api/backend.js index 69555506..a256d813 100644 --- a/frontend/src/lib/api/backend.js +++ b/frontend/src/lib/api/backend.js @@ -741,4 +741,32 @@ export class BackendConnection { credentials: "include", }); } + + async getChanges() { + const url = `${PUBLIC_BACKEND_URL}/migrations/changes`; + return await fetch(url, { + method: "GET", + credentials: "include", + headers: new Headers({ "Content-Type": "application/json" }), + }); + } + + async confirmChanges(classes) { + const url = `${PUBLIC_BACKEND_URL}/migrations/changes`; + return await fetch(url, { + method: "POST", + credentials: "include", + headers: new Headers({ "Content-Type": "application/json" }), + body: JSON.stringify(classes), + }); + } + + async fetchReport(reportType) { + const params = new URLSearchParams({ reportType: reportType }); + return fetch(PUBLIC_BACKEND_URL + `/migrations/report?${params}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }); + } } diff --git a/frontend/src/lib/sharedState.svelte.js b/frontend/src/lib/sharedState.svelte.js index a9bafb40..a41d6562 100644 --- a/frontend/src/lib/sharedState.svelte.js +++ b/frontend/src/lib/sharedState.svelte.js @@ -255,4 +255,5 @@ export const migrationState = writable({ fileB: null, cgmesVersionA: null, cgmesVersionB: null, + ignorePrefixes: false, }); diff --git a/frontend/src/lib/utils/migrationUtils.js b/frontend/src/lib/utils/migrationUtils.js new file mode 100644 index 00000000..ce50fe95 --- /dev/null +++ b/frontend/src/lib/utils/migrationUtils.js @@ -0,0 +1,27 @@ +/* + * 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. + * + */ + +import { URI } from "$lib/models/dto/index.ts"; + +export function isPrefixOnlyRename(oldIRI, newIRI) { + if (!newIRI || !oldIRI) { + return false; + } + const oldName = new URI(oldIRI).suffix; + const newName = new URI(newIRI).suffix; + return oldName === newName; +} diff --git a/frontend/src/routes/migrate/+page.svelte b/frontend/src/routes/migrate/+page.svelte index e229ecd9..8350a4ab 100644 --- a/frontend/src/routes/migrate/+page.svelte +++ b/frontend/src/routes/migrate/+page.svelte @@ -32,6 +32,7 @@ import ConfirmAssociationRenames from "./steps/propertyrenames/ConfirmAssociationRenames.svelte"; import ConfirmAttributeRenames from "./steps/propertyrenames/ConfirmAttributeRenames.svelte"; import ConfirmEnumEntryRenames from "./steps/propertyrenames/ConfirmEnumEntryRenames.svelte"; + import ReviewChanges from "./steps/ReviewChanges.svelte"; import SelectSchemas from "./steps/SelectSchemas.svelte"; import ValidateSchemas from "./steps/ValidateSchemas.svelte"; @@ -72,7 +73,8 @@ }, ], }, - { title: "Step 6: Generate Script", component: Export }, + { title: "Step 6: Review Changes", component: ReviewChanges }, + { title: "Step 7: Generate Artifacts", component: Export }, ]; let disableNext = $state(false); @@ -136,7 +138,7 @@
{#each steps as step, i} {step.title} diff --git a/frontend/src/routes/migrate/steps/ConfirmClassRenames.svelte b/frontend/src/routes/migrate/steps/ConfirmClassRenames.svelte index e93b4d2a..6439f83d 100644 --- a/frontend/src/routes/migrate/steps/ConfirmClassRenames.svelte +++ b/frontend/src/routes/migrate/steps/ConfirmClassRenames.svelte @@ -17,26 +17,32 @@
+ + In this step you can choose which artifacts you want to generate for the + migration. You can generate a migration package containing the SPARQL + updates for automatically migrating your data as well as SHACL shapes + for validating your data against the new schema. You can also generate a + markdown file based on the semantic changes configured during this + migration process. + Please note that the script generation might not be able to handle all edge cases yet, one such case being multiplicity changes on @@ -61,17 +87,22 @@
-
-

Migration Script

-

- The migration script contains SPARQL UPDATE queries that will - transform your data from the source schema to the target schema - based on the mappings and defaults you've configured. -

+
+ +
Generate and download script + + Generate and download report +
diff --git a/frontend/src/routes/migrate/steps/ResourceChangeCard.svelte b/frontend/src/routes/migrate/steps/ResourceChangeCard.svelte new file mode 100644 index 00000000..5f4fa275 --- /dev/null +++ b/frontend/src/routes/migrate/steps/ResourceChangeCard.svelte @@ -0,0 +1,184 @@ + + + + +
+
+
+ {#if title} + {title} + {/if} +

+ {resource.label ?? resource.iri} +

+
+ + {RESOURCE_CHANGE_LABELS[resource.semanticResourceChangeType] ?? + resource.semanticResourceChangeType ?? + "—"} + +
+ + {#if ignorePrefixes && resource.oldIRI && newLabel !== oldLabel} +
+ + {ignorePrefixes ? oldLabel : resource.oldIRI} + + + + {ignorePrefixes ? newLabel : resource.iri} + +
+ {/if} + + {#if resource.changes && resource.changes.length > 0 && resource.semanticResourceChangeType !== "DELETE"} +
+ Changes + {#if expanded} + + {:else} + + {/if} +
+ {#if expanded} +
    + {#each resource.changes as change} +
  • + + {getChangeLabel()} + +
    + + {change.from ?? "—"} + + + + {change.to ?? "—"} + +
    +
  • + {/each} +
+ {/if} + {/if} + +
+
Comment
+ +
+
diff --git a/frontend/src/routes/migrate/steps/ReviewChanges.svelte b/frontend/src/routes/migrate/steps/ReviewChanges.svelte new file mode 100644 index 00000000..a151efea --- /dev/null +++ b/frontend/src/routes/migrate/steps/ReviewChanges.svelte @@ -0,0 +1,161 @@ + + + + +
+ + This step lets you get an overview over all changes made in the schema + update and lets you assign comments to those changes. These comments + will be inserted in the migration script and the migration report you + can download in the next step. + + + {#if isLoading} +

Loading changes…

+ {:else if classes.length === 0} + + {:else} + {#each classes as cls, i} +
+ + + {#if cls.attributes?.length > 0} +
+

+ Attributes +

+ {#each cls.attributes as attribute (attribute.iri)} + {#if attribute.semanticResourceChangeType !== "DELETED_FROM_INHERITANCE" && attribute.semanticResourceChangeType !== "ADDED_FROM_INHERITANCE"} + + {/if} + {/each} +
+ {/if} + + {#if cls.associations?.length > 0} +
+

+ Associations +

+ {#each cls.associations as association (association.iri)} + {#if association.semanticResourceChangeType !== "DELETED_FROM_INHERITANCE" && association.semanticResourceChangeType !== "ADDED_FROM_INHERITANCE"} + + {/if} + {/each} +
+ {/if} + + {#if cls.enumEntries?.length > 0} +
+

+ Enum Entries +

+ {#each cls.enumEntries as enumEntry (enumEntry.iri)} + + {/each} +
+ {/if} +
+ {/each} + {/if} +
diff --git a/frontend/src/routes/migrate/steps/SelectSchemas.svelte b/frontend/src/routes/migrate/steps/SelectSchemas.svelte index ea5aae21..e18aa4a3 100644 --- a/frontend/src/routes/migrate/steps/SelectSchemas.svelte +++ b/frontend/src/routes/migrate/steps/SelectSchemas.svelte @@ -21,6 +21,7 @@ import { get } from "svelte/store"; import { Fa } from "svelte-fa"; + import CheckBoxEditControl from "$lib/components/CheckBoxEditControl.svelte"; import DatasetAndGraphSelection from "$lib/components/DatasetAndGraphSelection.svelte"; import FileSelectButton from "$lib/components/FileSelectButton.svelte"; import InfoBox from "$lib/components/InfoBox.svelte"; @@ -40,6 +41,7 @@ }); let compareMode = $state(CompareMode.FILE_TO_STORED); + let ignorePrefixes = $state(false); let cgmesVersionA = $state(CGMESVersion.V3_0); let cgmesVersionB = $state(CGMESVersion.V3_0); @@ -110,6 +112,7 @@ graphB = storedState.graphB; fileA = storedState.fileA; fileB = storedState.fileB; + ignorePrefixes = storedState.ignorePrefixes ?? false; }); function onCompareModeChange() { @@ -165,6 +168,8 @@ body.append("fileB", fileB); } + body.append("ignorePrefixes", ignorePrefixes); + let url = `${PUBLIC_BACKEND_URL}/migrations/context`; try { let res = await fetch(url, { @@ -184,6 +189,7 @@ graphB, fileA, fileB, + ignorePrefixes, }); } else { toastStore.error( @@ -210,10 +216,18 @@ system, or you can upload one or two files containing the schema(s) you want to migrate.

+

+ You can toggle the flag "ignore prefixes" which cause the rename + detection to only consider labels and not the full IRI. Renames due + to a prefix change will therefore be shown as a simple change in the + UI. +

-
+
@@ -225,6 +239,15 @@ getOptionLabel={o => o.label} onchange={onCompareModeChange} /> +
+ + +
{#if compareMode === CompareMode.STORED_TO_STORED} diff --git a/frontend/src/routes/migrate/steps/propertyrenames/ConfirmAssociationRenames.svelte b/frontend/src/routes/migrate/steps/propertyrenames/ConfirmAssociationRenames.svelte index bec87a4d..7b7ae1d1 100644 --- a/frontend/src/routes/migrate/steps/propertyrenames/ConfirmAssociationRenames.svelte +++ b/frontend/src/routes/migrate/steps/propertyrenames/ConfirmAssociationRenames.svelte @@ -90,7 +90,6 @@ return ( cls.associations && cls.associations.added.length + - cls.associations.modified.length + cls.associations.deletedAndRenamed.length > 0 ); @@ -179,21 +178,6 @@
{/if} - - {#if cls.associations.modified.length > 0} -
-

- Modified Associations -

-
    - {#each cls.associations.modified as modifiedAssociation} -
  • {modifiedAssociation.label}
  • - {/each} -
-
- {/if}
diff --git a/frontend/src/routes/migrate/steps/propertyrenames/ConfirmAttributeRenames.svelte b/frontend/src/routes/migrate/steps/propertyrenames/ConfirmAttributeRenames.svelte index 99c61f4e..b27eff5d 100644 --- a/frontend/src/routes/migrate/steps/propertyrenames/ConfirmAttributeRenames.svelte +++ b/frontend/src/routes/migrate/steps/propertyrenames/ConfirmAttributeRenames.svelte @@ -89,7 +89,6 @@ return ( cls.attributes && cls.attributes.added.length + - cls.attributes.modified.length + cls.attributes.deletedAndRenamed.length > 0 ); @@ -180,21 +179,6 @@
{/if} - - {#if cls.attributes.modified.length > 0} -
-

- Modified Attributes -

-
    - {#each cls.attributes.modified as modifiedAttribute} -
  • {modifiedAttribute.label}
  • - {/each} -
-
- {/if} diff --git a/frontend/src/routes/migrate/steps/propertyrenames/ConfirmEnumEntryRenames.svelte b/frontend/src/routes/migrate/steps/propertyrenames/ConfirmEnumEntryRenames.svelte index 4ede6dfa..e8c56968 100644 --- a/frontend/src/routes/migrate/steps/propertyrenames/ConfirmEnumEntryRenames.svelte +++ b/frontend/src/routes/migrate/steps/propertyrenames/ConfirmEnumEntryRenames.svelte @@ -90,7 +90,6 @@ return ( cls.enumEntries && cls.enumEntries.added.length + - cls.enumEntries.modified.length + cls.enumEntries.deletedAndRenamed.length > 0 ); @@ -179,21 +178,6 @@ {/if} - - {#if cls.enumEntries.modified.length > 0} -
-

- Modified Enum Entries -

-
    - {#each cls.enumEntries.modified as modifiedEnumEntry} -
  • {modifiedEnumEntry.label}
  • - {/each} -
-
- {/if}