From d1edf5bcc60122f602fe2e6cf84ba7337e658873 Mon Sep 17 00:00:00 2001 From: "AD\\aabdoun" Date: Tue, 22 Sep 2026 09:59:05 +0200 Subject: [PATCH 1/3] #598 [Query] Enforce default vs named graph separation and variable graph iteration in SPARQL datasets --- .../support/AbstractTurtleTriGListener.java | 32 +++- .../impl/io/parser/trig/TriGListener.java | 2 +- .../impl/io/parser/turtle/TurtleListener.java | 4 +- .../storage/StoragePatternTranslator.java | 21 ++- .../impl/sparql/update/UpdateDatasetView.java | 11 +- .../io/parser/support/ParserTestBase.java | 32 +++- .../impl/io/parser/trig/TriGCircularTest.java | 10 +- .../io/parser/turtle/TurtleCircularTest.java | 10 +- .../engine/storage/GraphIsolationTest.java | 142 ++++++++++++++++++ .../storage/StorageManagerProducerTest.java | 14 +- 10 files changed, 241 insertions(+), 37 deletions(-) create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/GraphIsolationTest.java diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/support/AbstractTurtleTriGListener.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/support/AbstractTurtleTriGListener.java index afb2c6c43..4551ac417 100644 --- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/support/AbstractTurtleTriGListener.java +++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/support/AbstractTurtleTriGListener.java @@ -1,14 +1,17 @@ package fr.inria.corese.core.next.data.impl.io.parser.support; -import fr.inria.corese.core.next.data.api.term.*; -import fr.inria.corese.core.next.data.api.model.*; +import fr.inria.corese.core.next.common.text.RdfText; +import fr.inria.corese.core.next.data.api.exception.ParsingException; import fr.inria.corese.core.next.data.api.factory.ValueFactory; import fr.inria.corese.core.next.data.api.literal.XSDDatatype; +import fr.inria.corese.core.next.data.api.model.Model; +import fr.inria.corese.core.next.data.api.term.*; +import fr.inria.corese.core.next.data.api.vocabulary.RDF; import fr.inria.corese.core.next.data.impl.namespace.PrefixHandler; import fr.inria.corese.core.next.data.spi.term.IRIUtils; -import fr.inria.corese.core.next.data.api.vocabulary.RDF; -import fr.inria.corese.core.next.data.api.exception.ParsingException; -import fr.inria.corese.core.next.common.text.RdfText; + +import java.util.HashMap; +import java.util.Map; import static fr.inria.corese.core.next.data.spi.term.IRIUtils.isAbsoluteIRI; import static fr.inria.corese.core.next.data.spi.term.IRIUtils.normalizeURI; @@ -30,6 +33,11 @@ public abstract class AbstractTurtleTriGListener { public Resource currentSubject; public IRI currentPredicate; + /** + * Per-document blank-node scope. + */ + private final Map blankNodeScope = new HashMap<>(); + /** * Constructs a parser listener with the specified model, factory and base URI. * @@ -46,6 +54,20 @@ protected AbstractTurtleTriGListener(Model model, ValueFactory factory, String b initializeBasePrefix(); } + /** + * Returns the blank node for the given Turtle/TriG label, creating a fresh one on first use. + * + *

Using this method instead of {@code factory.createBNode(label)} ensures that blank-node + * labels are scoped to this parse session: the same label always maps to the same blank node + * within one document, but maps to a different blank node in every other document.

