diff --git a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/shacl/generate/SHACLGenerateContentRestController.java b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/shacl/generate/SHACLGenerateContentRestController.java index 1bfe5ab9..d2034ea5 100644 --- a/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/shacl/generate/SHACLGenerateContentRestController.java +++ b/backend/src/main/java/org/rdfarchitect/api/controller/datasets/graphs/shacl/generate/SHACLGenerateContentRestController.java @@ -28,6 +28,7 @@ import org.apache.jena.riot.system.PrefixEntry; import org.rdfarchitect.database.GraphIdentifier; import org.rdfarchitect.models.cim.data.dto.relations.uri.URI; +import org.rdfarchitect.models.cim.rdf.resources.RDFA; import org.rdfarchitect.services.ExpandURIUseCase; import org.rdfarchitect.services.shacl.SHACLExportUseCase; import org.rdfarchitect.services.shacl.SHACLGenerateUseCase; @@ -90,7 +91,7 @@ public String getGeneratedSHACLAsString( var result = shaclGenerateUseCase.exportGeneratedSHACLGraph( new GraphIdentifier(datasetName, extendedGraphURI), - PrefixEntry.create("rdfash", "http://www.example.com/shacl#")); + PrefixEntry.create(RDFA.NS_PREFIX_SHACL, RDFA.NS_URI_SHACL)); logger.info( "Sending response to GET request: \"/api/datasets/{{}}/graphs/{{}}/shacl/generate/string\" to \"{}\".", diff --git a/backend/src/main/java/org/rdfarchitect/rdf/graph/DeltaCompressible.java b/backend/src/main/java/org/rdfarchitect/rdf/graph/DeltaCompressible.java index 5e97f121..7527802c 100644 --- a/backend/src/main/java/org/rdfarchitect/rdf/graph/DeltaCompressible.java +++ b/backend/src/main/java/org/rdfarchitect/rdf/graph/DeltaCompressible.java @@ -25,6 +25,7 @@ import org.apache.jena.graph.Triple; import org.apache.jena.graph.compose.CompositionBase; import org.apache.jena.graph.impl.SimpleEventManager; +import org.apache.jena.shared.PrefixMapping; import org.apache.jena.sparql.graph.GraphFactory; import org.apache.jena.util.iterator.ExtendedIterator; import org.jetbrains.annotations.NotNull; @@ -49,6 +50,12 @@ public class DeltaCompressible extends CompositionBase { /** -- GETTER -- Answer the graph of all triples removed. */ @Getter private Graph deletions; + /** + * Records prefix changes as a delta over the base graph's prefix mapping, mirroring the way + * {@link #additions} and {@link #deletions} record triple changes. + */ + private DeltaPrefixMapping prefixMapping; + @Getter private final UUID versionId = UUID.randomUUID(); public DeltaCompressible(@NotNull Graph base) { @@ -56,14 +63,18 @@ public DeltaCompressible(@NotNull Graph base) { this.base = base; this.additions = GraphMemFactory.createDefaultGraph(); this.deletions = GraphMemFactory.createDefaultGraph(); + this.prefixMapping = new DeltaPrefixMapping(base.getPrefixMapping()); } public void compress() { var newBase = GraphFactory.createDefaultGraph(); GraphUtil.add(newBase, this.find()); + // Fold the prefix delta into the new base, then start a fresh (empty) prefix delta. + newBase.getPrefixMapping().setNsPrefixes(getPrefixMapping().getNsPrefixMap()); base = newBase; additions = GraphMemFactory.createDefaultGraph(); deletions = GraphMemFactory.createDefaultGraph(); + prefixMapping = new DeltaPrefixMapping(base.getPrefixMapping()); } /** Add the triple to the graph, ie add it to the additions, remove it from the removals. */ @@ -95,6 +106,22 @@ protected ExtendedIterator graphBaseFind(Triple t) { return SimpleEventManager.notifyingRemove(this, iterator); } + /** + * Returns the delta-aware prefix mapping. Writes are recorded as additions/deletions over the + * base prefix mapping; reads fold the delta over the base. + */ + @Override + public PrefixMapping getPrefixMapping() { + return prefixMapping; + } + + /** + * @return {@code true} if any prefix has been added or removed relative to the base + */ + public boolean hasPrefixChanges() { + return prefixMapping.hasChanges(); + } + @Override public void close() { super.close(); diff --git a/backend/src/main/java/org/rdfarchitect/rdf/graph/DeltaPrefixMapping.java b/backend/src/main/java/org/rdfarchitect/rdf/graph/DeltaPrefixMapping.java new file mode 100644 index 00000000..35d719de --- /dev/null +++ b/backend/src/main/java/org/rdfarchitect/rdf/graph/DeltaPrefixMapping.java @@ -0,0 +1,214 @@ +/* + * 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.rdf.graph; + +import org.apache.jena.shared.JenaException; +import org.apache.jena.shared.PrefixMapping; +import org.apache.jena.shared.impl.PrefixMappingImpl; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A {@link PrefixMapping} implementation that records prefix changes as deltas similar to the + * {@link org.rdfarchitect.rdf.graph.wrapper.RDFGraphDelta} + */ +public class DeltaPrefixMapping implements PrefixMapping { + + private static final Pattern PREFIX_PATTERN = Pattern.compile("([A-Za-z][A-Za-z0-9-_.]*)?"); + + private final PrefixMapping base; + private final Map additions = new HashMap<>(); + private final Map deletions = new HashMap<>(); + private boolean locked = false; + + public DeltaPrefixMapping(PrefixMapping base) { + this.base = base; + } + + // ------------------------------------------------------------------------- + // Delta access — used by DeltaCompressible to detect and fold changes + // ------------------------------------------------------------------------- + + /** + * @return an unmodifiable view of the prefix additions relative to the base + */ + public Map getAddedPrefixes() { + return Map.copyOf(additions); + } + + /** + * @return an unmodifiable view of the prefix names removed relative to the base + */ + public Map getDeletedPrefixes() { + return Map.copyOf(deletions); + } + + /** + * @return {@code true} if any prefix has been added or removed relative to the base + */ + public boolean hasChanges() { + return !additions.isEmpty() || !deletions.isEmpty(); + } + + /** + * Builds a plain {@link PrefixMapping} snapshot of the current folded state (base − deletions + + * additions). Read-only queries delegate to this so resolution behaves exactly like Jena. + */ + private PrefixMapping folded() { + return new PrefixMappingImpl().setNsPrefixes(getNsPrefixMap()); + } + + private void checkUnlocked() { + if (locked) { + throw new JenaException("Attempted to modify a locked PrefixMapping."); + } + } + + private void validatePrefix(String prefix, String uri) { + Objects.requireNonNull(uri, "null URIs are prohibited as arguments to setNsPrefix"); + if (!PREFIX_PATTERN.matcher(prefix).matches()) { + throw new PrefixMapping.IllegalPrefixException(prefix); + } + } + + // ------------------------------------------------------------------------- + // Writes — record the change as a delta + // ------------------------------------------------------------------------- + + @Override + public PrefixMapping setNsPrefix(String prefix, String uri) { + checkUnlocked(); + validatePrefix(prefix, uri); + // Mirror DeltaCompressible#performAdd: setting a prefix to its base value is not a change. + if (uri.equals(base.getNsPrefixURI(prefix))) { + additions.remove(prefix); + deletions.remove(prefix); + return this; + } + additions.put(prefix, uri); + deletions.remove(prefix); + return this; + } + + @Override + public PrefixMapping removeNsPrefix(String prefix) { + checkUnlocked(); + additions.remove(prefix); + String baseUri = base.getNsPrefixURI(prefix); + if (baseUri != null) { + deletions.put(prefix, baseUri); + } + return this; + } + + @Override + public PrefixMapping clearNsPrefixMap() { + checkUnlocked(); + additions.clear(); + deletions.clear(); + deletions.putAll(base.getNsPrefixMap()); + return this; + } + + @Override + public PrefixMapping setNsPrefixes(PrefixMapping other) { + return setNsPrefixes(other.getNsPrefixMap()); + } + + @Override + public PrefixMapping setNsPrefixes(Map other) { + checkUnlocked(); + other.forEach(this::setNsPrefix); + return this; + } + + @Override + public PrefixMapping withDefaultMappings(PrefixMapping other) { + checkUnlocked(); + other.getNsPrefixMap() + .forEach( + (prefix, uri) -> { + if (getNsPrefixURI(prefix) == null && getNsURIPrefix(uri) == null) { + setNsPrefix(prefix, uri); + } + }); + return this; + } + + // ------------------------------------------------------------------------- + // Reads — fold the delta over the base: base − deletions + additions + // ------------------------------------------------------------------------- + + @Override + public Map getNsPrefixMap() { + Map folded = new HashMap<>(base.getNsPrefixMap()); + deletions.keySet().forEach(folded::remove); + folded.putAll(additions); + return folded; + } + + @Override + public String getNsPrefixURI(String prefix) { + if (additions.containsKey(prefix)) { + return additions.get(prefix); + } + if (deletions.containsKey(prefix)) { + return null; + } + return base.getNsPrefixURI(prefix); + } + + @Override + public String getNsURIPrefix(String uri) { + return folded().getNsURIPrefix(uri); + } + + @Override + public String expandPrefix(String prefixed) { + return folded().expandPrefix(prefixed); + } + + @Override + public String shortForm(String uri) { + return folded().shortForm(uri); + } + + @Override + public String qnameFor(String uri) { + return folded().qnameFor(uri); + } + + @Override + public int numPrefixes() { + return getNsPrefixMap().size(); + } + + @Override + public boolean samePrefixMappingAs(PrefixMapping other) { + return getNsPrefixMap().equals(other.getNsPrefixMap()); + } + + @Override + public PrefixMapping lock() { + this.locked = true; + return this; + } +} diff --git a/backend/src/main/java/org/rdfarchitect/rdf/graph/wrapper/GraphRewindable.java b/backend/src/main/java/org/rdfarchitect/rdf/graph/wrapper/GraphRewindable.java index 10c47678..9d06afe2 100644 --- a/backend/src/main/java/org/rdfarchitect/rdf/graph/wrapper/GraphRewindable.java +++ b/backend/src/main/java/org/rdfarchitect/rdf/graph/wrapper/GraphRewindable.java @@ -278,7 +278,9 @@ protected void compressBase() { * @return True if the current graph is the same as the last version, otherwise false. */ protected boolean noChangesInTransaction() { - return currentDelta.getAdditions().isEmpty() && currentDelta.getDeletions().isEmpty(); + return currentDelta.getAdditions().isEmpty() + && currentDelta.getDeletions().isEmpty() + && !currentDelta.hasPrefixChanges(); } // Graph diff --git a/backend/src/main/java/org/rdfarchitect/rdf/graph/wrapper/RDFGraphDelta.java b/backend/src/main/java/org/rdfarchitect/rdf/graph/wrapper/RDFGraphDelta.java index 72d4078b..618f420a 100644 --- a/backend/src/main/java/org/rdfarchitect/rdf/graph/wrapper/RDFGraphDelta.java +++ b/backend/src/main/java/org/rdfarchitect/rdf/graph/wrapper/RDFGraphDelta.java @@ -249,7 +249,9 @@ public DeltaCompressible getLastDelta() { @Override public boolean hasChanges() { - return !currentDelta.getAdditions().isEmpty() || !currentDelta.getDeletions().isEmpty(); + return !currentDelta.getAdditions().isEmpty() + || !currentDelta.getDeletions().isEmpty() + || currentDelta.hasPrefixChanges(); } // ------------------------------------------------------------------------- diff --git a/backend/src/main/java/org/rdfarchitect/services/shacl/SHACLStoringService.java b/backend/src/main/java/org/rdfarchitect/services/shacl/SHACLStoringService.java index 4d7d22d2..8be00082 100644 --- a/backend/src/main/java/org/rdfarchitect/services/shacl/SHACLStoringService.java +++ b/backend/src/main/java/org/rdfarchitect/services/shacl/SHACLStoringService.java @@ -81,23 +81,14 @@ public class SHACLStoringService @Override public void replaceCustomSHACLGraph(GraphIdentifier graphIdentifier, Graph shacl) { - var normalized = GraphUtils.normalizeBlankNodes(shacl); try (var ctx = databasePort.getGraphWithContext(graphIdentifier).begin(ReadWrite.WRITE)) { var storedGraph = ctx.getCustomSHACL(); - - var toDelete = storedGraph.find().filterKeep(t -> !normalized.contains(t)).toList(); - var toAdd = normalized.find().filterKeep(t -> !storedGraph.contains(t)).toList(); - - if (toDelete.isEmpty() && toAdd.isEmpty()) { - return; - } - - toDelete.forEach(storedGraph::delete); - toAdd.forEach(storedGraph::add); - + storedGraph.clear(); var storedModel = ModelFactory.createModelForGraph(storedGraph); + var newModel = ModelFactory.createModelForGraph(GraphUtils.normalizeBlankNodes(shacl)); storedModel.clearNsPrefixMap(); - storedModel.setNsPrefixes(ModelFactory.createModelForGraph(normalized)); + storedModel.add(newModel); + storedModel.setNsPrefixes(newModel); ctx.commit("Replace custom SHACL"); } diff --git a/backend/src/test/java/org/rdfarchitect/database/inmemory/GraphWithContextTransactionalPrefixTest.java b/backend/src/test/java/org/rdfarchitect/database/inmemory/GraphWithContextTransactionalPrefixTest.java new file mode 100644 index 00000000..4f88bfb8 --- /dev/null +++ b/backend/src/test/java/org/rdfarchitect/database/inmemory/GraphWithContextTransactionalPrefixTest.java @@ -0,0 +1,103 @@ +/* + * 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.database.inmemory; + +import static org.assertj.core.api.Assertions.*; + +import org.apache.jena.query.ReadWrite; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.sparql.graph.GraphFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GraphWithContextTransactionalPrefixTest { + + private static final String FOO_URI = "http://example.org/foo#"; + + private GraphWithContextTransactional ctx; + + @BeforeEach + void setUp() { + ctx = new GraphWithContextTransactional(GraphFactory.createDefaultGraph()); + } + + @AfterEach + void tearDown() { + if (ctx.isInTransaction()) { + ctx.end(); + } + } + + @Test + void commit_customShaclPrefix_isVisibleInSubsequentReadTransaction() { + try (var _ = ctx.begin(ReadWrite.WRITE)) { + var model = ModelFactory.createModelForGraph(ctx.getCustomSHACL()); + model.setNsPrefix("foo", FOO_URI); + ctx.commit("set prefix"); + } + + try (var _ = ctx.begin(ReadWrite.READ)) { + var model = ModelFactory.createModelForGraph(ctx.getCustomSHACL()); + assertThat(model.getNsPrefixURI("foo")).isEqualTo(FOO_URI); + } + } + + @Test + void commit_onlyPrefixChange_isRecordedAsUndoableStep() { + try (var _ = ctx.begin(ReadWrite.WRITE)) { + ModelFactory.createModelForGraph(ctx.getCustomSHACL()).setNsPrefix("foo", FOO_URI); + ctx.commit("set prefix"); + } + + try (var _ = ctx.begin(ReadWrite.READ)) { + assertThat(ctx.canUndo()).isTrue(); + } + } + + @Test + void undo_revertsCustomShaclPrefix() { + try (var _ = ctx.begin(ReadWrite.WRITE)) { + ModelFactory.createModelForGraph(ctx.getCustomSHACL()).setNsPrefix("foo", FOO_URI); + ctx.commit("set prefix"); + } + + ctx.undo(); + + try (var _ = ctx.begin(ReadWrite.READ)) { + var model = ModelFactory.createModelForGraph(ctx.getCustomSHACL()); + assertThat(model.getNsPrefixURI("foo")).isNull(); + } + } + + @Test + void redo_reappliesCustomShaclPrefix() { + try (var _ = ctx.begin(ReadWrite.WRITE)) { + ModelFactory.createModelForGraph(ctx.getCustomSHACL()).setNsPrefix("foo", FOO_URI); + ctx.commit("set prefix"); + } + ctx.undo(); + + ctx.redo(); + + try (var _ = ctx.begin(ReadWrite.READ)) { + var model = ModelFactory.createModelForGraph(ctx.getCustomSHACL()); + assertThat(model.getNsPrefixURI("foo")).isEqualTo(FOO_URI); + } + } +} diff --git a/backend/src/test/java/org/rdfarchitect/rdf/graph/DeltaPrefixMappingTest.java b/backend/src/test/java/org/rdfarchitect/rdf/graph/DeltaPrefixMappingTest.java new file mode 100644 index 00000000..4bc147c6 --- /dev/null +++ b/backend/src/test/java/org/rdfarchitect/rdf/graph/DeltaPrefixMappingTest.java @@ -0,0 +1,200 @@ +/* + * 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.rdf.graph; + +import static org.assertj.core.api.Assertions.*; + +import org.apache.jena.shared.PrefixMapping; +import org.apache.jena.shared.impl.PrefixMappingImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DeltaPrefixMappingTest { + + private static final String EX_URI = "http://example.org/ex#"; + private static final String FOO_URI = "http://example.org/foo#"; + private static final String BAR_URI = "http://example.org/bar#"; + + private PrefixMapping base; + + @BeforeEach + void setUp() { + base = new PrefixMappingImpl(); + base.setNsPrefix("ex", EX_URI); + } + + @Test + void newMapping_withoutChanges_hasNoChanges() { + var mapping = new DeltaPrefixMapping(base); + + assertThat(mapping.hasChanges()).isFalse(); + } + + @Test + void newMapping_exposesBasePrefixes() { + var mapping = new DeltaPrefixMapping(base); + + assertThat(mapping.getNsPrefixURI("ex")).isEqualTo(EX_URI); + assertThat(mapping.getNsPrefixMap()).containsEntry("ex", EX_URI); + } + + @Test + void setNsPrefix_addsToAdditionsAndIsVisible() { + var mapping = new DeltaPrefixMapping(base); + + mapping.setNsPrefix("foo", FOO_URI); + + assertThat(mapping.hasChanges()).isTrue(); + assertThat(mapping.getAddedPrefixes()).containsEntry("foo", FOO_URI); + assertThat(mapping.getNsPrefixURI("foo")).isEqualTo(FOO_URI); + assertThat(mapping.getNsPrefixMap()) + .containsEntry("foo", FOO_URI) + .containsEntry("ex", EX_URI); + } + + @Test + void setNsPrefix_identicalToBase_isNotAChange() { + var mapping = new DeltaPrefixMapping(base); + + mapping.setNsPrefix("ex", EX_URI); + + assertThat(mapping.hasChanges()).isFalse(); + assertThat(mapping.getAddedPrefixes()).doesNotContainKey("ex"); + assertThat(mapping.getNsPrefixURI("ex")).isEqualTo(EX_URI); + } + + @Test + void setNsPrefix_invalidPrefix_throws() { + var mapping = new DeltaPrefixMapping(base); + + assertThatExceptionOfType(PrefixMapping.IllegalPrefixException.class) + .isThrownBy(() -> mapping.setNsPrefix("1invalid", FOO_URI)); + } + + @Test + void removeNsPrefix_basePrefix_recordsDeletionAndHidesPrefix() { + var mapping = new DeltaPrefixMapping(base); + + mapping.removeNsPrefix("ex"); + + assertThat(mapping.hasChanges()).isTrue(); + assertThat(mapping.getDeletedPrefixes()).containsEntry("ex", EX_URI); + assertThat(mapping.getNsPrefixURI("ex")).isNull(); + assertThat(mapping.getNsPrefixMap()).doesNotContainKey("ex"); + } + + @Test + void removeNsPrefix_thenSetAgain_clearsDeletion() { + var mapping = new DeltaPrefixMapping(base); + + mapping.removeNsPrefix("ex"); + mapping.setNsPrefix("ex", BAR_URI); + + assertThat(mapping.getDeletedPrefixes()).doesNotContainKey("ex"); + assertThat(mapping.getNsPrefixURI("ex")).isEqualTo(BAR_URI); + } + + @Test + void setNsPrefix_overridingBaseValue_usesNewValue() { + var mapping = new DeltaPrefixMapping(base); + + mapping.setNsPrefix("ex", BAR_URI); + + assertThat(mapping.hasChanges()).isTrue(); + assertThat(mapping.getNsPrefixURI("ex")).isEqualTo(BAR_URI); + assertThat(mapping.getNsPrefixMap()).containsEntry("ex", BAR_URI); + } + + @Test + void setThenRemoveAddedPrefix_isNotVisibleAndNoDeletionRecorded() { + var mapping = new DeltaPrefixMapping(base); + + mapping.setNsPrefix("foo", FOO_URI); + mapping.removeNsPrefix("foo"); + + assertThat(mapping.getAddedPrefixes()).doesNotContainKey("foo"); + // "foo" is not declared in the base, so removing it records no deletion + // (mirrors DeltaCompressible#performDelete, which only records base triples). + assertThat(mapping.getDeletedPrefixes()).doesNotContainKey("foo"); + assertThat(mapping.getNsPrefixURI("foo")).isNull(); + } + + @Test + void clearNsPrefixMap_removesAllBasePrefixes() { + var mapping = new DeltaPrefixMapping(base); + + mapping.clearNsPrefixMap(); + + assertThat(mapping.getNsPrefixMap()).isEmpty(); + assertThat(mapping.getDeletedPrefixes()).containsEntry("ex", EX_URI); + assertThat(mapping.hasChanges()).isTrue(); + } + + @Test + void setNsPrefixes_fromPrefixMapping_addsAll() { + var other = new PrefixMappingImpl(); + other.setNsPrefix("foo", FOO_URI); + other.setNsPrefix("bar", BAR_URI); + var mapping = new DeltaPrefixMapping(base); + + mapping.setNsPrefixes(other); + + assertThat(mapping.getNsPrefixMap()) + .containsEntry("foo", FOO_URI) + .containsEntry("bar", BAR_URI) + .containsEntry("ex", EX_URI); + } + + @Test + void expandPrefix_resolvesFoldedState() { + var mapping = new DeltaPrefixMapping(base); + mapping.setNsPrefix("foo", FOO_URI); + + assertThat(mapping.expandPrefix("foo:Thing")).isEqualTo(FOO_URI + "Thing"); + assertThat(mapping.expandPrefix("ex:Thing")).isEqualTo(EX_URI + "Thing"); + } + + @Test + void shortForm_resolvesFoldedState() { + var mapping = new DeltaPrefixMapping(base); + mapping.setNsPrefix("foo", FOO_URI); + + assertThat(mapping.shortForm(FOO_URI + "Thing")).isEqualTo("foo:Thing"); + } + + @Test + void samePrefixMappingAs_equalFoldedState_returnsTrue() { + var mapping = new DeltaPrefixMapping(base); + mapping.setNsPrefix("foo", FOO_URI); + + var expected = new PrefixMappingImpl(); + expected.setNsPrefix("ex", EX_URI); + expected.setNsPrefix("foo", FOO_URI); + + assertThat(mapping.samePrefixMappingAs(expected)).isTrue(); + } + + @Test + void numPrefixes_reflectsFoldedState() { + var mapping = new DeltaPrefixMapping(base); + mapping.setNsPrefix("foo", FOO_URI); + mapping.removeNsPrefix("ex"); + + assertThat(mapping.numPrefixes()).isEqualTo(1); + } +} diff --git a/backend/src/test/java/org/rdfarchitect/rdf/graph/wrapper/RDFGraphDeltaPrefixTest.java b/backend/src/test/java/org/rdfarchitect/rdf/graph/wrapper/RDFGraphDeltaPrefixTest.java new file mode 100644 index 00000000..7d0e87e8 --- /dev/null +++ b/backend/src/test/java/org/rdfarchitect/rdf/graph/wrapper/RDFGraphDeltaPrefixTest.java @@ -0,0 +1,161 @@ +/* + * 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.rdf.graph.wrapper; + +import static org.assertj.core.api.Assertions.*; + +import org.apache.jena.query.ReadWrite; +import org.apache.jena.sparql.graph.GraphFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RDFGraphDeltaPrefixTest { + + private static final String FOO_URI = "http://example.org/foo#"; + private static final String BAR_URI = "http://example.org/bar#"; + + private TransactionContext txnContext; + private RDFGraphDelta graph; + + @BeforeEach + void setUp() { + txnContext = new TransactionContext(); + graph = new RDFGraphDelta(GraphFactory.createDefaultGraph(), 15, 5, txnContext); + } + + @AfterEach + void tearDown() { + if (txnContext.isInTransaction()) { + txnContext.end(); + } + } + + @Test + void hasChanges_afterSettingPrefix_returnsTrue() { + txnContext.begin(ReadWrite.WRITE); + + graph.getPrefixMapping().setNsPrefix("foo", FOO_URI); + + assertThat(graph.hasChanges()).isTrue(); + } + + @Test + void hasChanges_withoutAnyChange_returnsFalse() { + txnContext.begin(ReadWrite.WRITE); + + assertThat(graph.hasChanges()).isFalse(); + } + + @Test + void commit_prefixIsVisibleInSubsequentTransaction() { + txnContext.begin(ReadWrite.WRITE); + graph.getPrefixMapping().setNsPrefix("foo", FOO_URI); + graph.commit(); + txnContext.end(); + + txnContext.begin(ReadWrite.READ); + assertThat(graph.getPrefixMapping().getNsPrefixURI("foo")).isEqualTo(FOO_URI); + } + + @Test + void commit_multiplePrefixesAcrossCommits_areAllVisible() { + txnContext.begin(ReadWrite.WRITE); + graph.getPrefixMapping().setNsPrefix("foo", FOO_URI); + graph.commit(); + txnContext.end(); + + txnContext.begin(ReadWrite.WRITE); + graph.getPrefixMapping().setNsPrefix("bar", BAR_URI); + graph.commit(); + txnContext.end(); + + txnContext.begin(ReadWrite.READ); + assertThat(graph.getPrefixMapping().getNsPrefixMap()) + .containsEntry("foo", FOO_URI) + .containsEntry("bar", BAR_URI); + } + + @Test + void undo_revertsPrefixAddition() { + txnContext.begin(ReadWrite.WRITE); + graph.getPrefixMapping().setNsPrefix("foo", FOO_URI); + graph.commit(); + txnContext.end(); + + txnContext.begin(ReadWrite.WRITE); + graph.undo(); + txnContext.end(); + + txnContext.begin(ReadWrite.READ); + assertThat(graph.getPrefixMapping().getNsPrefixURI("foo")).isNull(); + } + + @Test + void redo_reappliesPrefixAddition() { + txnContext.begin(ReadWrite.WRITE); + graph.getPrefixMapping().setNsPrefix("foo", FOO_URI); + graph.commit(); + txnContext.end(); + + txnContext.begin(ReadWrite.WRITE); + graph.undo(); + txnContext.end(); + + txnContext.begin(ReadWrite.WRITE); + graph.redo(); + txnContext.end(); + + txnContext.begin(ReadWrite.READ); + assertThat(graph.getPrefixMapping().getNsPrefixURI("foo")).isEqualTo(FOO_URI); + } + + @Test + void undo_revertsPrefixRemoval() { + // commit 1: add foo + txnContext.begin(ReadWrite.WRITE); + graph.getPrefixMapping().setNsPrefix("foo", FOO_URI); + graph.commit(); + txnContext.end(); + + // commit 2: remove foo + txnContext.begin(ReadWrite.WRITE); + graph.getPrefixMapping().removeNsPrefix("foo"); + graph.commit(); + txnContext.end(); + + // undo the removal -> foo should be back + txnContext.begin(ReadWrite.WRITE); + graph.undo(); + txnContext.end(); + + txnContext.begin(ReadWrite.READ); + assertThat(graph.getPrefixMapping().getNsPrefixURI("foo")).isEqualTo(FOO_URI); + } + + @Test + void abort_discardsUncommittedPrefixChange() { + txnContext.begin(ReadWrite.WRITE); + graph.getPrefixMapping().setNsPrefix("foo", FOO_URI); + graph.abort(); + txnContext.end(); + + txnContext.begin(ReadWrite.READ); + assertThat(graph.getPrefixMapping().getNsPrefixURI("foo")).isNull(); + } +}