From 894fc45964449f28e688bc1599c9f3f023657adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?= Date: Fri, 18 Sep 2026 14:45:20 +0200 Subject: [PATCH] refactor(query): extract DescribeQueryCompiler and split NextSparqlPipelineExecutorTest into domain suites --- .../sparql/bridge/CoreseAstQueryBuilder.java | 75 +- .../sparql/bridge/DescribeQueryCompiler.java | 106 ++ ...xtSparqlPipelineAggregateExecutorTest.java | 328 +++++ ...NextSparqlPipelineDatasetExecutorTest.java | 217 ++++ .../NextSparqlPipelineExecutorTest.java | 1077 +---------------- ...tSparqlPipelineExpressionExecutorTest.java | 333 +++++ ...parqlPipelinePropertyPathExecutorTest.java | 104 ++ ...xtSparqlPipelineQueryFormExecutorTest.java | 105 ++ .../sparql/execution/PipelineTestSupport.java | 67 + 9 files changed, 1270 insertions(+), 1142 deletions(-) create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DescribeQueryCompiler.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineAggregateExecutorTest.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineDatasetExecutorTest.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExpressionExecutorTest.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelinePropertyPathExecutorTest.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineQueryFormExecutorTest.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/PipelineTestSupport.java diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilder.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilder.java index be0cd5304..fce2bacab 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilder.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/CoreseAstQueryBuilder.java @@ -146,8 +146,7 @@ public Query toNextQuery(DescribeQueryAst describeQueryAst) { describeQueryAst.valuesClause(), compiler); applyOrderBy(query, describeQueryAst.solutionModifier(), compiler); - List describedNodes = describeNodes(query, describeQueryAst, compiler); - lowerDescribeToConstructQuery(query, describedNodes); + DescribeQueryCompiler.compile(query, describeQueryAst, compiler); query.setAST(describeQueryAst); return query; } @@ -365,78 +364,6 @@ private Node groupByNode(Query query, String name) { return null; } - /** - * Resolves the resources of a {@code DESCRIBE} against the compiled body. - * - *

{@code DESCRIBE *} reuses the in-scope nodes of the body (like {@code SELECT *}). - * A described variable reuses its runtime node (so it is the one bound by the body) and - * fails fast when it is not visible; a described IRI becomes a fresh constant node.

- */ - private List describeNodes( - Query query, DescribeQueryAst describeQueryAst, WhereCompiler compiler) { - if (describeQueryAst.isDescribeAll()) { - return query.selectNodesFromPattern(); - } - List nodes = new ArrayList<>(); - for (TermAst term : describeQueryAst.described()) { - if (term instanceof VarAst(String name)) { - Node node = visibleBodyNode(query, name); - if (node == null) { - throw new IllegalArgumentException( - "DESCRIBE variable ?" + name + " is not visible in the compiled query body"); - } - nodes.add(node); - } else { - nodes.add(compiler.termResolver().toNode(term)); - } - } - return nodes; - } - - /** - * Lowers {@code DESCRIBE} to the construct-like shape expected by the current - * KGRAM-next runtime. - * - *

This keeps the current KGRAM contract, inherited from the historical pipeline: - * {@code DESCRIBE} is executed through the construct runtime path.

- */ - private void lowerDescribeToConstructQuery(Query query, List describedNodes) { - Exp constructTemplate = Exp.create(Type.BGP); - int syntheticIndex = 0; - for (Node describedNode : describedNodes) { - DescribePattern describePattern = describePattern(describedNode, syntheticIndex++); - // KGRAM-next currently represents DESCRIBE with outgoing and incoming construct triples. - constructTemplate.add(describePattern.outgoing().getEdge()); - constructTemplate.add(describePattern.incoming().getEdge()); - query.getBody().add(Exp.create(Type.OPTIONAL, Exp.create(Type.AND), describePattern.optionalBody())); - } - query.setConstruct(constructTemplate); - query.setConstruct(true); - query.setConstructNodes(constructTemplate.getNodes()); - } - - private DescribePattern describePattern(Node describedNode, int index) { - Node outgoingPredicate = createSyntheticDescribeNode("p", index, 0); - Node outgoingValue = createSyntheticDescribeNode("v", index, 0); - Node incomingPredicate = createSyntheticDescribeNode("p", index, 1); - Node incomingValue = createSyntheticDescribeNode("v", index, 1); - - Exp outgoing = Exp.create(Type.EDGE, new AstBackedEdge(describedNode, outgoingPredicate, outgoingValue)); - Exp incoming = Exp.create(Type.EDGE, new AstBackedEdge(incomingValue, incomingPredicate, describedNode)); - Exp outgoingBgp = Exp.create(Type.BGP); - outgoingBgp.add(outgoing); - Exp incomingBgp = Exp.create(Type.BGP); - incomingBgp.add(incoming); - return new DescribePattern(outgoing, incoming, Exp.create(Type.UNION, outgoingBgp, incomingBgp)); - } - - private Node createSyntheticDescribeNode(String role, int describedIndex, int directionIndex) { - return NodeImpl.forVariable("__describe_" + role + "_" + describedIndex + "_" + directionIndex); - } - - private record DescribePattern(Exp outgoing, Exp incoming, Exp optionalBody) { - } - /** * Maps {@code ORDER BY} conditions that are already expressible in runtime KGRAM terms. * diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DescribeQueryCompiler.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DescribeQueryCompiler.java new file mode 100644 index 000000000..f4db7b23d --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DescribeQueryCompiler.java @@ -0,0 +1,106 @@ +package fr.inria.corese.core.next.query.impl.sparql.bridge; + +import fr.inria.corese.core.next.query.impl.engine.model.ExpType.Type; +import fr.inria.corese.core.next.query.impl.engine.model.Node; +import fr.inria.corese.core.next.query.impl.engine.model.NodeImpl; +import fr.inria.corese.core.next.query.impl.engine.pattern.Exp; +import fr.inria.corese.core.next.query.impl.engine.pattern.Query; +import fr.inria.corese.core.next.query.impl.sparql.ast.DescribeQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst; + +import java.util.ArrayList; +import java.util.List; + +/** Compiles DESCRIBE resources into incoming and outgoing graph construction patterns. */ +final class DescribeQueryCompiler { + private DescribeQueryCompiler() { + } + + /** + * Adds the DESCRIBE patterns to an initialized query shell. + * + * @param query query with its body, dataset and modifiers already compiled + * @param description resources requested by DESCRIBE + * @param compiler compiler carrying the query prologue + * @throws IllegalArgumentException if a described variable is not visible + */ + static void compile(Query query, DescribeQueryAst description, WhereCompiler compiler) { + lowerDescribeToConstructQuery(query, describeNodes(query, description, compiler)); + } + + /** + * Resolves the resources of a {@code DESCRIBE} against the compiled body. + * + *

{@code DESCRIBE *} reuses the in-scope nodes of the body (like {@code SELECT *}). + * A described variable reuses its runtime node (so it is the one bound by the body) and + * fails fast when it is not visible; a described IRI becomes a fresh constant node.

+ */ + private static List describeNodes( + Query query, DescribeQueryAst describeQueryAst, WhereCompiler compiler) { + if (describeQueryAst.isDescribeAll()) { + return query.selectNodesFromPattern(); + } + List nodes = new ArrayList<>(); + for (TermAst term : describeQueryAst.described()) { + if (term instanceof VarAst(String name)) { + Node node = query.selectNodesFromPattern().stream() + .filter(candidate -> candidate.getLabel().equals(name)) + .findFirst().orElse(null); + if (node == null) { + throw new IllegalArgumentException( + "DESCRIBE variable ?" + name + " is not visible in the compiled query body"); + } + nodes.add(node); + } else { + nodes.add(compiler.termResolver().toNode(term)); + } + } + return nodes; + } + + /** + * Lowers {@code DESCRIBE} to the construct-like shape expected by the current + * KGRAM-next runtime. + * + *

This keeps the current KGRAM contract, inherited from the historical pipeline: + * {@code DESCRIBE} is executed through the construct runtime path.

+ */ + private static void lowerDescribeToConstructQuery(Query query, List describedNodes) { + Exp constructTemplate = Exp.create(Type.BGP); + int syntheticIndex = 0; + for (Node describedNode : describedNodes) { + DescribePattern describePattern = describePattern(describedNode, syntheticIndex++); + // KGRAM-next currently represents DESCRIBE with outgoing and incoming construct triples. + constructTemplate.add(describePattern.outgoing().getEdge()); + constructTemplate.add(describePattern.incoming().getEdge()); + query.getBody().add(Exp.create(Type.OPTIONAL, Exp.create(Type.AND), describePattern.optionalBody())); + } + query.setConstruct(constructTemplate); + query.setConstruct(true); + query.setConstructNodes(constructTemplate.getNodes()); + } + + private static DescribePattern describePattern(Node describedNode, int index) { + Node outgoingPredicate = createSyntheticDescribeNode("p", index, 0); + Node outgoingValue = createSyntheticDescribeNode("v", index, 0); + Node incomingPredicate = createSyntheticDescribeNode("p", index, 1); + Node incomingValue = createSyntheticDescribeNode("v", index, 1); + + Exp outgoing = Exp.create(Type.EDGE, new AstBackedEdge(describedNode, outgoingPredicate, outgoingValue)); + Exp incoming = Exp.create(Type.EDGE, new AstBackedEdge(incomingValue, incomingPredicate, describedNode)); + Exp outgoingBgp = Exp.create(Type.BGP); + outgoingBgp.add(outgoing); + Exp incomingBgp = Exp.create(Type.BGP); + incomingBgp.add(incoming); + return new DescribePattern(outgoing, incoming, Exp.create(Type.UNION, outgoingBgp, incomingBgp)); + } + + private static Node createSyntheticDescribeNode(String role, int describedIndex, int directionIndex) { + return NodeImpl.forVariable("__describe_" + role + "_" + describedIndex + "_" + directionIndex); + } + + private record DescribePattern(Exp outgoing, Exp incoming, Exp optionalBody) { + } + +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineAggregateExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineAggregateExecutorTest.java new file mode 100644 index 000000000..c26d14b4e --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineAggregateExecutorTest.java @@ -0,0 +1,328 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + + +import fr.inria.corese.core.next.data.api.term.Literal; +import fr.inria.corese.core.next.data.api.literal.XSDDatatype; +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.assertTrue; + +/** End-to-end aggregate pipeline tests. */ +class NextSparqlPipelineAggregateExecutorTest extends PipelineTestSupport { + + @Test + @DisplayName("GROUP BY and COUNT aggregate count solutions per group") + void groupByAndCountAggregate() { + String charlie = "http://example.org/charlie"; + insert(iri(ALICE), iri(KNOWS), iri(charlie)); + insert(iri(BOB), iri(KNOWS), iri(charlie)); + + try (var result = executor.evaluateTuple(""" + SELECT ?s (COUNT(?o) AS ?cnt) WHERE { + ?s ?o . + } + GROUP BY ?s + ORDER BY ?s + """)) { + assertTrue(result.hasNext()); + var row1 = result.next(); + assertEquals(ALICE, row1.getValue("s").stringValue()); + assertEquals("2", row1.getValue("cnt").stringValue()); + + assertTrue(result.hasNext()); + var row2 = result.next(); + assertEquals(BOB, row2.getValue("s").stringValue()); + assertEquals("1", row2.getValue("cnt").stringValue()); + + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("COUNT(*) and COUNT(DISTINCT) evaluate properly over whole solution") + void countStarAndDistinct() { + String tag = "http://example.org/tag"; + insert(iri(ALICE), iri(tag), valueFactory.createLiteral("java")); + insert(iri(ALICE), iri(tag), valueFactory.createLiteral("sparql")); + insert(iri(BOB), iri(tag), valueFactory.createLiteral("java")); + + try (var result = executor.evaluateTuple(""" + SELECT (COUNT(*) AS ?total) (COUNT(DISTINCT ?val) AS ?uniqueTags) WHERE { + ?s ?val . + } + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals("3", row.getValue("total").stringValue()); + assertEquals("2", row.getValue("uniqueTags").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("SUM and AVG aggregates promote types and calculate correct totals") + void sumAndAvgAggregates() { + String score = "http://example.org/score"; + insert(iri(ALICE), iri(score), valueFactory.createLiteral(10)); + insert(iri(ALICE), iri(score), valueFactory.createLiteral(20)); + + try (var result = executor.evaluateTuple(""" + SELECT ?s (SUM(?score) AS ?total) (AVG(?score) AS ?average) WHERE { + ?s ?score . + } + GROUP BY ?s + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals(ALICE, row.getValue("s").stringValue()); + assertEquals("30", row.getValue("total").stringValue()); + assertEquals("15", row.getValue("average").stringValue()); + assertEquals(XSDDatatype.DECIMAL.getIRI(), ((Literal) row.getValue("average")).getDatatype()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("MIN, MAX, and SAMPLE aggregates select extreme and sample values") + void minMaxSampleAggregates() { + String score = "http://example.org/score"; + insert(iri(ALICE), iri(score), valueFactory.createLiteral(5)); + insert(iri(ALICE), iri(score), valueFactory.createLiteral(25)); + insert(iri(ALICE), iri(score), valueFactory.createLiteral(15)); + + try (var result = executor.evaluateTuple(""" + SELECT ?s (MIN(?score) AS ?minVal) (MAX(?score) AS ?maxVal) (SAMPLE(?score) AS ?sampleVal) WHERE { + ?s ?score . + } + GROUP BY ?s + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals(ALICE, row.getValue("s").stringValue()); + assertEquals("5", row.getValue("minVal").stringValue()); + assertEquals("25", row.getValue("maxVal").stringValue()); + assertTrue(row.hasBinding("sampleVal")); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("GROUP_CONCAT aggregates strings with custom separator") + void groupConcatAggregate() { + String tag = "http://example.org/tag"; + insert(iri(ALICE), iri(tag), valueFactory.createLiteral("A")); + insert(iri(ALICE), iri(tag), valueFactory.createLiteral("B")); + + try (var result = executor.evaluateTuple(""" + SELECT ?s (GROUP_CONCAT(?tag; separator=";") AS ?tags) WHERE { + ?s ?tag . + } + GROUP BY ?s + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals(ALICE, row.getValue("s").stringValue()); + String tags = row.getValue("tags").stringValue(); + assertTrue("A;B".equals(tags) || "B;A".equals(tags)); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("GROUP BY with expression alias binds alias for projection") + void groupByExpressionAlias() { + String val = "http://example.org/val"; + insert(iri(ALICE), iri(val), valueFactory.createLiteral(10)); + insert(iri(BOB), iri(val), valueFactory.createLiteral(10)); + + try (var result = executor.evaluateTuple(""" + SELECT ?next (COUNT(?s) AS ?cnt) WHERE { + ?s ?v . + } + GROUP BY (?v + 1 AS ?next) + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals("11", row.getValue("next").stringValue()); + assertEquals("2", row.getValue("cnt").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("HAVING filters groups based on aggregate condition") + void havingWithAggregateCondition() { + String tag = "http://example.org/tag"; + insert(iri(ALICE), iri(tag), valueFactory.createLiteral("t1")); + insert(iri(ALICE), iri(tag), valueFactory.createLiteral("t2")); + insert(iri(BOB), iri(tag), valueFactory.createLiteral("t1")); + + try (var result = executor.evaluateTuple(""" + SELECT ?s (COUNT(?tag) AS ?cnt) WHERE { + ?s ?tag . + } + GROUP BY ?s + HAVING (COUNT(?tag) > 1) + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals(ALICE, row.getValue("s").stringValue()); + assertEquals("2", row.getValue("cnt").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("Implicit aggregate on empty dataset returns exactly 1 row") + void implicitAggregateOnEmptyDatasetReturnsOneRow() { + try (var result = executor.evaluateTuple(""" + SELECT (COUNT(?o) AS ?count) (SUM(?score) AS ?sum) (AVG(?score) AS ?avg) (GROUP_CONCAT(?o) AS ?gc) WHERE { + ?s ?o . + OPTIONAL { ?s ?score } + } + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals("0", row.getValue("count").stringValue()); + assertEquals("0", row.getValue("sum").stringValue()); + assertFalse(row.hasBinding("avg")); + assertEquals("", row.getValue("gc").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("GROUP BY on empty dataset returns 0 rows") + void groupByOnEmptyDatasetReturnsZeroRows() { + try (var result = executor.evaluateTuple(""" + SELECT ?s (COUNT(?o) AS ?count) WHERE { + ?s ?o . + } + GROUP BY ?s + """)) { + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("Projection expression with aggregate arithmetic") + void projectionExpressionWithAggregateArithmetic() { + try (var result = executor.evaluateTuple(""" + SELECT (COUNT(?s) AS ?c) ((COUNT(?s) + 5) AS ?cPlus) WHERE { + ?s ?o . + } + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals("1", row.getValue("c").stringValue()); + assertEquals("6", row.getValue("cPlus").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("ORDER BY with aggregate expression sorts groups") + void orderByAggregateExpression() { + String score = "http://example.org/score"; + insert(iri(ALICE), iri(score), valueFactory.createLiteral(10)); + insert(iri(BOB), iri(score), valueFactory.createLiteral(20)); + insert(iri(BOB), iri(score), valueFactory.createLiteral(30)); + + try (var result = executor.evaluateTuple(""" + SELECT ?s (SUM(?val) AS ?total) WHERE { + ?s ?val . + } + GROUP BY ?s + ORDER BY DESC(?total) + """)) { + assertTrue(result.hasNext()); + var row1 = result.next(); + assertEquals(BOB, row1.getValue("s").stringValue()); + assertEquals("50", row1.getValue("total").stringValue()); + + assertTrue(result.hasNext()); + var row2 = result.next(); + assertEquals(ALICE, row2.getValue("s").stringValue()); + assertEquals("10", row2.getValue("total").stringValue()); + + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("GROUP_CONCAT concatenates blank nodes preserving their string representations") + void groupConcatBlankNodes() { + String edge = "http://example.org/item"; + var b1 = valueFactory.createBNode("b1"); + var b2 = valueFactory.createBNode("b2"); + insert(iri(ALICE), iri(edge), b1); + insert(iri(ALICE), iri(edge), b2); + + try (var result = executor.evaluateTuple(""" + SELECT ?s (GROUP_CONCAT(?o ; SEPARATOR=",") AS ?concat) WHERE { + ?s ?o . + } + GROUP BY ?s + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals(ALICE, row.getValue("s").stringValue()); + String concat = row.getValue("concat").stringValue(); + assertTrue(concat.contains(b1.stringValue()) && concat.contains(b2.stringValue()), + "GROUP_CONCAT must include blank node string representations"); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("GROUP BY with multiple identical expression aliases binds each alias") + void groupByMultipleIdenticalExpressionAliases() { + String num = "http://example.org/num"; + insert(iri(ALICE), iri(num), valueFactory.createLiteral(5)); + + try (var result = executor.evaluateTuple(""" + SELECT ?y ?z (COUNT(*) AS ?cnt) WHERE { + ?s ?val . + } + GROUP BY (?val + 1 AS ?y) (?val + 1 AS ?z) + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals("6", row.getValue("y").stringValue()); + assertEquals("6", row.getValue("z").stringValue()); + assertEquals("1", row.getValue("cnt").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("Combined query with GROUP BY, Aggregates, HAVING and ORDER BY") + void combinedGroupByAggregatesHavingOrderBy() { + String score = "http://example.org/score"; + insert(iri(ALICE), iri(score), valueFactory.createLiteral(10)); + insert(iri(ALICE), iri(score), valueFactory.createLiteral(5)); + insert(iri(BOB), iri(score), valueFactory.createLiteral(20)); + insert(iri(BOB), iri(score), valueFactory.createLiteral(30)); + + try (var result = executor.evaluateTuple(""" + SELECT ?s (SUM(?val) AS ?total) (COUNT(?val) AS ?count) WHERE { + ?s ?val . + } + GROUP BY ?s + HAVING (SUM(?val) > 20) + ORDER BY DESC(?total) + """)) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals(BOB, row.getValue("s").stringValue()); + assertEquals("50", row.getValue("total").stringValue()); + assertEquals("2", row.getValue("count").stringValue()); + assertFalse(result.hasNext()); + } + } + +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineDatasetExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineDatasetExecutorTest.java new file mode 100644 index 000000000..78b79fa73 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineDatasetExecutorTest.java @@ -0,0 +1,217 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + + +import fr.inria.corese.core.next.data.api.term.IRI; +import fr.inria.corese.core.next.query.api.dataset.Dataset; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** End-to-end dataset pipeline tests. */ +class NextSparqlPipelineDatasetExecutorTest extends PipelineTestSupport { + + @Test + @DisplayName("VALUES joins graph patterns at its position and rejects incompatible mappings") + void inlineValuesJoinsGraphPatterns() { + insert(iri(BOB), iri(KNOWS), iri(ALICE)); + + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?person ?friend WHERE { + VALUES ?person { } + ?person ?friend . + } + """); + + assertTrue(result.hasNext()); + var binding = result.next(); + assertEquals(ALICE, binding.getValue("person").stringValue()); + assertEquals(BOB, binding.getValue("friend").stringValue()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("exists-graph-variable: EXISTS uses the outer graph binding") + void existsGraphVariable() { + String ex = "http://www.example.org/"; + IRI graph = iri(ex + "exists-graph-variable.ttl"); + // The W3C fixture is loaded as both default data and a named graph; + // its relative <> resolves to the fixture's graph IRI. + insert(iri(ex + "s1"), iri(ex + "p"), graph); + insert(iri(ex + "s2"), iri(ex + "p"), iri(ex + "o2")); + insertInGraph(iri(ex + "s1"), iri(ex + "p"), graph, graph); + insertInGraph(iri(ex + "s2"), iri(ex + "p"), iri(ex + "o2"), graph); + try (var result = executor.evaluateTuple(""" + PREFIX : + SELECT ?s WHERE { + ?s :p ?g . + FILTER EXISTS { GRAPH ?g { ?s2 :p ?o2 } } + } + """)) { + assertEquals(List.of(ex + "s1"), + result.stream().map(row -> row.getValue("s").stringValue()).toList()); + } + } + + @Test + @DisplayName("EXISTS waits for its GRAPH variable even after an unrelated BIND") + void existsGraphVariableWaitsForOuterBinding() { + String ex = "http://www.example.org/"; + IRI graph = iri(ex + "g"); + insert(iri(ex + "s1"), iri(ex + "p"), graph); + insert(iri(ex + "s2"), iri(ex + "p"), iri(ex + "missing")); + insertInGraph(iri(ex + "a"), iri(ex + "q"), iri(ex + "b"), graph); + try (var result = executor.evaluateTuple(""" + PREFIX : + SELECT ?s { + BIND(1 AS ?unrelated) + ?s :p ?g . + FILTER EXISTS { GRAPH ?g { ?s2 :q ?o2 } } + } + """)) { + assertEquals(List.of(ex + "s1"), + result.stream().map(row -> row.getValue("s").stringValue()).toList()); + } + } + + @Test + @DisplayName("Default graph merge removes duplicate triples, preserving projected and named-graph rows") + void defaultGraphMergePreservesSolutionMultiplicity() { + IRI firstGraph = iri("http://example.org/g1"); + IRI secondGraph = iri("http://example.org/g2"); + insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), firstGraph); + insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), secondGraph); + insertInGraph(iri(ALICE), iri(KNOWS), iri("http://example.org/carol"), secondGraph); + Dataset dataset = Dataset.builder().defaultGraph(firstGraph).defaultGraph(secondGraph) + .namedGraph(firstGraph).namedGraph(secondGraph).build(); + try (var result = executor.evaluateTuple( + "SELECT ?s { ?s ?o }", null, dataset, 0L)) { + assertEquals(List.of(ALICE, ALICE), + result.stream().map(row -> row.getValue("s").stringValue()).toList()); + } + try (var result = executor.evaluateTuple( + "SELECT ?g { GRAPH ?g { ?s ?o } }", null, dataset, 0L)) { + assertEquals(3, result.stream().count()); + } + } + + @Test + @DisplayName("SELECT with dataset FROM restricts results to the named graph") + void selectWithDatasetFromRestrictsToNamedGraph() { + String graph1 = "http://example.org/graph1"; + String graph2 = "http://example.org/graph2"; + String carol = "http://example.org/carol"; + + // alice→knows→bob in graph1, alice→knows→carol in graph2 + insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri(graph1)); + insertInGraph(iri(ALICE), iri(KNOWS), iri(carol), iri(graph2)); + + Dataset dataset = Dataset.builder().defaultGraph(iri(graph1)).build(); + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?o WHERE { <" + ALICE + "> <" + KNOWS + "> ?o }", + null, dataset, 0L); + + assertTrue(result.hasNext()); + assertEquals(BOB, result.next().getValue("o").stringValue()); + assertFalse(result.hasNext(), "Only data from graph1 should be visible"); + } + + @Test + @DisplayName("SELECT with dataset FROM returns nothing when named graph is empty") + void selectWithDatasetFromReturnsNothingForEmptyNamedGraph() { + String emptyGraph = "http://example.org/empty"; + + Dataset dataset = Dataset.builder().defaultGraph(iri(emptyGraph)).build(); + TupleQueryResult result = executor.evaluateTuple( + "SELECT * WHERE { ?s ?p ?o }", + null, dataset, 0L); + + assertFalse(result.hasNext(), "No results expected for an empty named graph"); + } + + @Test + @DisplayName("An explicit empty dataset hides repository data") + void explicitEmptyDatasetHasNoDefaultGraph() { + TupleQueryResult result = executor.evaluateTuple( + "SELECT * WHERE { ?s ?p ?o }", + null, Dataset.empty(), 0L); + + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("A named-only dataset has an empty default graph") + void namedOnlyDatasetHasNoDefaultGraph() { + String graph = "http://example.org/graph"; + insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri(graph)); + Dataset dataset = Dataset.builder().namedGraph(iri(graph)).build(); + + TupleQueryResult defaultResult = executor.evaluateTuple( + "SELECT * WHERE { ?s ?p ?o }", null, dataset, 0L); + + assertFalse(defaultResult.hasNext()); + } + + // ------------------------------------------------------------------------- + // CONSTRUCT / graph evaluation + // ------------------------------------------------------------------------- + + @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 + void valuesInsideGraphPreservesUnboundGraphRows() { + insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri("http://example.org/g1")); + insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri("http://example.org/g2")); + try (var result = executor.evaluateTuple(""" + SELECT ?g ?t WHERE { + GRAPH ?g { + VALUES (?g ?t) { (UNDEF "foo") ( "bar") } + } + } + """)) { + assertEquals(List.of("http://example.org/g1:bar", "http://example.org/g1:foo", "http://example.org/g2:foo"), + result.stream().map(row -> row.getValue("g").stringValue() + ":" + row.getValue("t").stringValue()) + .sorted().toList()); + } + } + + @Test + void graphNameIsNotAnInputBindingForItsInnerBind() { + insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri("http://example.org/g1")); + try (var result = executor.evaluateTuple("SELECT ?g ?copy { GRAPH ?g { BIND(?g AS ?copy) } }")) { + assertTrue(result.hasNext()); + var row = result.next(); + assertEquals("http://example.org/g1", row.getValue("g").stringValue()); + assertFalse(row.hasBinding("copy")); + assertFalse(result.hasNext()); + } + } + + @Test + void graphWithValuesRequiresAnExistingNamedGraph() { + insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri("http://example.org/g1")); + assertTrue(executor.evaluateBoolean("ASK { GRAPH { VALUES ?x { 1 } } }")); + assertFalse(executor.evaluateBoolean("ASK { GRAPH { VALUES ?x { 1 } } }")); + assertFalse(executor.evaluateBoolean(""" + ASK FROM NAMED + { GRAPH { VALUES ?x { 1 } } } + """)); + } + +} 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 1fe7a2103..22135ebac 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 @@ -1,26 +1,12 @@ package fr.inria.corese.core.next.query.impl.sparql.execution; -import fr.inria.corese.core.next.data.Values; -import fr.inria.corese.core.next.data.api.term.IRI; import fr.inria.corese.core.next.data.api.term.Literal; -import fr.inria.corese.core.next.data.api.term.Resource; -import fr.inria.corese.core.next.data.api.model.Statement; -import fr.inria.corese.core.next.data.api.term.Value; -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.query.api.dataset.Dataset; 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.storage.impl.memory.MemoryStorageManager; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.util.Iterator; import java.util.List; import java.util.Set; @@ -29,32 +15,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -/** - * End-to-end tests for the minimal autonomous SELECT/ASK execution path. - * - *

These tests intentionally use {@link MemoryStorageManager} instead of - * mocks, so they exercise the parser, AST-to-KGRAM bridge, KGRAM evaluation, - * {@code StorageManagerProducer}, and result adaptation together.

- */ -class NextSparqlPipelineExecutorTest { - - private static final String ALICE = "http://example.org/alice"; - private static final String BOB = "http://example.org/bob"; - private static final String KNOWS = "http://example.org/knows"; - private static final String NAME = "http://example.org/name"; - - private ValueFactory valueFactory; - private MemoryStorageManager storage; - private NextSparqlPipelineExecutor executor; - - @BeforeEach - void setUp() { - valueFactory = Values.factory(); - storage = MemoryStorageManager.builder().build(); - executor = new NextSparqlPipelineExecutor(storage); - - insert(iri(ALICE), iri(KNOWS), iri(BOB)); - } +/** End-to-end core pipeline tests. */ +class NextSparqlPipelineExecutorTest extends PipelineTestSupport { @Test @DisplayName("SELECT * WHERE { ?s ?p ?o } runs through the next pipeline") @@ -120,45 +82,6 @@ void selectReducedStarExecutes() { } } - @Test - @DisplayName("FILTER evaluates native numeric expressions") - void filterRunsThroughNativeExpressionEvaluator() { - String age = "http://example.org/age"; - insert(iri(ALICE), iri(age), valueFactory.createLiteral(42)); - insert(iri(BOB), iri(age), valueFactory.createLiteral(18)); - - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?person WHERE { - ?person ?age . - FILTER(?age >= 21) - } - ORDER BY ?person - """); - - assertTrue(result.hasNext()); - assertEquals(ALICE, result.next().getValue("person").stringValue()); - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("BIND evaluates native arithmetic and exposes the result") - void bindRunsThroughNativeExpressionEvaluator() { - String age = "http://example.org/age"; - insert(iri(ALICE), iri(age), valueFactory.createLiteral(41)); - - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?nextAge WHERE { - ?age . - BIND(?age + 1 AS ?nextAge) - } - """); - - assertTrue(result.hasNext()); - assertEquals(42, ((fr.inria.corese.core.next.data.api.term.Literal) - result.next().getValue("nextAge")).intValue()); - assertFalse(result.hasNext()); - } - @Test @DisplayName("BIND in a UNION branch leaves references outside its group unbound") void bindInUnionBranchDoesNotReceiveOuterBindings() { @@ -196,85 +119,6 @@ void orderByUsesNativeRdfValueOrder() { assertFalse(result.hasNext()); } - @Test - @DisplayName("FILTER numeric comparison does not reuse RDF term tie-breakers") - void filterUsesValueComparisonInsteadOfTotalTermOrder() { - String rank = "http://example.org/rank"; - insert(iri(ALICE), iri(rank), valueFactory.createLiteral(1)); - - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?rank WHERE { - ?rank . - FILTER(?rank < 1.0) - } - """); - - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("STRLEN counts Unicode code points through the native evaluator") - void stringLengthUsesUnicodeCodePoints() { - insert(iri(ALICE), iri(NAME), valueFactory.createLiteral("A🙂")); - - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?length WHERE { - ?name . - BIND(STRLEN(?name) AS ?length) - } - """); - - assertTrue(result.hasNext()); - assertEquals(2, ((fr.inria.corese.core.next.data.api.term.Literal) - result.next().getValue("length")).intValue()); - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("SUBSTR uses Unicode code-point positions and preserves the language tag") - void substringUsesUnicodeCodePointPositions() { - insert(iri(ALICE), iri(NAME), valueFactory.createLiteral("A🙂B", "fr")); - - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?part WHERE { - ?name . - BIND(SUBSTR(?name, 2, 1) AS ?part) - } - """); - - Literal part = (Literal) result.next().getValue("part"); - assertEquals("🙂", part.getLabel()); - assertEquals("fr", part.getLanguage().orElseThrow()); - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("NOW returns one stable value throughout a query evaluation") - void nowIsStableWithinOneQuery() { - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?first ?second WHERE { - ?s ?p ?o . - BIND(NOW() AS ?first) - BIND(NOW() AS ?second) - } - """); - - var binding = result.next(); - assertTrue(binding.getValue("first").sameTerm(binding.getValue("second"))); - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("Logical operators apply the SPARQL error truth table") - void logicalOperatorsApplySparqlErrorTruthTable() { - assertFalse(executor.evaluateTuple(""" - SELECT ?s WHERE { ?s ?p ?o . FILTER((1 / 0) && false) } - """).hasNext()); - assertTrue(executor.evaluateTuple(""" - SELECT ?s WHERE { ?s ?p ?o . FILTER((1 / 0) || true) } - """).hasNext()); - } - @Test @DisplayName("Inline VALUES preserves row order, duplicates, and UNDEF bindings") void inlineValuesPreservesBagSemanticsAndUndef() { @@ -301,25 +145,6 @@ void inlineValuesPreservesBagSemanticsAndUndef() { assertFalse(result.hasNext()); } - @Test - @DisplayName("VALUES joins graph patterns at its position and rejects incompatible mappings") - void inlineValuesJoinsGraphPatterns() { - insert(iri(BOB), iri(KNOWS), iri(ALICE)); - - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?person ?friend WHERE { - VALUES ?person { } - ?person ?friend . - } - """); - - assertTrue(result.hasNext()); - var binding = result.next(); - assertEquals(ALICE, binding.getValue("person").stringValue()); - assertEquals(BOB, binding.getValue("friend").stringValue()); - assertFalse(result.hasNext()); - } - @Test @DisplayName("Query-level VALUES constrains the completed WHERE result") void queryLevelValuesConstrainWhereResults() { @@ -400,416 +225,6 @@ void valuesUndefDoesNotClearIncomingBinding() { assertFalse(result.hasNext()); } - @Test - @DisplayName("BIND expression errors keep the input solution with an unbound target") - void bindExpressionErrorLeavesTargetUnbound() { - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?person ?value WHERE { - VALUES ?person { } - BIND(1 / 0 AS ?value) - } - """); - - assertTrue(result.hasNext()); - var binding = result.next(); - assertEquals(ALICE, binding.getValue("person").stringValue()); - assertFalse(binding.hasBinding("value")); - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("Arithmetic preserves SPARQL floating-point type promotion") - void arithmeticPreservesFloatingPointPromotion() { - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?sum WHERE { ?s ?p ?o . BIND(1e0 + 1 AS ?sum) } - """); - - Literal sum = (Literal) result.next().getValue("sum"); - assertEquals(XSDDatatype.DOUBLE, sum.getCoreDatatype()); - assertEquals(2.0d, sum.doubleValue()); - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("FILTER REGEX evaluates without the historical expression interpreter") - void regexFilterRunsThroughNativeExpressionEvaluator() { - insert(iri(ALICE), iri(NAME), valueFactory.createLiteral("Alice")); - insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); - - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?person WHERE { - ?person ?name . - FILTER(REGEX(?name, "^ali", "i")) - } - """); - - assertTrue(result.hasNext()); - assertEquals(ALICE, result.next().getValue("person").stringValue()); - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("FILTER EXISTS evaluates as a correlated native graph pattern") - void correlatedExistsFilterRunsEndToEnd() { - insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); - - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?person WHERE { - ?person ?friend . - FILTER EXISTS { ?friend ?name } - } - """); - - assertTrue(result.hasNext()); - assertEquals(ALICE, result.next().getValue("person").stringValue()); - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("FILTER NOT EXISTS rejects solutions with a correlated match") - void correlatedNotExistsFilterRunsEndToEnd() { - insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); - - TupleQueryResult result = executor.evaluateTuple(""" - SELECT ?person WHERE { - ?person ?friend . - FILTER NOT EXISTS { ?friend ?name } - } - """); - - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("temporalProximity01: NOT EXISTS excludes only earlier examinations") - void temporalProximity01() { - String ex = "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/negation#"; - IRI type = iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"); - IRI date = iri("http://purl.org/dc/elements/1.1/date"); - for (String exam : List.of("examination1", "examination2")) { - insert(iri(ex + exam), type, iri(ex + "PhysicalExamination")); - insert(iri(ex + exam), iri(ex + "precedes"), iri(ex + "operation1")); - } - insert(iri(ex + "examination1"), date, - valueFactory.createLiteral("2010-01-10", XSDDatatype.DATE.getIRI())); - insert(iri(ex + "examination2"), date, - valueFactory.createLiteral("2010-01-02", XSDDatatype.DATE.getIRI())); - insert(iri(ex + "examination1"), iri(ex + "follows"), iri(ex + "examination2")); - insert(iri(ex + "examination2"), iri(ex + "precedes"), iri(ex + "examination1")); - insert(iri(ex + "operation1"), type, iri(ex + "SurgicalProcedure")); - insert(iri(ex + "operation1"), date, - valueFactory.createLiteral("2010-01-15", XSDDatatype.DATE.getIRI())); - insert(iri(ex + "operation1"), iri(ex + "follows"), iri(ex + "examination1")); - insert(iri(ex + "operation1"), iri(ex + "follows"), iri(ex + "examination2")); - - try (var result = executor.evaluateTuple(""" - PREFIX ex: - PREFIX dc: - SELECT ?exam ?date { - ?exam a ex:PhysicalExamination; dc:date ?date; ex:precedes ex:operation1 . - ?op a ex:SurgicalProcedure; dc:date ?opDT . - FILTER NOT EXISTS { - ?otherExam a ex:PhysicalExamination; - ex:follows ?exam; - ex:precedes ex:operation1 - } - } - """)) { - var rows = result.stream().toList(); - assertEquals(1, rows.size()); - assertEquals(ex + "examination1", rows.getFirst().getValue("exam").stringValue()); - assertEquals(valueFactory.createLiteral("2010-01-10", XSDDatatype.DATE.getIRI()), - rows.getFirst().getValue("date")); - } - } - - @Test - @DisplayName("exists-graph-variable: EXISTS uses the outer graph binding") - void existsGraphVariable() { - String ex = "http://www.example.org/"; - IRI graph = iri(ex + "exists-graph-variable.ttl"); - // The W3C fixture is loaded as both default data and a named graph; - // its relative <> resolves to the fixture's graph IRI. - insert(iri(ex + "s1"), iri(ex + "p"), graph); - insert(iri(ex + "s2"), iri(ex + "p"), iri(ex + "o2")); - insertInGraph(iri(ex + "s1"), iri(ex + "p"), graph, graph); - insertInGraph(iri(ex + "s2"), iri(ex + "p"), iri(ex + "o2"), graph); - try (var result = executor.evaluateTuple(""" - PREFIX : - SELECT ?s WHERE { - ?s :p ?g . - FILTER EXISTS { GRAPH ?g { ?s2 :p ?o2 } } - } - """)) { - assertEquals(List.of(ex + "s1"), - result.stream().map(row -> row.getValue("s").stringValue()).toList()); - } - } - - @Test - @DisplayName("EXISTS waits for its GRAPH variable even after an unrelated BIND") - void existsGraphVariableWaitsForOuterBinding() { - String ex = "http://www.example.org/"; - IRI graph = iri(ex + "g"); - insert(iri(ex + "s1"), iri(ex + "p"), graph); - insert(iri(ex + "s2"), iri(ex + "p"), iri(ex + "missing")); - insertInGraph(iri(ex + "a"), iri(ex + "q"), iri(ex + "b"), graph); - try (var result = executor.evaluateTuple(""" - PREFIX : - SELECT ?s { - BIND(1 AS ?unrelated) - ?s :p ?g . - FILTER EXISTS { GRAPH ?g { ?s2 :q ?o2 } } - } - """)) { - assertEquals(List.of(ex + "s1"), - result.stream().map(row -> row.getValue("s").stringValue()).toList()); - } - } - - @Test - @DisplayName("Default graph merge removes duplicate triples, preserving projected and named-graph rows") - void defaultGraphMergePreservesSolutionMultiplicity() { - IRI firstGraph = iri("http://example.org/g1"); - IRI secondGraph = iri("http://example.org/g2"); - insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), firstGraph); - insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), secondGraph); - insertInGraph(iri(ALICE), iri(KNOWS), iri("http://example.org/carol"), secondGraph); - Dataset dataset = Dataset.builder().defaultGraph(firstGraph).defaultGraph(secondGraph) - .namedGraph(firstGraph).namedGraph(secondGraph).build(); - try (var result = executor.evaluateTuple( - "SELECT ?s { ?s ?o }", null, dataset, 0L)) { - assertEquals(List.of(ALICE, ALICE), - result.stream().map(row -> row.getValue("s").stringValue()).toList()); - } - try (var result = executor.evaluateTuple( - "SELECT ?g { GRAPH ?g { ?s ?o } }", null, dataset, 0L)) { - assertEquals(3, result.stream().count()); - } - } - - @Test - @DisplayName("EXISTS inside OPTIONAL filters within optional branch rather than being postponed") - void existsInsideOptionalFiltersLocally() { - IRI a = iri("http://example.org/a"); - IRI b = iri("http://example.org/b"); - IRI e = iri("http://example.org/e"); - IRI d = iri("http://example.org/d"); - IRI p = iri("http://example.org/p"); - IRI r = iri("http://example.org/r"); - insert(a, p, b); - insert(a, p, e); - insert(b, r, d); - try (var result = executor.evaluateTuple(""" - PREFIX : - SELECT ?s ?o { - VALUES ?s { :a } - OPTIONAL { - ?s :p ?o - FILTER EXISTS { ?o :r ?z } - } - } - """)) { - var rows = result.stream().toList(); - assertEquals(1, rows.size()); - assertEquals("http://example.org/b", rows.getFirst().getValue("o").stringValue()); - } - } - - @Test - @DisplayName("ASK WHERE { ?s ?p ?o } returns true when data exists") - void askSpoReturnsTrueWhenDataExists() { - assertTrue(executor.evaluateBoolean("ASK WHERE { ?s ?p ?o }")); - } - - @Test - @DisplayName("ASK WHERE {

} returns false") - void askSpoReturnsFalseWhenNoDataExists() { - assertFalse(executor.evaluateBoolean(""" - ASK WHERE { - - } - """)); - } - - @Test - @DisplayName("Tuple evaluation rejects non-SELECT queries") - void tupleEvaluationRejectsNonSelectQuery() { - assertThrows( - IllegalArgumentException.class, - () -> executor.evaluateTuple("ASK WHERE { ?s ?p ?o }")); - } - - @Test - @DisplayName("Boolean evaluation rejects non-ASK queries") - void booleanEvaluationRejectsNonAskQuery() { - assertThrows( - IllegalArgumentException.class, - () -> executor.evaluateBoolean("SELECT * WHERE { ?s ?p ?o }")); - } - - // ------------------------------------------------------------------------- - // Initial bindings - // ------------------------------------------------------------------------- - - @Test - @DisplayName("SELECT with initial binding filters results to bound variable value") - void selectWithInitialBindingFiltersResults() { - // bob also knows carol — without binding both alice→bob and bob→carol would match ?s - insert(iri(BOB), iri(KNOWS), iri("http://example.org/carol")); - - BindingSet bindings = singleBinding("s", iri(ALICE)); - TupleQueryResult result = executor.evaluateTuple( - "SELECT ?friend WHERE { ?s <" + KNOWS + "> ?friend }", - bindings, null, 0L); - - assertTrue(result.hasNext()); - assertEquals(BOB, result.next().getValue("friend").stringValue()); - assertFalse(result.hasNext(), "Only alice's friends should be returned"); - } - - @Test - @DisplayName("ASK with initial binding returns true when bound triple exists") - void askWithInitialBindingReturnsTrueWhenMatch() { - BindingSet bindings = singleBinding("s", iri(ALICE)); - assertTrue(executor.evaluateBoolean( - "ASK { ?s <" + KNOWS + "> <" + BOB + "> }", - bindings, null, 0L)); - } - - @Test - @DisplayName("ASK with initial binding returns false when bound triple absent") - void askWithInitialBindingReturnsFalseWhenNoMatch() { - BindingSet bindings = singleBinding("s", iri(BOB)); // bob knows nobody - assertFalse(executor.evaluateBoolean( - "ASK { ?s <" + KNOWS + "> ?o }", - bindings, null, 0L)); - } - - // ------------------------------------------------------------------------- - // Dataset restriction - // ------------------------------------------------------------------------- - - @Test - @DisplayName("SELECT with dataset FROM restricts results to the named graph") - void selectWithDatasetFromRestrictsToNamedGraph() { - String graph1 = "http://example.org/graph1"; - String graph2 = "http://example.org/graph2"; - String carol = "http://example.org/carol"; - - // alice→knows→bob in graph1, alice→knows→carol in graph2 - insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri(graph1)); - insertInGraph(iri(ALICE), iri(KNOWS), iri(carol), iri(graph2)); - - Dataset dataset = Dataset.builder().defaultGraph(iri(graph1)).build(); - TupleQueryResult result = executor.evaluateTuple( - "SELECT ?o WHERE { <" + ALICE + "> <" + KNOWS + "> ?o }", - null, dataset, 0L); - - assertTrue(result.hasNext()); - assertEquals(BOB, result.next().getValue("o").stringValue()); - assertFalse(result.hasNext(), "Only data from graph1 should be visible"); - } - - @Test - @DisplayName("SELECT with dataset FROM returns nothing when named graph is empty") - void selectWithDatasetFromReturnsNothingForEmptyNamedGraph() { - String emptyGraph = "http://example.org/empty"; - - Dataset dataset = Dataset.builder().defaultGraph(iri(emptyGraph)).build(); - TupleQueryResult result = executor.evaluateTuple( - "SELECT * WHERE { ?s ?p ?o }", - null, dataset, 0L); - - assertFalse(result.hasNext(), "No results expected for an empty named graph"); - } - - @Test - @DisplayName("An explicit empty dataset hides repository data") - void explicitEmptyDatasetHasNoDefaultGraph() { - TupleQueryResult result = executor.evaluateTuple( - "SELECT * WHERE { ?s ?p ?o }", - null, Dataset.empty(), 0L); - - assertFalse(result.hasNext()); - } - - @Test - @DisplayName("A named-only dataset has an empty default graph") - void namedOnlyDatasetHasNoDefaultGraph() { - String graph = "http://example.org/graph"; - insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri(graph)); - Dataset dataset = Dataset.builder().namedGraph(iri(graph)).build(); - - TupleQueryResult defaultResult = executor.evaluateTuple( - "SELECT * WHERE { ?s ?p ?o }", null, dataset, 0L); - - assertFalse(defaultResult.hasNext()); - } - - // ------------------------------------------------------------------------- - // 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 - // ------------------------------------------------------------------------- - @Test @DisplayName("Query with generous timeout completes normally") void queryWithGenerousTimeoutCompletesNormally() { @@ -844,63 +259,6 @@ void queryTimeoutExceptionThrownWhenDeadlineExceeded() { assertThrows(QueryTimeoutException.class, this::evaluateAndConsumeTimeoutQuery); } - private void evaluateAndConsumeTimeoutQuery() { - try (TupleQueryResult result = executor.evaluateTuple( - "SELECT * WHERE { ?s1 ?p1 ?o1 . ?s2 ?p2 ?o2 }", - null, null, 1L)) { - result.stream().count(); - } - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - private void insert(Resource subject, IRI predicate, Value object) { - storage.mutations().add(valueFactory.createStatement(subject, predicate, object)); - } - - private void insertInGraph(Resource subject, IRI predicate, Value object, Resource context) { - storage.mutations().add( - valueFactory.createStatement(subject, predicate, object, context)); - } - - private IRI iri(String iri) { - return valueFactory.createIRI(iri); - } - - /** - * Creates a one-entry {@link BindingSet} binding {@code varName} to {@code value}. - */ - private BindingSet singleBinding(String varName, Value value) { - Binding b = new Binding() { - @Override public String name() { return varName; } - @Override public Value value() { return value; } - }; - return new BindingSet() { - @Override public Set getBindingNames() { return Set.of(varName); } - @Override public boolean hasBinding(String name) { return varName.equals(name); } - @Override public Value getValue(String name) { return varName.equals(name) ? value : null; } - @Override public Iterator iterator() { return List.of(b).iterator(); } - }; - } - @Test - void valuesInsideGraphPreservesUnboundGraphRows() { - insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri("http://example.org/g1")); - insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri("http://example.org/g2")); - try (var result = executor.evaluateTuple(""" - SELECT ?g ?t WHERE { - GRAPH ?g { - VALUES (?g ?t) { (UNDEF "foo") ( "bar") } - } - } - """)) { - assertEquals(List.of("http://example.org/g1:bar", "http://example.org/g1:foo", "http://example.org/g2:foo"), - result.stream().map(row -> row.getValue("g").stringValue() + ":" + row.getValue("t").stringValue()) - .sorted().toList()); - } - } - @Test void trailingValuesDoesNotSupplyBindingsToWhere() { try (var result = executor.evaluateTuple("SELECT ?x ?y { BIND(?x AS ?y) } VALUES ?x { 1 }")) { @@ -950,36 +308,6 @@ void incompatibleValuesRowAfterUndefDoesNotPopIncomingBindings() { } } - @Test - void graphNameIsNotAnInputBindingForItsInnerBind() { - insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri("http://example.org/g1")); - try (var result = executor.evaluateTuple("SELECT ?g ?copy { GRAPH ?g { BIND(?g AS ?copy) } }")) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals("http://example.org/g1", row.getValue("g").stringValue()); - assertFalse(row.hasBinding("copy")); - assertFalse(result.hasNext()); - } - } - - @Test - void nestedGroupFilterDoesNotSeeOuterBind() { - try (var result = executor.evaluateTuple("SELECT ?x { BIND(1 AS ?x) { FILTER(?x = 1) } }")) { - assertFalse(result.hasNext()); - } - } - - @Test - void graphWithValuesRequiresAnExistingNamedGraph() { - insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri("http://example.org/g1")); - assertTrue(executor.evaluateBoolean("ASK { GRAPH { VALUES ?x { 1 } } }")); - assertFalse(executor.evaluateBoolean("ASK { GRAPH { VALUES ?x { 1 } } }")); - assertFalse(executor.evaluateBoolean(""" - ASK FROM NAMED - { GRAPH { VALUES ?x { 1 } } } - """)); - } - @Test void valuesDistinguishesEmptyTableFromEmptySolution() { assertFalse(executor.evaluateBoolean("ASK {} VALUES ?x {}")); @@ -994,405 +322,18 @@ void valuesDistinguishesEmptyTableFromEmptySolution() { } } - @Test - @DisplayName("GROUP BY and COUNT aggregate count solutions per group") - void groupByAndCountAggregate() { - String charlie = "http://example.org/charlie"; - insert(iri(ALICE), iri(KNOWS), iri(charlie)); - insert(iri(BOB), iri(KNOWS), iri(charlie)); - - try (var result = executor.evaluateTuple(""" - SELECT ?s (COUNT(?o) AS ?cnt) WHERE { - ?s ?o . - } - GROUP BY ?s - ORDER BY ?s - """)) { - assertTrue(result.hasNext()); - var row1 = result.next(); - assertEquals(ALICE, row1.getValue("s").stringValue()); - assertEquals("2", row1.getValue("cnt").stringValue()); - - assertTrue(result.hasNext()); - var row2 = result.next(); - assertEquals(BOB, row2.getValue("s").stringValue()); - assertEquals("1", row2.getValue("cnt").stringValue()); - - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("COUNT(*) and COUNT(DISTINCT) evaluate properly over whole solution") - void countStarAndDistinct() { - String tag = "http://example.org/tag"; - insert(iri(ALICE), iri(tag), valueFactory.createLiteral("java")); - insert(iri(ALICE), iri(tag), valueFactory.createLiteral("sparql")); - insert(iri(BOB), iri(tag), valueFactory.createLiteral("java")); - - try (var result = executor.evaluateTuple(""" - SELECT (COUNT(*) AS ?total) (COUNT(DISTINCT ?val) AS ?uniqueTags) WHERE { - ?s ?val . - } - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals("3", row.getValue("total").stringValue()); - assertEquals("2", row.getValue("uniqueTags").stringValue()); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("SUM and AVG aggregates promote types and calculate correct totals") - void sumAndAvgAggregates() { - String score = "http://example.org/score"; - insert(iri(ALICE), iri(score), valueFactory.createLiteral(10)); - insert(iri(ALICE), iri(score), valueFactory.createLiteral(20)); - - try (var result = executor.evaluateTuple(""" - SELECT ?s (SUM(?score) AS ?total) (AVG(?score) AS ?average) WHERE { - ?s ?score . - } - GROUP BY ?s - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals(ALICE, row.getValue("s").stringValue()); - assertEquals("30", row.getValue("total").stringValue()); - assertEquals("15", row.getValue("average").stringValue()); - assertEquals(XSDDatatype.DECIMAL.getIRI(), ((Literal) row.getValue("average")).getDatatype()); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("MIN, MAX, and SAMPLE aggregates select extreme and sample values") - void minMaxSampleAggregates() { - String score = "http://example.org/score"; - insert(iri(ALICE), iri(score), valueFactory.createLiteral(5)); - insert(iri(ALICE), iri(score), valueFactory.createLiteral(25)); - insert(iri(ALICE), iri(score), valueFactory.createLiteral(15)); - - try (var result = executor.evaluateTuple(""" - SELECT ?s (MIN(?score) AS ?minVal) (MAX(?score) AS ?maxVal) (SAMPLE(?score) AS ?sampleVal) WHERE { - ?s ?score . - } - GROUP BY ?s - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals(ALICE, row.getValue("s").stringValue()); - assertEquals("5", row.getValue("minVal").stringValue()); - assertEquals("25", row.getValue("maxVal").stringValue()); - assertTrue(row.hasBinding("sampleVal")); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("GROUP_CONCAT aggregates strings with custom separator") - void groupConcatAggregate() { - String tag = "http://example.org/tag"; - insert(iri(ALICE), iri(tag), valueFactory.createLiteral("A")); - insert(iri(ALICE), iri(tag), valueFactory.createLiteral("B")); - - try (var result = executor.evaluateTuple(""" - SELECT ?s (GROUP_CONCAT(?tag; separator=";") AS ?tags) WHERE { - ?s ?tag . - } - GROUP BY ?s - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals(ALICE, row.getValue("s").stringValue()); - String tags = row.getValue("tags").stringValue(); - assertTrue("A;B".equals(tags) || "B;A".equals(tags)); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("GROUP BY with expression alias binds alias for projection") - void groupByExpressionAlias() { - String val = "http://example.org/val"; - insert(iri(ALICE), iri(val), valueFactory.createLiteral(10)); - insert(iri(BOB), iri(val), valueFactory.createLiteral(10)); - - try (var result = executor.evaluateTuple(""" - SELECT ?next (COUNT(?s) AS ?cnt) WHERE { - ?s ?v . - } - GROUP BY (?v + 1 AS ?next) - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals("11", row.getValue("next").stringValue()); - assertEquals("2", row.getValue("cnt").stringValue()); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("HAVING filters groups based on aggregate condition") - void havingWithAggregateCondition() { - String tag = "http://example.org/tag"; - insert(iri(ALICE), iri(tag), valueFactory.createLiteral("t1")); - insert(iri(ALICE), iri(tag), valueFactory.createLiteral("t2")); - insert(iri(BOB), iri(tag), valueFactory.createLiteral("t1")); - - try (var result = executor.evaluateTuple(""" - SELECT ?s (COUNT(?tag) AS ?cnt) WHERE { - ?s ?tag . - } - GROUP BY ?s - HAVING (COUNT(?tag) > 1) - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals(ALICE, row.getValue("s").stringValue()); - assertEquals("2", row.getValue("cnt").stringValue()); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("Implicit aggregate on empty dataset returns exactly 1 row") - void implicitAggregateOnEmptyDatasetReturnsOneRow() { - try (var result = executor.evaluateTuple(""" - SELECT (COUNT(?o) AS ?count) (SUM(?score) AS ?sum) (AVG(?score) AS ?avg) (GROUP_CONCAT(?o) AS ?gc) WHERE { - ?s ?o . - OPTIONAL { ?s ?score } - } - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals("0", row.getValue("count").stringValue()); - assertEquals("0", row.getValue("sum").stringValue()); - assertFalse(row.hasBinding("avg")); - assertEquals("", row.getValue("gc").stringValue()); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("GROUP BY on empty dataset returns 0 rows") - void groupByOnEmptyDatasetReturnsZeroRows() { - try (var result = executor.evaluateTuple(""" - SELECT ?s (COUNT(?o) AS ?count) WHERE { - ?s ?o . - } - GROUP BY ?s - """)) { - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("Projection expression with aggregate arithmetic") - void projectionExpressionWithAggregateArithmetic() { - try (var result = executor.evaluateTuple(""" - SELECT (COUNT(?s) AS ?c) ((COUNT(?s) + 5) AS ?cPlus) WHERE { - ?s ?o . - } - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals("1", row.getValue("c").stringValue()); - assertEquals("6", row.getValue("cPlus").stringValue()); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("ORDER BY with aggregate expression sorts groups") - void orderByAggregateExpression() { - String score = "http://example.org/score"; - insert(iri(ALICE), iri(score), valueFactory.createLiteral(10)); - insert(iri(BOB), iri(score), valueFactory.createLiteral(20)); - insert(iri(BOB), iri(score), valueFactory.createLiteral(30)); - - try (var result = executor.evaluateTuple(""" - SELECT ?s (SUM(?val) AS ?total) WHERE { - ?s ?val . - } - GROUP BY ?s - ORDER BY DESC(?total) - """)) { - assertTrue(result.hasNext()); - var row1 = result.next(); - assertEquals(BOB, row1.getValue("s").stringValue()); - assertEquals("50", row1.getValue("total").stringValue()); - - assertTrue(result.hasNext()); - var row2 = result.next(); - assertEquals(ALICE, row2.getValue("s").stringValue()); - assertEquals("10", row2.getValue("total").stringValue()); - - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("GROUP_CONCAT concatenates blank nodes preserving their string representations") - void groupConcatBlankNodes() { - String edge = "http://example.org/item"; - var b1 = valueFactory.createBNode("b1"); - var b2 = valueFactory.createBNode("b2"); - insert(iri(ALICE), iri(edge), b1); - insert(iri(ALICE), iri(edge), b2); - - try (var result = executor.evaluateTuple(""" - SELECT ?s (GROUP_CONCAT(?o ; SEPARATOR=",") AS ?concat) WHERE { - ?s ?o . - } - GROUP BY ?s - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals(ALICE, row.getValue("s").stringValue()); - String concat = row.getValue("concat").stringValue(); - assertTrue(concat.contains(b1.stringValue()) && concat.contains(b2.stringValue()), - "GROUP_CONCAT must include blank node string representations"); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("GROUP BY with multiple identical expression aliases binds each alias") - void groupByMultipleIdenticalExpressionAliases() { - String num = "http://example.org/num"; - insert(iri(ALICE), iri(num), valueFactory.createLiteral(5)); - - try (var result = executor.evaluateTuple(""" - SELECT ?y ?z (COUNT(*) AS ?cnt) WHERE { - ?s ?val . - } - GROUP BY (?val + 1 AS ?y) (?val + 1 AS ?z) - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals("6", row.getValue("y").stringValue()); - assertEquals("6", row.getValue("z").stringValue()); - assertEquals("1", row.getValue("cnt").stringValue()); - assertFalse(result.hasNext()); - } - } - - @Test - @DisplayName("Combined query with GROUP BY, Aggregates, HAVING and ORDER BY") - void combinedGroupByAggregatesHavingOrderBy() { - String score = "http://example.org/score"; - insert(iri(ALICE), iri(score), valueFactory.createLiteral(10)); - insert(iri(ALICE), iri(score), valueFactory.createLiteral(5)); - insert(iri(BOB), iri(score), valueFactory.createLiteral(20)); - insert(iri(BOB), iri(score), valueFactory.createLiteral(30)); - - try (var result = executor.evaluateTuple(""" - SELECT ?s (SUM(?val) AS ?total) (COUNT(?val) AS ?count) WHERE { - ?s ?val . - } - GROUP BY ?s - HAVING (SUM(?val) > 20) - ORDER BY DESC(?total) - """)) { - assertTrue(result.hasNext()); - var row = result.next(); - assertEquals(BOB, row.getValue("s").stringValue()); - assertEquals("50", row.getValue("total").stringValue()); - assertEquals("2", row.getValue("count").stringValue()); - assertFalse(result.hasNext()); - } - } - - @Test - void propertyPathsPreserveSequenceAndAlternativeMultiplicities() { - insert(iri(ALICE), iri(KNOWS), iri("http://example.org/carol")); - insert(iri(BOB), iri(KNOWS), iri("http://example.org/dave")); - insert(iri("http://example.org/carol"), iri(KNOWS), iri("http://example.org/dave")); - assertEquals(2, pathCount("ex:alice ex:knows/ex:knows ?end")); - assertEquals(4, pathCount("ex:alice (ex:knows|ex:knows) ?end")); - assertEquals(3, pathCount("ex:alice ex:knows+ ?end")); - assertEquals(2, pathCount("?start ^(ex:knows/ex:knows) ex:alice")); - } - - @Test - void propertyPathCyclesTerminateAndDeduplicateReachableNodes() { - insert(iri(BOB), iri(KNOWS), iri(ALICE)); - insert(iri(ALICE), iri(KNOWS), iri(ALICE)); - assertEquals(2, pathCount("ex:alice ex:knows* ?end")); - assertEquals(2, pathCount("ex:alice ex:knows+ ?end")); - assertEquals(2, pathCount("ex:alice ex:knows? ?end")); - assertEquals(2, pathCount("?node ex:knows+ ?node")); - assertEquals(1, pathCount("ex:alice ex:knows+ ex:alice")); - } - - @Test - void zeroLengthPathsIncludeAbsentConstantsAndLiteralGraphNodes() { - insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); - assertEquals(1, pathCount("ex:absent ex:knows* ?end")); - assertEquals(1, pathCount("?start ex:knows* ex:absent")); - assertEquals(1, pathCount("ex:absent ex:knows? ex:absent")); - assertEquals(0, pathCount("ex:absent ex:knows+ ?end")); - assertEquals(3, pathCount("?node ex:missing* ?node")); - assertEquals(1, pathCount("\"Bob\" ex:missing* ?end")); - } - - @Test - void negatedPropertySetsRespectEachDirection() { - insert(iri(ALICE), iri(NAME), valueFactory.createLiteral("Alice")); - insert(iri(BOB), iri(NAME), iri(ALICE)); - assertEquals(1, pathCount("ex:alice !ex:knows ?end")); - assertEquals(0, pathCount("ex:alice !(ex:knows|ex:name) ?end")); - assertEquals(1, pathCount("ex:alice !(^ex:knows) ?end")); - assertEquals(2, pathCount("ex:alice !(ex:knows|^ex:knows) ?end")); - assertEquals(1, pathCount("ex:alice !(ex:name|^ex:name) ?end")); - } - - @Test - void propertyPathsComposeWithValuesOptionalAndExists() { - assertEquals(2, pathCount("VALUES ?start { ex:alice ex:alice } ?start ex:knows+ ?end")); - assertEquals(1, pathCount("ex:alice ex:knows+ ?end OPTIONAL { ?end ex:missing+ ?other }")); - assertEquals(1, pathCount("ex:alice ex:knows ?end FILTER EXISTS { ?end ^ex:knows+ ex:alice }")); - } - - @Test - void propertyPathsStayWithinTheActiveGraph() { - Resource graph = iri("http://example.org/graph"); - storage.mutations().add(valueFactory.createStatement(iri(BOB), iri(KNOWS), iri(ALICE), graph)); - assertEquals(1, pathCount("GRAPH ex:graph { ?start ex:knows+ ?end }")); - assertEquals(0, pathCount("GRAPH ex:graph { ex:alice ex:knows+ ?end }")); - assertEquals(1, pathCount("GRAPH ?g { ex:bob ex:knows+ ex:alice }")); - } - - @Test - void boundVariablesDoNotInventZeroLengthGraphNodes() { - assertEquals(0, pathCount("VALUES ?node { ex:absent } ?node ex:knows? ?node")); - assertEquals(0, pathCount("VALUES ?start { ex:absent } ?start ex:knows* ?end")); - assertEquals(0, pathCount("VALUES ?end { ex:absent } ?start ex:knows* ?end")); - assertEquals(1, pathCount("VALUES ?start { ex:absent } ?start ex:knows* ex:absent")); - } - - @Test - void propertyPathTraversalHandlesLongCyclesWithoutRecursiveJavaCalls() { - int length = 2000; - for (int index = 0; index < length; index++) { - insert(iri("http://example.org/node" + index), iri(KNOWS), - iri("http://example.org/node" + ((index + 1) % length))); - } - assertEquals(length, pathCount("ex:node0 ex:knows+ ?end")); - } - - private long pathCount(String pattern) { + private void evaluateAndConsumeTimeoutQuery() { try (TupleQueryResult result = executor.evaluateTuple( - "PREFIX ex: SELECT * WHERE { " + pattern + " }")) { - long count = 0; + "SELECT * WHERE { ?s1 ?p1 ?o1 . ?s2 ?p2 ?o2 }", + null, null, 1L)) { while (result.hasNext()) { result.next(); - count++; } - return count; } } + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + } diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExpressionExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExpressionExecutorTest.java new file mode 100644 index 000000000..e8870fc8a --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExpressionExecutorTest.java @@ -0,0 +1,333 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + + +import fr.inria.corese.core.next.data.api.term.IRI; +import fr.inria.corese.core.next.data.api.term.Literal; +import fr.inria.corese.core.next.data.api.literal.XSDDatatype; +import fr.inria.corese.core.next.query.api.result.BindingSet; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** End-to-end expression pipeline tests. */ +class NextSparqlPipelineExpressionExecutorTest extends PipelineTestSupport { + + @Test + @DisplayName("FILTER evaluates native numeric expressions") + void filterRunsThroughNativeExpressionEvaluator() { + String age = "http://example.org/age"; + insert(iri(ALICE), iri(age), valueFactory.createLiteral(42)); + insert(iri(BOB), iri(age), valueFactory.createLiteral(18)); + + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?person WHERE { + ?person ?age . + FILTER(?age >= 21) + } + ORDER BY ?person + """); + + assertTrue(result.hasNext()); + assertEquals(ALICE, result.next().getValue("person").stringValue()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("BIND evaluates native arithmetic and exposes the result") + void bindRunsThroughNativeExpressionEvaluator() { + String age = "http://example.org/age"; + insert(iri(ALICE), iri(age), valueFactory.createLiteral(41)); + + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?nextAge WHERE { + ?age . + BIND(?age + 1 AS ?nextAge) + } + """); + + assertTrue(result.hasNext()); + assertEquals(42, ((fr.inria.corese.core.next.data.api.term.Literal) + result.next().getValue("nextAge")).intValue()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("FILTER numeric comparison does not reuse RDF term tie-breakers") + void filterUsesValueComparisonInsteadOfTotalTermOrder() { + String rank = "http://example.org/rank"; + insert(iri(ALICE), iri(rank), valueFactory.createLiteral(1)); + + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?rank WHERE { + ?rank . + FILTER(?rank < 1.0) + } + """); + + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("STRLEN counts Unicode code points through the native evaluator") + void stringLengthUsesUnicodeCodePoints() { + insert(iri(ALICE), iri(NAME), valueFactory.createLiteral("A🙂")); + + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?length WHERE { + ?name . + BIND(STRLEN(?name) AS ?length) + } + """); + + assertTrue(result.hasNext()); + assertEquals(2, ((fr.inria.corese.core.next.data.api.term.Literal) + result.next().getValue("length")).intValue()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("SUBSTR uses Unicode code-point positions and preserves the language tag") + void substringUsesUnicodeCodePointPositions() { + insert(iri(ALICE), iri(NAME), valueFactory.createLiteral("A🙂B", "fr")); + + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?part WHERE { + ?name . + BIND(SUBSTR(?name, 2, 1) AS ?part) + } + """); + + Literal part = (Literal) result.next().getValue("part"); + assertEquals("🙂", part.getLabel()); + assertEquals("fr", part.getLanguage().orElseThrow()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("NOW returns one stable value throughout a query evaluation") + void nowIsStableWithinOneQuery() { + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?first ?second WHERE { + ?s ?p ?o . + BIND(NOW() AS ?first) + BIND(NOW() AS ?second) + } + """); + + var binding = result.next(); + assertTrue(binding.getValue("first").sameTerm(binding.getValue("second"))); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("Logical operators apply the SPARQL error truth table") + void logicalOperatorsApplySparqlErrorTruthTable() { + assertFalse(executor.evaluateTuple(""" + SELECT ?s WHERE { ?s ?p ?o . FILTER((1 / 0) && false) } + """).hasNext()); + assertTrue(executor.evaluateTuple(""" + SELECT ?s WHERE { ?s ?p ?o . FILTER((1 / 0) || true) } + """).hasNext()); + } + + @Test + @DisplayName("BIND expression errors keep the input solution with an unbound target") + void bindExpressionErrorLeavesTargetUnbound() { + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?person ?value WHERE { + VALUES ?person { } + BIND(1 / 0 AS ?value) + } + """); + + assertTrue(result.hasNext()); + var binding = result.next(); + assertEquals(ALICE, binding.getValue("person").stringValue()); + assertFalse(binding.hasBinding("value")); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("Arithmetic preserves SPARQL floating-point type promotion") + void arithmeticPreservesFloatingPointPromotion() { + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?sum WHERE { ?s ?p ?o . BIND(1e0 + 1 AS ?sum) } + """); + + Literal sum = (Literal) result.next().getValue("sum"); + assertEquals(XSDDatatype.DOUBLE, sum.getCoreDatatype()); + assertEquals(2.0d, sum.doubleValue()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("FILTER REGEX evaluates without the historical expression interpreter") + void regexFilterRunsThroughNativeExpressionEvaluator() { + insert(iri(ALICE), iri(NAME), valueFactory.createLiteral("Alice")); + insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); + + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?person WHERE { + ?person ?name . + FILTER(REGEX(?name, "^ali", "i")) + } + """); + + assertTrue(result.hasNext()); + assertEquals(ALICE, result.next().getValue("person").stringValue()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("FILTER EXISTS evaluates as a correlated native graph pattern") + void correlatedExistsFilterRunsEndToEnd() { + insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); + + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?person WHERE { + ?person ?friend . + FILTER EXISTS { ?friend ?name } + } + """); + + assertTrue(result.hasNext()); + assertEquals(ALICE, result.next().getValue("person").stringValue()); + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("FILTER NOT EXISTS rejects solutions with a correlated match") + void correlatedNotExistsFilterRunsEndToEnd() { + insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); + + TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?person WHERE { + ?person ?friend . + FILTER NOT EXISTS { ?friend ?name } + } + """); + + assertFalse(result.hasNext()); + } + + @Test + @DisplayName("temporalProximity01: NOT EXISTS excludes only earlier examinations") + void temporalProximity01() { + String ex = "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/negation#"; + IRI type = iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"); + IRI date = iri("http://purl.org/dc/elements/1.1/date"); + for (String exam : List.of("examination1", "examination2")) { + insert(iri(ex + exam), type, iri(ex + "PhysicalExamination")); + insert(iri(ex + exam), iri(ex + "precedes"), iri(ex + "operation1")); + } + insert(iri(ex + "examination1"), date, + valueFactory.createLiteral("2010-01-10", XSDDatatype.DATE.getIRI())); + insert(iri(ex + "examination2"), date, + valueFactory.createLiteral("2010-01-02", XSDDatatype.DATE.getIRI())); + insert(iri(ex + "examination1"), iri(ex + "follows"), iri(ex + "examination2")); + insert(iri(ex + "examination2"), iri(ex + "precedes"), iri(ex + "examination1")); + insert(iri(ex + "operation1"), type, iri(ex + "SurgicalProcedure")); + insert(iri(ex + "operation1"), date, + valueFactory.createLiteral("2010-01-15", XSDDatatype.DATE.getIRI())); + insert(iri(ex + "operation1"), iri(ex + "follows"), iri(ex + "examination1")); + insert(iri(ex + "operation1"), iri(ex + "follows"), iri(ex + "examination2")); + + try (var result = executor.evaluateTuple(""" + PREFIX ex: + PREFIX dc: + SELECT ?exam ?date { + ?exam a ex:PhysicalExamination; dc:date ?date; ex:precedes ex:operation1 . + ?op a ex:SurgicalProcedure; dc:date ?opDT . + FILTER NOT EXISTS { + ?otherExam a ex:PhysicalExamination; + ex:follows ?exam; + ex:precedes ex:operation1 + } + } + """)) { + var rows = result.stream().toList(); + assertEquals(1, rows.size()); + assertEquals(ex + "examination1", rows.getFirst().getValue("exam").stringValue()); + assertEquals(valueFactory.createLiteral("2010-01-10", XSDDatatype.DATE.getIRI()), + rows.getFirst().getValue("date")); + } + } + + @Test + @DisplayName("EXISTS inside OPTIONAL filters within optional branch rather than being postponed") + void existsInsideOptionalFiltersLocally() { + IRI a = iri("http://example.org/a"); + IRI b = iri("http://example.org/b"); + IRI e = iri("http://example.org/e"); + IRI d = iri("http://example.org/d"); + IRI p = iri("http://example.org/p"); + IRI r = iri("http://example.org/r"); + insert(a, p, b); + insert(a, p, e); + insert(b, r, d); + try (var result = executor.evaluateTuple(""" + PREFIX : + SELECT ?s ?o { + VALUES ?s { :a } + OPTIONAL { + ?s :p ?o + FILTER EXISTS { ?o :r ?z } + } + } + """)) { + var rows = result.stream().toList(); + assertEquals(1, rows.size()); + assertEquals("http://example.org/b", rows.getFirst().getValue("o").stringValue()); + } + } + + @Test + @DisplayName("SELECT with initial binding filters results to bound variable value") + void selectWithInitialBindingFiltersResults() { + // bob also knows carol — without binding both alice→bob and bob→carol would match ?s + insert(iri(BOB), iri(KNOWS), iri("http://example.org/carol")); + + BindingSet bindings = singleBinding("s", iri(ALICE)); + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?friend WHERE { ?s <" + KNOWS + "> ?friend }", + bindings, null, 0L); + + assertTrue(result.hasNext()); + assertEquals(BOB, result.next().getValue("friend").stringValue()); + assertFalse(result.hasNext(), "Only alice's friends should be returned"); + } + + @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 + // ------------------------------------------------------------------------- + + @Test + void nestedGroupFilterDoesNotSeeOuterBind() { + try (var result = executor.evaluateTuple("SELECT ?x { BIND(1 AS ?x) { FILTER(?x = 1) } }")) { + assertFalse(result.hasNext()); + } + } + +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelinePropertyPathExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelinePropertyPathExecutorTest.java new file mode 100644 index 000000000..0fdaa2d52 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelinePropertyPathExecutorTest.java @@ -0,0 +1,104 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + + +import fr.inria.corese.core.next.data.api.term.Resource; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import org.junit.jupiter.api.Test; + + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** End-to-end propertypath pipeline tests. */ +class NextSparqlPipelinePropertyPathExecutorTest extends PipelineTestSupport { + + @Test + void propertyPathsPreserveSequenceAndAlternativeMultiplicities() { + insert(iri(ALICE), iri(KNOWS), iri("http://example.org/carol")); + insert(iri(BOB), iri(KNOWS), iri("http://example.org/dave")); + insert(iri("http://example.org/carol"), iri(KNOWS), iri("http://example.org/dave")); + assertEquals(2, pathCount("ex:alice ex:knows/ex:knows ?end")); + assertEquals(4, pathCount("ex:alice (ex:knows|ex:knows) ?end")); + assertEquals(3, pathCount("ex:alice ex:knows+ ?end")); + assertEquals(2, pathCount("?start ^(ex:knows/ex:knows) ex:alice")); + } + + @Test + void propertyPathCyclesTerminateAndDeduplicateReachableNodes() { + insert(iri(BOB), iri(KNOWS), iri(ALICE)); + insert(iri(ALICE), iri(KNOWS), iri(ALICE)); + assertEquals(2, pathCount("ex:alice ex:knows* ?end")); + assertEquals(2, pathCount("ex:alice ex:knows+ ?end")); + assertEquals(2, pathCount("ex:alice ex:knows? ?end")); + assertEquals(2, pathCount("?node ex:knows+ ?node")); + assertEquals(1, pathCount("ex:alice ex:knows+ ex:alice")); + } + + @Test + void zeroLengthPathsIncludeAbsentConstantsAndLiteralGraphNodes() { + insert(iri(BOB), iri(NAME), valueFactory.createLiteral("Bob")); + assertEquals(1, pathCount("ex:absent ex:knows* ?end")); + assertEquals(1, pathCount("?start ex:knows* ex:absent")); + assertEquals(1, pathCount("ex:absent ex:knows? ex:absent")); + assertEquals(0, pathCount("ex:absent ex:knows+ ?end")); + assertEquals(3, pathCount("?node ex:missing* ?node")); + assertEquals(1, pathCount("\"Bob\" ex:missing* ?end")); + } + + @Test + void negatedPropertySetsRespectEachDirection() { + insert(iri(ALICE), iri(NAME), valueFactory.createLiteral("Alice")); + insert(iri(BOB), iri(NAME), iri(ALICE)); + assertEquals(1, pathCount("ex:alice !ex:knows ?end")); + assertEquals(0, pathCount("ex:alice !(ex:knows|ex:name) ?end")); + assertEquals(1, pathCount("ex:alice !(^ex:knows) ?end")); + assertEquals(2, pathCount("ex:alice !(ex:knows|^ex:knows) ?end")); + assertEquals(1, pathCount("ex:alice !(ex:name|^ex:name) ?end")); + } + + @Test + void propertyPathsComposeWithValuesOptionalAndExists() { + assertEquals(2, pathCount("VALUES ?start { ex:alice ex:alice } ?start ex:knows+ ?end")); + assertEquals(1, pathCount("ex:alice ex:knows+ ?end OPTIONAL { ?end ex:missing+ ?other }")); + assertEquals(1, pathCount("ex:alice ex:knows ?end FILTER EXISTS { ?end ^ex:knows+ ex:alice }")); + } + + @Test + void propertyPathsStayWithinTheActiveGraph() { + Resource graph = iri("http://example.org/graph"); + storage.mutations().add(valueFactory.createStatement(iri(BOB), iri(KNOWS), iri(ALICE), graph)); + assertEquals(1, pathCount("GRAPH ex:graph { ?start ex:knows+ ?end }")); + assertEquals(0, pathCount("GRAPH ex:graph { ex:alice ex:knows+ ?end }")); + assertEquals(1, pathCount("GRAPH ?g { ex:bob ex:knows+ ex:alice }")); + } + + @Test + void boundVariablesDoNotInventZeroLengthGraphNodes() { + assertEquals(0, pathCount("VALUES ?node { ex:absent } ?node ex:knows? ?node")); + assertEquals(0, pathCount("VALUES ?start { ex:absent } ?start ex:knows* ?end")); + assertEquals(0, pathCount("VALUES ?end { ex:absent } ?start ex:knows* ?end")); + assertEquals(1, pathCount("VALUES ?start { ex:absent } ?start ex:knows* ex:absent")); + } + + @Test + void propertyPathTraversalHandlesLongCyclesWithoutRecursiveJavaCalls() { + int length = 2000; + for (int index = 0; index < length; index++) { + insert(iri("http://example.org/node" + index), iri(KNOWS), + iri("http://example.org/node" + ((index + 1) % length))); + } + assertEquals(length, pathCount("ex:node0 ex:knows+ ?end")); + } + + private long pathCount(String pattern) { + try (TupleQueryResult result = executor.evaluateTuple( + "PREFIX ex: SELECT * WHERE { " + pattern + " }")) { + long count = 0; + while (result.hasNext()) { + result.next(); + count++; + } + return count; + } + } + +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineQueryFormExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineQueryFormExecutorTest.java new file mode 100644 index 000000000..026a0056b --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineQueryFormExecutorTest.java @@ -0,0 +1,105 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + + +import fr.inria.corese.core.next.data.api.model.Statement; +import fr.inria.corese.core.next.query.api.dataset.Dataset; +import fr.inria.corese.core.next.query.api.result.BindingSet; +import fr.inria.corese.core.next.query.api.result.GraphQueryResult; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** End-to-end queryform pipeline tests. */ +class NextSparqlPipelineQueryFormExecutorTest extends PipelineTestSupport { + + @Test + @DisplayName("ASK WHERE { ?s ?p ?o } returns true when data exists") + void askSpoReturnsTrueWhenDataExists() { + assertTrue(executor.evaluateBoolean("ASK WHERE { ?s ?p ?o }")); + } + + @Test + @DisplayName("ASK WHERE {

} returns false") + void askSpoReturnsFalseWhenNoDataExists() { + assertFalse(executor.evaluateBoolean(""" + ASK WHERE { + + } + """)); + } + + @Test + @DisplayName("Tuple evaluation rejects non-SELECT queries") + void tupleEvaluationRejectsNonSelectQuery() { + assertThrows( + IllegalArgumentException.class, + () -> executor.evaluateTuple("ASK WHERE { ?s ?p ?o }")); + } + + @Test + @DisplayName("Boolean evaluation rejects non-ASK queries") + void booleanEvaluationRejectsNonAskQuery() { + assertThrows( + IllegalArgumentException.class, + () -> executor.evaluateBoolean("SELECT * WHERE { ?s ?p ?o }")); + } + + // ------------------------------------------------------------------------- + // Initial bindings + // ------------------------------------------------------------------------- + + @Test + @DisplayName("ASK with initial binding returns true when bound triple exists") + void askWithInitialBindingReturnsTrueWhenMatch() { + BindingSet bindings = singleBinding("s", iri(ALICE)); + assertTrue(executor.evaluateBoolean( + "ASK { ?s <" + KNOWS + "> <" + BOB + "> }", + bindings, null, 0L)); + } + + @Test + @DisplayName("ASK with initial binding returns false when bound triple absent") + void askWithInitialBindingReturnsFalseWhenNoMatch() { + BindingSet bindings = singleBinding("s", iri(BOB)); // bob knows nobody + assertFalse(executor.evaluateBoolean( + "ASK { ?s <" + KNOWS + "> ?o }", + bindings, null, 0L)); + } + + // ------------------------------------------------------------------------- + // Dataset restriction + // ------------------------------------------------------------------------- + + @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()); + } + +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/PipelineTestSupport.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/PipelineTestSupport.java new file mode 100644 index 000000000..d64d94279 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/PipelineTestSupport.java @@ -0,0 +1,67 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + + +import fr.inria.corese.core.next.data.Values; +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.data.api.term.Value; +import fr.inria.corese.core.next.data.api.factory.ValueFactory; +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.storage.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.BeforeEach; + +import java.util.Iterator; +import java.util.List; +import java.util.Set; + + +/** Shared dataset fixture for native pipeline integration tests. */ +abstract class PipelineTestSupport { + static final String ALICE = "http://example.org/alice"; + static final String BOB = "http://example.org/bob"; + static final String KNOWS = "http://example.org/knows"; + static final String NAME = "http://example.org/name"; + + ValueFactory valueFactory; + MemoryStorageManager storage; + NextSparqlPipelineExecutor executor; + + @BeforeEach + void setUp() { + valueFactory = Values.factory(); + storage = MemoryStorageManager.builder().build(); + executor = new NextSparqlPipelineExecutor(storage); + + insert(iri(ALICE), iri(KNOWS), iri(BOB)); + } + + void insert(Resource subject, IRI predicate, Value object) { + storage.mutations().add(valueFactory.createStatement(subject, predicate, object)); + } + + void insertInGraph(Resource subject, IRI predicate, Value object, Resource context) { + storage.mutations().add( + valueFactory.createStatement(subject, predicate, object, context)); + } + + IRI iri(String iri) { + return valueFactory.createIRI(iri); + } + + /** + * Creates a one-entry {@link BindingSet} binding {@code varName} to {@code value}. + */ + BindingSet singleBinding(String varName, Value value) { + Binding b = new Binding() { + @Override public String name() { return varName; } + @Override public Value value() { return value; } + }; + return new BindingSet() { + @Override public Set getBindingNames() { return Set.of(varName); } + @Override public boolean hasBinding(String name) { return varName.equals(name); } + @Override public Value getValue(String name) { return varName.equals(name) ? value : null; } + @Override public Iterator iterator() { return List.of(b).iterator(); } + }; + } +}