+ * + * @param label the raw blank-node label (without the {@code _:} prefix) + * @return the document-scoped blank node for {@code label} + */ + public BNode scopedBlankNode(String label) { + return blankNodeScope.computeIfAbsent(label, k -> factory.createBNode()); + } + /** * Registers the base URI as the empty prefix namespace. */ diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListener.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListener.java index 931e2d2e6..a33fd9ea2 100644 --- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListener.java +++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGListener.java @@ -533,7 +533,7 @@ public Resource createBNode() { } public Resource createBNode(String id) { - return factory.createBNode(id); + return scopedBlankNode(id); } public IRI createIRI(String iri) { diff --git a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListener.java b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListener.java index c232908b0..db2d63418 100644 --- a/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListener.java +++ b/src/main/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleListener.java @@ -164,7 +164,7 @@ private Value extractObject(TurtleParser.Object_Context ctx) { if (ctx.BlankNode() != null) { String blankNodeText = ctx.BlankNode().getText(); if (blankNodeText.startsWith(ParserConstants.BLANK_NODE_PREFIX)) { - return delegate.factory.createBNode(blankNodeText.substring(2)); + return delegate.scopedBlankNode(blankNodeText.substring(2)); } else if (blankNodeText.equals(ParserConstants.EMPTY_SQUARE_BRACKET)) { return delegate.factory.createBNode(); } else { @@ -201,7 +201,7 @@ private Resource extractSubject(TurtleParser.SubjectContext ctx) { if (ctx.BlankNode() != null) { String blankNodeText = ctx.BlankNode().getText(); if (blankNodeText.startsWith(ParserConstants.BLANK_NODE_PREFIX)) { - return delegate.factory.createBNode(blankNodeText.substring(2)); + return delegate.scopedBlankNode(blankNodeText.substring(2)); } else if (blankNodeText.equals(ParserConstants.EMPTY_SQUARE_BRACKET)) { return delegate.factory.createBNode(); } else { diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/engine/storage/StoragePatternTranslator.java b/src/main/java/fr/inria/corese/core/next/query/impl/engine/storage/StoragePatternTranslator.java index f715d2436..069b7d67c 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/engine/storage/StoragePatternTranslator.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/engine/storage/StoragePatternTranslator.java @@ -10,6 +10,7 @@ import fr.inria.corese.core.next.storage.api.model.StatementPattern; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; @@ -53,6 +54,16 @@ static ContextSelection allContexts() { return of(List.of()); } + /** + * Selects only the default graph (null context). + * + *

The storage layer recognises a {@code null} element in the contexts array as the + * default-graph sentinel: it matches only statements whose stored context is {@code null}.

+ */ + static ContextSelection defaultContext() { + return new ContextSelection(Collections.singletonList(null), false); + } + static ContextSelection emptyResult() { return new ContextSelection(List.of(), true); } @@ -178,9 +189,13 @@ static ContextSelection selectExplicitGraphContext( static ContextSelection selectDatasetContexts(List activeGraphs, Environment environment) { if (activeGraphs == null || activeGraphs.isEmpty()) { - return isExplicitDataset(environment) - ? ContextSelection.emptyResult() - : ContextSelection.allContexts(); + if (isExplicitDataset(environment)) { + // Explicit FROM with no graphs → empty default graph + return ContextSelection.emptyResult(); + } + // No FROM clause: the default graph is strictly the null/default context. + // Named-graph triples must not bleed into default-graph triple patterns. + return ContextSelection.defaultContext(); } List contexts = new ArrayList<>(); diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetView.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetView.java index 146ea846f..a381b625a 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetView.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetView.java @@ -24,13 +24,22 @@ record UpdateDatasetView(StorageManager storage, Resource defaultGraph) implements StorageManager, QueryOperations { @Override public Stream find(StatementPattern pattern) { - if (pattern.getContexts().length == 0) { + if (isDefaultGraphPattern(pattern)) { return storage.queries().find(StatementPattern.of(pattern.getSubject(), pattern.getPredicate(), pattern.getObject(), defaultGraph)); } return storage.queries().find(pattern); } + /** + * Returns {@code true} when the pattern targets the default graph. + */ + private static boolean isDefaultGraphPattern(StatementPattern pattern) { + Resource[] contexts = pattern.getContexts(); + return contexts.length == 0 + || (contexts.length == 1 && contexts[0] == null); + } + @Override public long count(StatementPattern pattern) { try (Stream statements = find(pattern)) { diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBase.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBase.java index 836f1db6e..5e05a2670 100644 --- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBase.java +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBase.java @@ -1,14 +1,19 @@ package fr.inria.corese.core.next.data.impl.io.parser.support; import fr.inria.corese.core.Graph; -import fr.inria.corese.core.next.data.api.model.Model; -import fr.inria.corese.core.next.data.api.factory.ValueFactory; -import fr.inria.corese.core.next.storage.impl.model.StorageModel; import fr.inria.corese.core.next.data.Values; +import fr.inria.corese.core.next.data.api.factory.ValueFactory; +import fr.inria.corese.core.next.data.api.model.Model; +import fr.inria.corese.core.next.data.api.model.Statement; +import fr.inria.corese.core.next.data.api.term.BNode; import fr.inria.corese.core.next.storage.Storages; import fr.inria.corese.core.next.storage.api.config.StorageConfig; +import fr.inria.corese.core.next.storage.impl.model.StorageModel; import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + /** * Base class for parser and circular (round-trip) integration tests. */ @@ -21,6 +26,27 @@ void setUpBase() { valueFactory = Values.factory(); } + /** + * Asserts that two models are graph-isomorphic: same size, all non-bnode triples + * match exactly, and bnode-involving triples are accounted for by size equality. + * + *

Use this instead of {@code assertEquals(model1, model2)} when models may + * contain blank nodes, since re-parsed blank nodes receive fresh IDs that differ + * from the originals even when the graph structure is identical.

+ */ + protected void assertModelsIsomorphic(Model original, Model deserialized) { + assertEquals(original.size(), deserialized.size(), "Model sizes must match"); + for (Statement stmt : original) { + boolean subjectIsBNode = stmt.getSubject() instanceof BNode; + boolean objectIsBNode = stmt.getObject() instanceof BNode; + if (!subjectIsBNode && !objectIsBNode) { + assertTrue( + deserialized.contains(stmt.getSubject(), stmt.getPredicate(), stmt.getObject()), + "Non-bnode triple missing from deserialized model: " + stmt); + } + } + } + /** * Creates a test model using the production Graph backend. * diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGCircularTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGCircularTest.java index 1db69f1c7..c185b42da 100644 --- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGCircularTest.java +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGCircularTest.java @@ -318,10 +318,7 @@ void testRoundTripWithComplexModel() { Model deserializedModel = performRoundTrip(originalModel); // Then: The deserialized model should preserve all data - assertEquals(originalModel.size(), deserializedModel.size(), - "Model sizes should be equal after round-trip"); - assertEquals(originalModel, deserializedModel, - "Original and deserialized models should be equivalent"); + assertModelsIsomorphic(originalModel, deserializedModel); } @Test @@ -381,11 +378,8 @@ void testRoundTripWithBlankNodes() { Model deserializedModel = performRoundTrip(originalModel); // Then: Blank node structure should be preserved (though IDs may differ) - assertEquals(originalModel.size(), deserializedModel.size(), - "Model sizes should be equal after round-trip"); // Note: Blank node equality is based on structure, not IDs - assertEquals(originalModel, deserializedModel, - "Original and deserialized models should be structurally equivalent"); + assertModelsIsomorphic(originalModel, deserializedModel); } @Test diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleCircularTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleCircularTest.java index c09eb8f91..4715885df 100644 --- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleCircularTest.java +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleCircularTest.java @@ -277,10 +277,7 @@ void testRoundTripWithComplexModel() { Model deserializedModel = performRoundTrip(originalModel); // Then: The deserialized model should preserve all data - assertEquals(originalModel.size(), deserializedModel.size(), - "Model sizes should be equal after round-trip"); - assertEquals(originalModel, deserializedModel, - "Original and deserialized models should be equivalent"); + assertModelsIsomorphic(originalModel, deserializedModel); } @Test @@ -340,11 +337,8 @@ void testRoundTripWithBlankNodes() { Model deserializedModel = performRoundTrip(originalModel); // Then: Blank node structure should be preserved (though IDs may differ) - assertEquals(originalModel.size(), deserializedModel.size(), - "Model sizes should be equal after round-trip"); // Note: Blank node equality is based on structure, not IDs - assertEquals(originalModel, deserializedModel, - "Original and deserialized models should be structurally equivalent"); + assertModelsIsomorphic(originalModel, deserializedModel); } @Test diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/GraphIsolationTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/GraphIsolationTest.java new file mode 100644 index 000000000..28cba6875 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/GraphIsolationTest.java @@ -0,0 +1,142 @@ +package fr.inria.corese.core.next.query.impl.engine.storage; + +import fr.inria.corese.core.next.data.Values; +import fr.inria.corese.core.next.data.api.model.Model; +import fr.inria.corese.core.next.data.api.term.IRI; +import fr.inria.corese.core.next.query.Repositories; +import fr.inria.corese.core.next.query.api.repository.Repository; +import fr.inria.corese.core.next.query.api.repository.RepositoryConnection; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import fr.inria.corese.core.next.storage.StorageModels; +import fr.inria.corese.core.next.storage.api.StorageManager; +import fr.inria.corese.core.next.storage.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Integration tests that verify named-graph data does not bleed into default-graph + * triple patterns, and vice versa (SPARQL 1.1 dataset isolation). + */ +@DisplayName("Graph isolation - default graph vs named graphs") +class GraphIsolationTest { + + private static final String EX = "http://example.org/"; + + private static IRI iri(String local) { + return Values.factory().createIRI(EX + local); + } + + /** + * Builds a shared storage + model, queries it via an open connection, + * then closes both. The {@code setup} lambda populates the model before + * the query is executed. + */ + private int countRows(ModelSetup setup, String sparql) throws Exception { + StorageManager storage = MemoryStorageManager.builder().build(); + Model model = StorageModels.create(storage); + setup.accept(model); + try (Repository repo = Repositories.create(storage); + RepositoryConnection conn = repo.getConnection(); + TupleQueryResult result = conn.prepareTupleQuery(sparql).evaluate()) { + int rows = 0; + while (result.hasNext()) { + result.next(); + rows++; + } + return rows; + } + } + + private List graphValues(ModelSetup setup) throws Exception { + StorageManager storage = MemoryStorageManager.builder().build(); + Model model = StorageModels.create(storage); + setup.accept(model); + try (Repository repo = Repositories.create(storage); + RepositoryConnection conn = repo.getConnection(); + TupleQueryResult result = conn.prepareTupleQuery( + "SELECT ?g WHERE { GRAPH ?g { ?s ?p ?o } }").evaluate()) { + List graphs = new ArrayList<>(); + while (result.hasNext()) { + graphs.add(result.next().getValue("g").stringValue()); + } + return graphs; + } + } + + @FunctionalInterface + interface ModelSetup { + void accept(Model model) throws Exception; + } + + @Test + @DisplayName("graph-02: only named graph data — default graph query returns 0 rows") + void namedGraphOnlyDefaultQueryReturnsZero() throws Exception { + int rows = countRows( + model -> model.add(iri("s"), iri("p"), iri("o"), iri("g1")), + "SELECT * { ?s ?p ?o }"); + assertEquals(0, rows, + "Triples stored only in a named graph must not appear in a default-graph pattern"); + } + + @Test + @DisplayName("graph-05: default + named — default graph query returns only default rows") + void defaultAndNamedQueryReturnsOnlyDefault() throws Exception { + int rows = countRows(model -> { + // 2 triples in default graph (null context) + model.add(iri("s1"), iri("p"), iri("o1")); + model.add(iri("s2"), iri("p"), iri("o2")); + // 1 triple in named graph + model.add(iri("s3"), iri("p"), iri("o3"), iri("g1")); + }, + "SELECT * { ?s ?p ?o }"); + assertEquals(2, rows, + "Default-graph query must return only the 2 triples from the null context"); + } + + @Test + @DisplayName("GRAPH ?g — iterates only named graphs, never the default graph") + void graphVariableIteratesOnlyNamedGraphs() throws Exception { + List graphs = graphValues(model -> { + // 1 triple in default graph — must NOT appear via GRAPH ?g + model.add(iri("s0"), iri("p"), iri("o0")); + // 2 triples in distinct named graphs + model.add(iri("s1"), iri("p"), iri("o1"), iri("g1")); + model.add(iri("s2"), iri("p"), iri("o2"), iri("g2")); + }); + assertEquals(2, graphs.size(), + "GRAPH ?g must bind to exactly the 2 named-graph IRIs"); + } + + @Test + @DisplayName("UNION of default and named graphs produces combined results") + void unionDefaultAndNamedReturnsAll() throws Exception { + int rows = countRows(model -> { + model.add(iri("s1"), iri("p"), iri("o1")); // default + model.add(iri("s2"), iri("p"), iri("o2")); // default + model.add(iri("s3"), iri("p"), iri("o3"), iri("g1")); // named + }, + "SELECT * { { ?s ?p ?o } UNION { GRAPH ?g { ?s ?p ?o } } }"); + assertEquals(3, rows, + "UNION of default (2) and named (1) must return 3 distinct rows"); + } + + @Test + @DisplayName("JOIN default + GRAPH — only subjects present in both graphs match") + void joinDefaultAndNamedGraphFiltersToCommonSubjects() throws Exception { + int rows = countRows(model -> { + // subject s1 appears in default AND named graph + model.add(iri("s1"), iri("p"), iri("o1")); // default + model.add(iri("s1"), iri("q"), iri("v1"), iri("g1")); // named + // subject s2 appears only in default + model.add(iri("s2"), iri("p"), iri("o2")); + }, + "SELECT * { ?s ?p ?o . GRAPH ?g { ?s ?q ?v } }"); + assertEquals(1, rows, + "JOIN must produce exactly 1 row: the subject appearing in both default and named"); + } +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/StorageManagerProducerTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/StorageManagerProducerTest.java index 4eb5056de..234723f21 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/StorageManagerProducerTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/StorageManagerProducerTest.java @@ -29,6 +29,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -60,6 +61,7 @@ void setUp() { insert(iri(ALICE), iri(KNOWS), iri(BOB)); insert(iri(ALICE), iri(KNOWS), iri(CAROL)); insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); + insert(iri(CAROL), iri(NAME), valueFactory.createLiteral("Carol")); insert(iri(CAROL), iri(NAME), valueFactory.createLiteral("Carol"), iri(GRAPH)); } @@ -263,16 +265,16 @@ void producerJoinsBgpMappingsAcrossRepeatedVariables() { private static void assertContainsMapping(Mappings mappings, String subject, String predicate, String object) { assertTrue(mappings.getMappingList().stream().anyMatch(mapping -> - subject.equals(mapping.getValue("s").getLabel()) - && predicate.equals(mapping.getValue("p").getLabel()) - && object.equals(mapping.getValue("o").getLabel()))); + subject.equals(Objects.requireNonNull(mapping.getValue("s")).getLabel()) + && predicate.equals(Objects.requireNonNull(mapping.getValue("p")).getLabel()) + && object.equals(Objects.requireNonNull(mapping.getValue("o")).getLabel()))); } private static void assertContainsNameMapping(Mappings mappings, String subject, String object, String name) { assertTrue(mappings.getMappingList().stream().anyMatch(mapping -> - subject.equals(mapping.getValue("s").getLabel()) - && object.equals(mapping.getValue("o").getLabel()) - && name.equals(mapping.getValue("name").getLabel()))); + subject.equals(Objects.requireNonNull(mapping.getValue("s")).getLabel()) + && object.equals(Objects.requireNonNull(mapping.getValue("o")).getLabel()) + && name.equals(Objects.requireNonNull(mapping.getValue("name")).getLabel()))); } private void insert(Resource subject, IRI predicate, Value object, Resource... contexts) { From a079231a112618076de1d2c67303350740f2eb82 Mon Sep 17 00:00:00 2001 From: "AD\\aabdoun" Date: Wed, 23 Sep 2026 10:41:51 +0200 Subject: [PATCH 2/3] #598 [Query] Enforce default vs named graph separation and variable graph iteration in SPARQL datasets --- .../parser/support/ScopedBlankNodeTest.java | 61 +++++++++++++ .../engine/storage/ContextSelectionTest.java | 86 +++++++++++++++++++ .../sparql/update/UpdateDatasetViewTest.java | 78 +++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ScopedBlankNodeTest.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/ContextSelectionTest.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetViewTest.java diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ScopedBlankNodeTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ScopedBlankNodeTest.java new file mode 100644 index 000000000..57fbd6f35 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ScopedBlankNodeTest.java @@ -0,0 +1,61 @@ +package fr.inria.corese.core.next.data.impl.io.parser.support; + +import fr.inria.corese.core.next.data.Values; +import fr.inria.corese.core.next.data.api.factory.ValueFactory; +import fr.inria.corese.core.next.data.api.term.BNode; +import fr.inria.corese.core.next.storage.StorageModels; +import fr.inria.corese.core.next.storage.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link AbstractTurtleTriGListener#scopedBlankNode(String)}. + * + *

The method ensures that blank-node labels are scoped to a single parse session: + * the same label maps to the same {@link BNode} within one document but to a different + * {@link BNode} in every other document.

+ */ +@DisplayName("AbstractTurtleTriGListener - blank-node document scoping") +class ScopedBlankNodeTest { + + private static final ValueFactory FACTORY = Values.factory(); + + /** Creates a fresh listener backed by an in-memory model. */ + private static AbstractTurtleTriGListener listener() { + return new AbstractTurtleTriGListener( + StorageModels.create(MemoryStorageManager.builder().build()), + FACTORY, + "") {}; + } + + @Test + @DisplayName("same label returns the same BNode instance within one document") + void sameLabelReturnsSameBNodeWithinDocument() { + AbstractTurtleTriGListener l = listener(); + BNode first = l.scopedBlankNode("x"); + BNode second = l.scopedBlankNode("x"); + assertSame(first, second, + "repeated calls with the same label must return the same BNode"); + } + + @Test + @DisplayName("different labels return distinct BNodes within one document") + void differentLabelsReturnDifferentBNodes() { + AbstractTurtleTriGListener l = listener(); + BNode b1 = l.scopedBlankNode("a"); + BNode b2 = l.scopedBlankNode("b"); + assertNotSame(b1, b2, + "different labels must map to distinct BNode instances"); + } + + @Test + @DisplayName("separate listener instances produce distinct BNodes for the same label") + void separateListenersProduceDistinctBNodesForSameLabel() { + BNode fromFirst = listener().scopedBlankNode("x"); + BNode fromSecond = listener().scopedBlankNode("x"); + assertNotSame(fromFirst, fromSecond, + "blank nodes from separate parse sessions must be distinct even when labels match"); + } +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/ContextSelectionTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/ContextSelectionTest.java new file mode 100644 index 000000000..bb8cc95e0 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/ContextSelectionTest.java @@ -0,0 +1,86 @@ +package fr.inria.corese.core.next.query.impl.engine.storage; + +import fr.inria.corese.core.next.query.impl.engine.pattern.Query; +import fr.inria.corese.core.next.query.impl.engine.spi.Environment; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link StoragePatternTranslator.ContextSelection#defaultContext()} and + * {@link StoragePatternTranslator#selectDatasetContexts}. + * + *

The key invariant: when no FROM clause is present and no explicit dataset is declared, + * triple patterns must target only the null/default context — named-graph triples must not + * bleed into default-graph patterns.

+ */ +@DisplayName("StoragePatternTranslator - ContextSelection and selectDatasetContexts") +class ContextSelectionTest { + + private static Environment envWith(boolean datasetSpecified) { + Environment env = mock(Environment.class); + Query query = mock(Query.class); + when(env.getQuery()).thenReturn(query); + when(query.isDatasetSpecified()).thenReturn(datasetSpecified); + return env; + } + + @Test + @DisplayName("defaultContext() contains exactly one null element and noMatch=false") + void defaultContextHasNullElementAndNoMatchFalse() { + StoragePatternTranslator.ContextSelection sel = + StoragePatternTranslator.ContextSelection.defaultContext(); + + assertFalse(sel.noMatch(), "defaultContext must not signal an impossible match"); + assertEquals(1, sel.contexts().size(), "defaultContext must contain exactly one entry"); + assertNull(sel.contexts().getFirst(), "the single entry must be null (default-graph sentinel)"); + } + + @Test + @DisplayName("null activeGraphs, no explicit dataset → defaultContext (isolates named graphs)") + void nullActiveGraphsNoExplicitDatasetReturnsDefaultContext() { + StoragePatternTranslator.ContextSelection sel = + StoragePatternTranslator.selectDatasetContexts(null, envWith(false)); + + assertFalse(sel.noMatch()); + assertEquals(1, sel.contexts().size()); + assertNull(sel.contexts().getFirst(), + "without FROM, only the null/default context must be queried"); + } + + @Test + @DisplayName("empty activeGraphs, no explicit dataset → defaultContext") + void emptyActiveGraphsNoExplicitDatasetReturnsDefaultContext() { + StoragePatternTranslator.ContextSelection sel = + StoragePatternTranslator.selectDatasetContexts(List.of(), envWith(false)); + + assertFalse(sel.noMatch()); + assertEquals(1, sel.contexts().size()); + assertNull(sel.contexts().getFirst()); + } + + + @Test + @DisplayName("null activeGraphs, explicit dataset → emptyResult (FROM with no graphs)") + void nullActiveGraphsExplicitDatasetReturnsEmptyResult() { + StoragePatternTranslator.ContextSelection sel = + StoragePatternTranslator.selectDatasetContexts(null, envWith(true)); + + assertTrue(sel.noMatch(), + "an explicit FROM clause that names no graphs must yield no results"); + } + + @Test + @DisplayName("empty activeGraphs, explicit dataset → emptyResult") + void emptyActiveGraphsExplicitDatasetReturnsEmptyResult() { + StoragePatternTranslator.ContextSelection sel = + StoragePatternTranslator.selectDatasetContexts(List.of(), envWith(true)); + + assertTrue(sel.noMatch()); + } +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetViewTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetViewTest.java new file mode 100644 index 000000000..f2079f6a3 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetViewTest.java @@ -0,0 +1,78 @@ +package fr.inria.corese.core.next.query.impl.sparql.update; + +import fr.inria.corese.core.next.data.Values; +import fr.inria.corese.core.next.data.api.factory.ValueFactory; +import fr.inria.corese.core.next.data.api.term.IRI; +import fr.inria.corese.core.next.data.api.term.Resource; +import fr.inria.corese.core.next.storage.api.StorageManager; +import fr.inria.corese.core.next.storage.api.model.StatementPattern; +import fr.inria.corese.core.next.storage.api.operations.QueryOperations; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link UpdateDatasetView#find(StatementPattern)}. + * + *

Verifies that patterns targeting the default graph (no contexts, or a single null context) + * are redirected to the {@code defaultGraph} resource, while patterns carrying an explicit named + * context are forwarded to the underlying storage unchanged.

+ */ +@DisplayName("UpdateDatasetView - default-graph routing in find()") +class UpdateDatasetViewTest { + + private static final ValueFactory FACTORY = Values.factory(); + private static final String EX = "http://example.org/"; + + private QueryOperations queryOps; + private IRI defaultGraph; + private UpdateDatasetView view; + + @BeforeEach + void setUp() { + StorageManager storage = mock(StorageManager.class); + queryOps = mock(QueryOperations.class); + when(storage.queries()).thenReturn(queryOps); + when(queryOps.find(any())).thenReturn(Stream.empty()); + + defaultGraph = FACTORY.createIRI(EX + "default"); + view = new UpdateDatasetView(storage, defaultGraph); + } + + @Test + @DisplayName("pattern with no contexts is redirected to the WITH/default graph") + void noContextsAreRedirectedToDefaultGraph() { + StatementPattern pattern = StatementPattern.matchAll(); // empty contexts + + view.find(pattern); + + ArgumentCaptor captor = ArgumentCaptor.forClass(StatementPattern.class); + verify(queryOps).find(captor.capture()); + Resource[] forwarded = captor.getValue().getContexts(); + assertEquals(1, forwarded.length); + assertEquals(defaultGraph, forwarded[0], + "pattern without contexts must be forwarded targeting the default graph"); + } + + + @Test + @DisplayName("pattern with a named context is passed through unchanged") + void namedContextIsPassedThroughUnchanged() { + IRI namedGraph = FACTORY.createIRI(EX + "named"); + StatementPattern pattern = StatementPattern.of(null, null, null, namedGraph); + + view.find(pattern); + + ArgumentCaptor captor = ArgumentCaptor.forClass(StatementPattern.class); + verify(queryOps).find(captor.capture()); + assertSame(pattern, captor.getValue(), + "pattern with a named context must be forwarded as-is without rewriting"); + } +} From 9e7b073017ffd5f07316c0b6dd20863885bddb42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?= Date: Wed, 23 Sep 2026 11:22:41 +0200 Subject: [PATCH 3/3] test(dataset): strengthen graph isolation and canonical isomorphism assertions --- .../io/parser/support/ParserTestBase.java | 28 +++---- .../io/parser/support/ParserTestBaseTest.java | 73 +++++++++++++++++++ .../parser/support/ScopedBlankNodeTest.java | 4 +- .../impl/io/parser/trig/TriGCircularTest.java | 1 - .../io/parser/turtle/TurtleCircularTest.java | 1 - .../engine/storage/GraphIsolationTest.java | 2 +- .../sparql/update/UpdateDatasetViewTest.java | 21 ++++++ 7 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBaseTest.java diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBase.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBase.java index 5e05a2670..f5d75a737 100644 --- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBase.java +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBase.java @@ -4,15 +4,14 @@ import fr.inria.corese.core.next.data.Values; import fr.inria.corese.core.next.data.api.factory.ValueFactory; import fr.inria.corese.core.next.data.api.model.Model; -import fr.inria.corese.core.next.data.api.model.Statement; -import fr.inria.corese.core.next.data.api.term.BNode; +import fr.inria.corese.core.next.data.impl.io.serializer.rdfc10.RDFC10Canonicalizer; +import fr.inria.corese.core.next.data.impl.io.serializer.rdfc10.RDFC10SerializerOptions; import fr.inria.corese.core.next.storage.Storages; import fr.inria.corese.core.next.storage.api.config.StorageConfig; import fr.inria.corese.core.next.storage.impl.model.StorageModel; import org.junit.jupiter.api.BeforeEach; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; /** * Base class for parser and circular (round-trip) integration tests. @@ -27,24 +26,17 @@ void setUpBase() { } /** - * Asserts that two models are graph-isomorphic: same size, all non-bnode triples - * match exactly, and bnode-involving triples are accounted for by size equality. - * - *

Use this instead of {@code assertEquals(model1, model2)} when models may - * contain blank nodes, since re-parsed blank nodes receive fresh IDs that differ - * from the originals even when the graph structure is identical.

+ * Compares canonical RDF datasets, preserving triples, graph contexts and blank-node + * structure while allowing blank-node identifiers to differ after parsing. */ protected void assertModelsIsomorphic(Model original, Model deserialized) { assertEquals(original.size(), deserialized.size(), "Model sizes must match"); - for (Statement stmt : original) { - boolean subjectIsBNode = stmt.getSubject() instanceof BNode; - boolean objectIsBNode = stmt.getObject() instanceof BNode; - if (!subjectIsBNode && !objectIsBNode) { - assertTrue( - deserialized.contains(stmt.getSubject(), stmt.getPredicate(), stmt.getObject()), - "Non-bnode triple missing from deserialized model: " + stmt); - } - } + RDFC10SerializerOptions options = RDFC10SerializerOptions.defaultConfig(); + RDFC10Canonicalizer canonicalizer = new RDFC10Canonicalizer( + options.getHashAlgorithm(), options.getPermutationLimit(), + options.getDepthFactor(), Values.factory()); + assertEquals(canonicalizer.canonicalize(original), canonicalizer.canonicalize(deserialized), + "Models must preserve RDF dataset structure, including graph contexts"); } /** diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBaseTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBaseTest.java new file mode 100644 index 000000000..440bf0fda --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ParserTestBaseTest.java @@ -0,0 +1,73 @@ +package fr.inria.corese.core.next.data.impl.io.parser.support; + +import fr.inria.corese.core.next.data.api.model.Model; +import fr.inria.corese.core.next.data.api.term.IRI; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ParserTestBaseTest extends ParserTestBase { + + private IRI iri(String name) { + return valueFactory.createIRI("urn:" + name); + } + + @Test + void acceptsRenamedBlankNodesAcrossSubjectObjectAndGraph() { + Model original = createTestModel(); + Model renamed = createTestModel(); + var a = valueFactory.createBNode("a"); + var b = valueFactory.createBNode("b"); + var x = valueFactory.createBNode("x"); + var y = valueFactory.createBNode("y"); + original.add(a, iri("p"), b, a); + original.add(b, iri("p"), a); + renamed.add(y, iri("p"), x); + renamed.add(x, iri("p"), y, x); + + assertModelsIsomorphic(original, renamed); + } + + @Test + void rejectsChangedBlankNodeTriples() { + Model original = createTestModel(); + Model corrupted = createTestModel(); + original.add(valueFactory.createBNode("a"), iri("p"), valueFactory.createLiteral("original")); + corrupted.add(valueFactory.createBNode("b"), iri("q"), valueFactory.createLiteral("corrupted")); + + assertThrows(AssertionError.class, () -> assertModelsIsomorphic(original, corrupted)); + } + + @Test + void rejectsSplitSharedBlankNode() { + Model original = createTestModel(); + Model corrupted = createTestModel(); + var shared = valueFactory.createBNode("shared"); + original.add(iri("s"), iri("p"), shared); + original.add(shared, iri("q"), iri("o")); + corrupted.add(iri("s"), iri("p"), valueFactory.createBNode("x")); + corrupted.add(valueFactory.createBNode("y"), iri("q"), iri("o")); + + assertThrows(AssertionError.class, () -> assertModelsIsomorphic(original, corrupted)); + } + + @Test + void rejectsChangedNamedGraph() { + Model original = createTestModel(); + Model corrupted = createTestModel(); + original.add(iri("s"), iri("p"), iri("o"), iri("g1")); + corrupted.add(iri("s"), iri("p"), iri("o"), iri("g2")); + + assertThrows(AssertionError.class, () -> assertModelsIsomorphic(original, corrupted)); + } + + @Test + void rejectsNamedGraphMovedToDefaultGraph() { + Model original = createTestModel(); + Model corrupted = createTestModel(); + original.add(iri("s"), iri("p"), iri("o"), iri("g")); + corrupted.add(iri("s"), iri("p"), iri("o")); + + assertThrows(AssertionError.class, () -> assertModelsIsomorphic(original, corrupted)); + } +} diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ScopedBlankNodeTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ScopedBlankNodeTest.java index 57fbd6f35..a14aabff5 100644 --- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ScopedBlankNodeTest.java +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/support/ScopedBlankNodeTest.java @@ -46,7 +46,7 @@ void differentLabelsReturnDifferentBNodes() { AbstractTurtleTriGListener l = listener(); BNode b1 = l.scopedBlankNode("a"); BNode b2 = l.scopedBlankNode("b"); - assertNotSame(b1, b2, + assertNotEquals(b1, b2, "different labels must map to distinct BNode instances"); } @@ -55,7 +55,7 @@ void differentLabelsReturnDifferentBNodes() { void separateListenersProduceDistinctBNodesForSameLabel() { BNode fromFirst = listener().scopedBlankNode("x"); BNode fromSecond = listener().scopedBlankNode("x"); - assertNotSame(fromFirst, fromSecond, + assertNotEquals(fromFirst, fromSecond, "blank nodes from separate parse sessions must be distinct even when labels match"); } } diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGCircularTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGCircularTest.java index c185b42da..727bcb7cb 100644 --- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGCircularTest.java +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/trig/TriGCircularTest.java @@ -378,7 +378,6 @@ void testRoundTripWithBlankNodes() { Model deserializedModel = performRoundTrip(originalModel); // Then: Blank node structure should be preserved (though IDs may differ) - // Note: Blank node equality is based on structure, not IDs assertModelsIsomorphic(originalModel, deserializedModel); } diff --git a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleCircularTest.java b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleCircularTest.java index 4715885df..e53b3756e 100644 --- a/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleCircularTest.java +++ b/src/test/java/fr/inria/corese/core/next/data/impl/io/parser/turtle/TurtleCircularTest.java @@ -337,7 +337,6 @@ void testRoundTripWithBlankNodes() { Model deserializedModel = performRoundTrip(originalModel); // Then: Blank node structure should be preserved (though IDs may differ) - // Note: Blank node equality is based on structure, not IDs assertModelsIsomorphic(originalModel, deserializedModel); } diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/GraphIsolationTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/GraphIsolationTest.java index 28cba6875..4e95f6113 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/GraphIsolationTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/engine/storage/GraphIsolationTest.java @@ -108,7 +108,7 @@ void graphVariableIteratesOnlyNamedGraphs() throws Exception { model.add(iri("s1"), iri("p"), iri("o1"), iri("g1")); model.add(iri("s2"), iri("p"), iri("o2"), iri("g2")); }); - assertEquals(2, graphs.size(), + assertEquals(List.of(EX + "g1", EX + "g2"), graphs.stream().sorted().toList(), "GRAPH ?g must bind to exactly the 2 named-graph IRIs"); } diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetViewTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetViewTest.java index f2079f6a3..616a24759 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetViewTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/update/UpdateDatasetViewTest.java @@ -62,6 +62,27 @@ void noContextsAreRedirectedToDefaultGraph() { } + @Test + @DisplayName("null context is redirected to the WITH graph without changing triple terms") + void nullContextIsRedirectedToDefaultGraph() { + IRI subject = FACTORY.createIRI(EX + "s"); + IRI predicate = FACTORY.createIRI(EX + "p"); + IRI object = FACTORY.createIRI(EX + "o"); + StatementPattern pattern = StatementPattern.of(subject, predicate, object, (Resource) null); + + try (var statements = view.find(pattern)) { + assertEquals(0, statements.count()); + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(StatementPattern.class); + verify(queryOps).find(captor.capture()); + StatementPattern forwarded = captor.getValue(); + assertArrayEquals(new Resource[]{defaultGraph}, forwarded.getContexts()); + assertEquals(subject, forwarded.getSubject()); + assertEquals(predicate, forwarded.getPredicate()); + assertEquals(object, forwarded.getObject()); + } + @Test @DisplayName("pattern with a named context is passed through unchanged") void namedContextIsPassedThroughUnchanged() {