Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -30,6 +33,11 @@ public abstract class AbstractTurtleTriGListener {
public Resource currentSubject;
public IRI currentPredicate;

/**
* Per-document blank-node scope.
*/
private final Map<String, BNode> blankNodeScope = new HashMap<>();

/**
* Constructs a parser listener with the specified model, factory and base URI.
*
Expand All @@ -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.
*
* <p>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 <em>different</em> blank node in every other document.</p>
*
* @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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ public Resource createBNode() {
}

public Resource createBNode(String id) {
return factory.createBNode(id);
return scopedBlankNode(id);
}

public IRI createIRI(String iri) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -53,6 +54,16 @@ static ContextSelection allContexts() {
return of(List.of());
}

/**
* Selects only the default graph (null context).
*
* <p>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}.</p>
*/
static ContextSelection defaultContext() {
return new ContextSelection(Collections.singletonList(null), false);
}

static ContextSelection emptyResult() {
return new ContextSelection(List.of(), true);
}
Expand Down Expand Up @@ -178,9 +189,13 @@ static ContextSelection selectExplicitGraphContext(

static ContextSelection selectDatasetContexts(List<Node> 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<Resource> contexts = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,22 @@
record UpdateDatasetView(StorageManager storage, Resource defaultGraph) implements StorageManager, QueryOperations {
@Override
public Stream<Statement> 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<Statement> statements = find(pattern)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
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.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;

/**
* Base class for parser and circular (round-trip) integration tests.
*/
Expand All @@ -21,6 +25,20 @@ void setUpBase() {
valueFactory = Values.factory();
}

/**
* 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");
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");
}

/**
* Creates a test model using the production Graph backend.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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)}.
*
* <p>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.</p>
*/
@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");
assertNotEquals(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");
assertNotEquals(fromFirst, fromSecond,
"blank nodes from separate parse sessions must be distinct even when labels match");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -381,11 +378,7 @@ 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -340,11 +337,7 @@ 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
Expand Down
Loading
Loading