diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/KgramNodeConverter.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/KgramNodeConverter.java new file mode 100644 index 000000000..26c4a4654 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/KgramNodeConverter.java @@ -0,0 +1,46 @@ +package fr.inria.corese.core.next.query.impl.sparql.bridge; + +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.kgram.api.core.Node; +import fr.inria.corese.core.sparql.api.IDatatype; + +/** + * Converts KGRAM {@link Node} constants to API {@link Value} instances. + * + *

This is the single point in the bridge layer that is allowed to inspect + * {@link IDatatype} on behalf of callers outside {@code next.query.kgram}.

+ */ +public final class KgramNodeConverter { + + private KgramNodeConverter() {} + + /** + * Converts a KGRAM constant {@link Node} to the corresponding API {@link Value}. + * + * @param node the KGRAM node to convert (must not be null) + * @param factory the value factory used to create API term instances + * @return the API value, or {@code null} when the node kind is not supported + */ + public static Value nodeToValue(Node node, CoreseValueFactory factory) { + IDatatype dt = node.getDatatypeValue(); + if (dt.isURI()) { + return factory.createIRI(dt.getLabel()); + } + if (dt.isBlank()) { + return factory.createBNode(dt.getLabel()); + } + if (dt.isLiteral()) { + String lang = dt.getLang(); + if (lang != null && !lang.isEmpty()) { + return factory.createLiteral(dt.getLabel(), lang); + } + String datatypeUri = dt.getDatatypeURI(); + if (datatypeUri != null) { + return factory.createLiteral(dt.getLabel(), factory.createIRI(datatypeUri)); + } + return factory.createLiteral(dt.getLabel()); + } + return null; + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java index 0574490d4..70b19c30a 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java @@ -23,6 +23,7 @@ import fr.inria.corese.core.next.query.impl.sparql.ast.QueryAst; import fr.inria.corese.core.next.query.impl.sparql.ast.SelectQueryAst; import fr.inria.corese.core.next.query.impl.sparql.bridge.CoreseAstQueryBuilder; +import fr.inria.corese.core.next.query.impl.sparql.bridge.KgramNodeConverter; import fr.inria.corese.core.next.query.kgram.api.core.Edge; import fr.inria.corese.core.next.query.kgram.api.core.Node; import fr.inria.corese.core.next.query.kgram.core.Eval; @@ -36,10 +37,6 @@ import fr.inria.corese.core.next.query.kgram.tool.NodeImpl; import fr.inria.corese.core.next.query.kgram.tool.StorageManagerProducer; import fr.inria.corese.core.next.storagemanager.api.StorageManager; -import fr.inria.corese.core.sparql.api.IDatatype; -import fr.inria.corese.core.sparql.datatype.DatatypeMap; -import fr.inria.corese.core.sparql.triple.parser.Constant; -import fr.inria.corese.core.sparql.triple.parser.Variable; import java.util.ArrayList; import java.util.List; @@ -348,28 +345,13 @@ private Node resolveTemplateNode(Node templateNode, Mapping mapping) { /** * Converts a KGRAM constant {@link Node} to the corresponding API {@link Value}. * - * @return the API value, or {@code null} when the datatype kind is not supported + *

Delegates to {@link KgramNodeConverter} so that this class does not depend on + * {@code IDatatype} directly.

+ * + * @return the API value, or {@code null} when the node kind is not supported */ private Value kgramNodeToApiValue(Node node, CoreseValueFactory factory) { - IDatatype dt = node.getDatatypeValue(); - if (dt.isURI()) { - return factory.createIRI(dt.getLabel()); - } - if (dt.isBlank()) { - return factory.createBNode(dt.getLabel()); - } - if (dt.isLiteral()) { - String lang = dt.getLang(); - if (lang != null && !lang.isEmpty()) { - return factory.createLiteral(dt.getLabel(), lang); - } - String datatypeUri = dt.getDatatypeURI(); - if (datatypeUri != null) { - return factory.createLiteral(dt.getLabel(), factory.createIRI(datatypeUri)); - } - return factory.createLiteral(dt.getLabel()); - } - return null; + return KgramNodeConverter.nodeToValue(node, factory); } // ------------------------------------------------------------------------- @@ -399,7 +381,7 @@ private void applyDataset(Query kgramQuery, Dataset dataset) { private List urisToKgramNodes(List uris) { List nodes = new ArrayList<>(uris.size()); for (String uri : uris) { - nodes.add(new NodeImpl(Constant.create(DatatypeMap.newResource(uri)))); + nodes.add(NodeImpl.forIRI(uri)); } return nodes; } @@ -429,7 +411,7 @@ private Mapping buildInitialMapping(BindingSet bindings) { for (Binding b : bindings) { Node targetNode = valueToKgramNode(b.value()); if (targetNode != null) { - queryNodes.add(new NodeImpl(new Variable(b.name()))); + queryNodes.add(NodeImpl.forVariable(b.name())); targetNodes.add(targetNode); } } @@ -442,24 +424,20 @@ private Mapping buildInitialMapping(BindingSet bindings) { * @return a constant node, or {@code null} when the value type is not supported */ private Node valueToKgramNode(Value value) { - IDatatype dt; if (value instanceof IRI iri) { - dt = DatatypeMap.newResource(iri.stringValue()); + return NodeImpl.forIRI(iri.stringValue()); } else if (value instanceof BNode bNode) { - dt = DatatypeMap.createBlank(bNode.getID()); + return NodeImpl.forBlank(bNode.getID()); } else if (value instanceof Literal literal) { String lang = literal.getLanguage().orElse(null); if (lang != null && !lang.isEmpty()) { - dt = DatatypeMap.createLiteral(literal.getLabel(), null, lang); - } else { - String datatypeUri = literal.getDatatype() != null - ? literal.getDatatype().stringValue() - : null; - dt = DatatypeMap.createLiteral(literal.getLabel(), datatypeUri, null); + return NodeImpl.forLiteral(literal.getLabel(), null, lang); } - } else { - return null; + String datatypeUri = literal.getDatatype() != null + ? literal.getDatatype().stringValue() + : null; + return NodeImpl.forLiteral(literal.getLabel(), datatypeUri, null); } - return new NodeImpl(Constant.create(dt)); + return null; } } diff --git a/src/main/java/fr/inria/corese/core/next/query/kgram/tool/NodeImpl.java b/src/main/java/fr/inria/corese/core/next/query/kgram/tool/NodeImpl.java index 65120ff41..37954ef0d 100644 --- a/src/main/java/fr/inria/corese/core/next/query/kgram/tool/NodeImpl.java +++ b/src/main/java/fr/inria/corese/core/next/query/kgram/tool/NodeImpl.java @@ -5,8 +5,10 @@ import fr.inria.corese.core.next.query.kgram.api.core.Node; import fr.inria.corese.core.next.query.kgram.api.core.TripleStore; import fr.inria.corese.core.sparql.api.IDatatype; +import fr.inria.corese.core.sparql.datatype.DatatypeMap; import fr.inria.corese.core.sparql.triple.parser.Atom; import fr.inria.corese.core.sparql.triple.parser.Constant; +import fr.inria.corese.core.sparql.triple.parser.Variable; public class NodeImpl implements Node { @@ -19,6 +21,32 @@ public NodeImpl(Atom at) { atom = at; } + /** Creates a constant node for an IRI. */ + public static NodeImpl forIRI(String iri) { + return new NodeImpl(Constant.create(DatatypeMap.newResource(iri))); + } + + /** Creates a constant node for a blank node. */ + public static NodeImpl forBlank(String id) { + return new NodeImpl(Constant.create(DatatypeMap.createBlank(id))); + } + + /** + * Creates a constant node for a literal. + * + * @param label lexical value + * @param datatypeUri datatype IRI, or {@code null} + * @param lang language tag, or {@code null} + */ + public static NodeImpl forLiteral(String label, String datatypeUri, String lang) { + return new NodeImpl(Constant.create(DatatypeMap.createLiteral(label, datatypeUri, lang))); + } + + /** Creates a variable node with the given name. */ + public static NodeImpl forVariable(String name) { + return new NodeImpl(new Variable(name)); + } + @Override public IDatatype getValue() { return atom.getDatatypeValue(); diff --git a/src/main/java/fr/inria/corese/core/next/util/StringUtils.java b/src/main/java/fr/inria/corese/core/next/util/StringUtils.java index a07f5565c..afb73f8bd 100644 --- a/src/main/java/fr/inria/corese/core/next/util/StringUtils.java +++ b/src/main/java/fr/inria/corese/core/next/util/StringUtils.java @@ -1,7 +1,5 @@ package fr.inria.corese.core.next.util; -import fr.inria.corese.core.sparql.triple.parser.Processor; - public class StringUtils { public static String trimChevronIRIs(String uri) { @@ -101,7 +99,7 @@ public static String escapeForDisplay(String iri) { /** * Strips angle brackets if present, then returns the local part after {@code #}, {@code /}, or {@code :}, - * lowercased for {@link Processor} lookup. + * lowercased. */ public static String localNameFromIriToken(String raw) { String t = raw.trim(); diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/KgramNodeConverterTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/KgramNodeConverterTest.java new file mode 100644 index 000000000..914e3decd --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/KgramNodeConverterTest.java @@ -0,0 +1,87 @@ +package fr.inria.corese.core.next.query.impl.sparql.bridge; + +import fr.inria.corese.core.next.data.api.BNode; +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Literal; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.kgram.api.core.Node; +import fr.inria.corese.core.next.query.kgram.tool.NodeImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class KgramNodeConverterTest { + + private CoreseValueFactory factory; + + @BeforeEach + void setUp() { + factory = new CoreseValueFactory(); + } + + @Test + @DisplayName("IRI node converts to API IRI with the same string value") + void iriNodeConvertsToApiIRI() { + Node node = NodeImpl.forIRI("http://example.org/alice"); + + Value value = KgramNodeConverter.nodeToValue(node, factory); + + assertInstanceOf(IRI.class, value); + assertEquals("http://example.org/alice", value.stringValue()); + } + + @Test + @DisplayName("Blank node converts to API BNode with the same ID") + void blankNodeConvertsToApiBNode() { + Node node = NodeImpl.forBlank("b1"); + + Value value = KgramNodeConverter.nodeToValue(node, factory); + + assertInstanceOf(BNode.class, value); + assertEquals("b1", ((BNode) value).getID()); + } + + @Test + @DisplayName("Language-tagged literal converts to API Literal preserving label and lang") + void langLiteralConvertsToApiLiteral() { + Node node = NodeImpl.forLiteral("hello", null, "en"); + + Value value = KgramNodeConverter.nodeToValue(node, factory); + + assertInstanceOf(Literal.class, value); + Literal lit = (Literal) value; + assertEquals("hello", lit.getLabel()); + assertEquals("en", lit.getLanguage().orElse(null)); + } + + @Test + @DisplayName("Typed literal converts to API Literal preserving label and datatype IRI") + void typedLiteralConvertsToApiLiteral() { + String xsdInteger = "http://www.w3.org/2001/XMLSchema#integer"; + Node node = NodeImpl.forLiteral("42", xsdInteger, null); + + Value value = KgramNodeConverter.nodeToValue(node, factory); + + assertInstanceOf(Literal.class, value); + Literal lit = (Literal) value; + assertEquals("42", lit.getLabel()); + assertNotNull(lit.getDatatype()); + assertEquals(xsdInteger, lit.getDatatype().stringValue()); + assertTrue(lit.getLanguage().isEmpty()); + } + + @Test + @DisplayName("Plain literal (no lang, no explicit datatype) converts to API Literal with label") + void plainLiteralConvertsToApiLiteral() { + Node node = NodeImpl.forLiteral("bare", null, null); + + Value value = KgramNodeConverter.nodeToValue(node, factory); + + assertInstanceOf(Literal.class, value); + assertEquals("bare", ((Literal) value).getLabel()); + } + +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java index 6fc6df0b6..42d9e22ce 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java @@ -2,6 +2,7 @@ import fr.inria.corese.core.next.data.api.IRI; import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Statement; import fr.inria.corese.core.next.data.api.Value; import fr.inria.corese.core.next.data.api.ValueFactory; import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; @@ -9,6 +10,7 @@ import fr.inria.corese.core.next.query.api.exception.QueryTimeoutException; import fr.inria.corese.core.next.query.api.result.Binding; import fr.inria.corese.core.next.query.api.result.BindingSet; +import fr.inria.corese.core.next.query.api.result.GraphQueryResult; import fr.inria.corese.core.next.query.api.result.TupleQueryResult; import fr.inria.corese.core.next.query.impl.dataset.CoreseDataset; import fr.inria.corese.core.next.storagemanager.impl.memory.MemoryStorageManager; @@ -194,6 +196,65 @@ void selectWithDatasetFromReturnsNothingForEmptyNamedGraph() { assertFalse(result.hasNext(), "No results expected for an empty named graph"); } + // ------------------------------------------------------------------------- + // CONSTRUCT / graph evaluation + // ------------------------------------------------------------------------- + + @Test + @DisplayName("CONSTRUCT query materialises triples from WHERE bindings") + void constructQueryMaterialisesTriples() { + GraphQueryResult result = executor.evaluateGraph(""" + CONSTRUCT { ?s ?o } + WHERE { ?s ?o } + """); + + assertTrue(result.hasNext()); + Statement stmt = result.next(); + assertEquals(ALICE, stmt.getSubject().stringValue()); + assertEquals(KNOWS, stmt.getPredicate().stringValue()); + assertEquals(BOB, stmt.getObject().stringValue()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("CONSTRUCT with no matching WHERE returns empty graph result") + void constructWithNoMatchReturnsEmptyResult() { + GraphQueryResult result = executor.evaluateGraph(""" + CONSTRUCT { ?s ?o } + WHERE { ?s ?o } + """); + + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("Graph evaluation rejects non-CONSTRUCT/DESCRIBE queries") + void graphEvaluationRejectsSelectQuery() { + assertThrows( + IllegalArgumentException.class, + () -> executor.evaluateGraph("SELECT * WHERE { ?s ?p ?o }")); + } + + // ------------------------------------------------------------------------- + // Initial bindings — literal and blank-node values + // ------------------------------------------------------------------------- + + @Test + @DisplayName("SELECT with literal initial binding filters results by literal value") + void selectWithLiteralInitialBindingFiltersResults() { + insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); + insert(iri(ALICE), iri(NAME), valueFactory.createLiteral("Alice")); + + BindingSet bindings = singleBinding("name", valueFactory.createLiteral("Bob")); + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s WHERE { ?s <" + NAME + "> ?name }", + bindings, null, 0L); + + assertTrue(result.hasNext()); + assertEquals(BOB, result.next().getValue("s").stringValue()); + assertFalse(result.hasNext(), "Only the triple with literal 'Bob' should match"); + } + // ------------------------------------------------------------------------- // Timeout // ------------------------------------------------------------------------- diff --git a/src/test/java/fr/inria/corese/core/next/query/kgram/tool/NodeImplFactoryTest.java b/src/test/java/fr/inria/corese/core/next/query/kgram/tool/NodeImplFactoryTest.java new file mode 100644 index 000000000..44f692840 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/kgram/tool/NodeImplFactoryTest.java @@ -0,0 +1,106 @@ +package fr.inria.corese.core.next.query.kgram.tool; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("NodeImpl factory methods: forIRI, forBlank, forLiteral, forVariable") +class NodeImplFactoryTest { + + @Test + @DisplayName("forIRI creates a constant non-variable non-blank node with the given label") + void forIRICreatesConstantIRINode() { + NodeImpl node = NodeImpl.forIRI("http://example.org/alice"); + + assertTrue(node.isConstant()); + assertFalse(node.isVariable()); + assertFalse(node.isBlank()); + assertEquals("http://example.org/alice", node.getLabel()); + } + + @Test + @DisplayName("forIRI: two nodes with the same URI compare as the same RDF term") + void twoIRINodesWithSameURIAreSameTerm() { + NodeImpl a = NodeImpl.forIRI("http://example.org/alice"); + NodeImpl b = NodeImpl.forIRI("http://example.org/alice"); + + assertNotSame(a, b); + assertTrue(a.same(b)); + assertEquals(0, a.compare(b)); + } + + @Test + @DisplayName("forIRI: two nodes with different URIs are not the same RDF term") + void twoIRINodesWithDifferentURIAreNotSame() { + NodeImpl a = NodeImpl.forIRI("http://example.org/alice"); + NodeImpl b = NodeImpl.forIRI("http://example.org/bob"); + + assertFalse(a.same(b)); + } + + @Test + @DisplayName("forBlank creates a constant blank node with the given ID as label") + void forBlankCreatesBlankNode() { + NodeImpl node = NodeImpl.forBlank("b42"); + + assertTrue(node.isConstant()); + assertTrue(node.isBlank()); + assertFalse(node.isVariable()); + assertEquals("b42", node.getLabel()); + } + + @Test + @DisplayName("forLiteral creates a constant non-blank non-variable node with the given label") + void forLiteralCreatesLiteralNode() { + NodeImpl node = NodeImpl.forLiteral("hello", "http://www.w3.org/2001/XMLSchema#string", null); + + assertTrue(node.isConstant()); + assertFalse(node.isVariable()); + assertFalse(node.isBlank()); + assertEquals("hello", node.getLabel()); + } + + @Test + @DisplayName("forLiteral with lang tag stores the label correctly") + void forLiteralWithLangStoresLabel() { + NodeImpl node = NodeImpl.forLiteral("bonjour", null, "fr"); + + assertEquals("bonjour", node.getLabel()); + assertTrue(node.isConstant()); + assertFalse(node.isBlank()); + } + + @Test + @DisplayName("forVariable creates a variable non-constant node with the given name") + void forVariableCreatesVariableNode() { + NodeImpl node = NodeImpl.forVariable("x"); + + assertTrue(node.isVariable()); + assertFalse(node.isConstant()); + assertFalse(node.isBlank()); + assertEquals("x", node.getLabel()); + } + + @Test + @DisplayName("forVariable: two variables with the same name compare as same") + void twoVariablesWithSameNameAreSame() { + NodeImpl a = NodeImpl.forVariable("x"); + NodeImpl b = NodeImpl.forVariable("x"); + + assertNotSame(a, b); + assertTrue(a.same(b)); + } + + @Test + @DisplayName("forVariable: two variables with different names are not same") + void twoVariablesWithDifferentNamesAreNotSame() { + NodeImpl a = NodeImpl.forVariable("x"); + NodeImpl b = NodeImpl.forVariable("y"); + + assertFalse(a.same(b)); + } +}