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 6e52ede70..138724635 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 @@ -14,10 +14,7 @@ import fr.inria.corese.core.sparql.triple.parser.Expression; import fr.inria.corese.core.sparql.triple.parser.Variable; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Objects; +import java.util.*; /** * Builds KGRAM {@code Exp} / {@code Query} structures from Corese-next query AST nodes. @@ -59,14 +56,18 @@ public CoreseAstQueryBuilder() { public Query toNextQuery(AskQueryAst askQueryAst) { Objects.requireNonNull(askQueryAst, "askQueryAst"); rejectUnsupportedAskClauses(askQueryAst); - - Query query = createQuery( - askQueryAst.whereClause(), - askQueryAst.datasetClause(), - askQueryAst.solutionModifier()); - applyOrderBy(query, askQueryAst.solutionModifier()); - query.setAsk(true); - return query; + SparqlAstToExpression.setPrefixes(buildPrefixMap(askQueryAst.prologue().prefixDeclarations())); + try { + Query query = createQuery( + askQueryAst.whereClause(), + askQueryAst.datasetClause(), + askQueryAst.solutionModifier()); + applyOrderBy(query, askQueryAst.solutionModifier()); + query.setAsk(true); + return query; + } finally { + SparqlAstToExpression.clearPrefixes(); + } } /** @@ -81,15 +82,19 @@ public Query toNextQuery(AskQueryAst askQueryAst) { public Query toNextQuery(SelectQueryAst selectQueryAst) { Objects.requireNonNull(selectQueryAst, "selectQueryAst"); rejectUnsupportedSelectClauses(selectQueryAst); - - Query query = createQuery( - selectQueryAst.whereClause(), - selectQueryAst.datasetClause(), - selectQueryAst.solutionModifier()); - applyProjection(query, selectQueryAst.projection()); - query.setDistinct(selectQueryAst.solutionModifier().distinct()); - applyOrderBy(query, selectQueryAst.solutionModifier()); - return query; + SparqlAstToExpression.setPrefixes(buildPrefixMap(selectQueryAst.prologue().prefixDeclarations())); + try { + Query query = createQuery( + selectQueryAst.whereClause(), + selectQueryAst.datasetClause(), + selectQueryAst.solutionModifier()); + applyProjection(query, selectQueryAst.projection()); + query.setDistinct(selectQueryAst.solutionModifier().distinct()); + applyOrderBy(query, selectQueryAst.solutionModifier()); + return query; + } finally { + SparqlAstToExpression.clearPrefixes(); + } } /** @@ -105,15 +110,19 @@ public Query toNextQuery(SelectQueryAst selectQueryAst) { public Query toNextQuery(DescribeQueryAst describeQueryAst) { Objects.requireNonNull(describeQueryAst, "describeQueryAst"); rejectUnsupportedDescribeClauses(describeQueryAst); - - Query query = createQuery( - describeQueryAst.whereClause(), - describeQueryAst.datasetClause(), - describeQueryAst.solutionModifier()); - applyOrderBy(query, describeQueryAst.solutionModifier()); - List describedNodes = describeNodes(query, describeQueryAst); - lowerDescribeToConstructQuery(query, describedNodes); - return query; + SparqlAstToExpression.setPrefixes(buildPrefixMap(describeQueryAst.prologue().prefixDeclarations())); + try { + Query query = createQuery( + describeQueryAst.whereClause(), + describeQueryAst.datasetClause(), + describeQueryAst.solutionModifier()); + applyOrderBy(query, describeQueryAst.solutionModifier()); + List describedNodes = describeNodes(query, describeQueryAst); + lowerDescribeToConstructQuery(query, describedNodes); + return query; + } finally { + SparqlAstToExpression.clearPrefixes(); + } } /** @@ -129,38 +138,23 @@ public Query toNextQuery(DescribeQueryAst describeQueryAst) { public Query toNextQuery(ConstructQueryAst constructQueryAst) { Objects.requireNonNull(constructQueryAst, "constructQueryAst"); rejectUnsupportedConstructClauses(constructQueryAst); - - Query query = createQuery( - constructQueryAst.whereClause(), - constructQueryAst.datasetClause(), - constructQueryAst.solutionModifier()); - applyOrderBy(query, constructQueryAst.solutionModifier()); - Exp template = compileConstructTemplate(query, constructQueryAst.constructTemplate()); - query.setConstruct(true); - query.setConstruct(template); - query.setConstructNodes(template.getNodes()); - return query; - } - - /** - * Converts a filter expression carried as {@link TermAst}: must be a {@link ConstraintAst}. - */ - public Filter toNextFilter(TermAst filterExpression) { - Objects.requireNonNull(filterExpression, "filterExpression"); - if (!(filterExpression instanceof ConstraintAst constraint)) { - throw new IllegalArgumentException( - "FILTER expects a ConstraintAst, got: " + filterExpression.getClass().getName()); + SparqlAstToExpression.setPrefixes(buildPrefixMap(constructQueryAst.prologue().prefixDeclarations())); + try { + Query query = createQuery( + constructQueryAst.whereClause(), + constructQueryAst.datasetClause(), + constructQueryAst.solutionModifier()); + applyOrderBy(query, constructQueryAst.solutionModifier()); + Exp template = compileConstructTemplate(query, constructQueryAst.constructTemplate()); + query.setConstruct(true); + query.setConstruct(template); + query.setConstructNodes(template.getNodes()); + return query; + } finally { + SparqlAstToExpression.clearPrefixes(); } - return toNextFilter(constraint); } - /** - * Converts a constraint tree (boolean filter expression) into a KGRAM {@link Filter}. - */ - public Filter toNextFilter(ConstraintAst filterExpression) { - Objects.requireNonNull(filterExpression, "filterExpression"); - return SparqlAstToExpression.toNextFilter(filterExpression, whereCompiler); - } /** * Converts a query term used as subject, predicate, object, or variable reference @@ -188,6 +182,14 @@ static TermAst simplePredicate(PathAst path) { + path.getClass().getSimpleName()); } + private static Map buildPrefixMap(List decls) { + Map map = new HashMap<>(decls.size() * 2); + for (PrefixDeclarationAst decl : decls) { + map.put(decl.prefix(), decl.namespace().raw()); + } + return map; + } + private static void rejectUnsupportedAskClauses(AskQueryAst askQueryAst) { if (!askQueryAst.valuesClause().mappings().isEmpty()) { throw new UnsupportedQueryFeatureException( diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlAstToExpression.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlAstToExpression.java index 1df0eb3b3..064f9ae6a 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlAstToExpression.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlAstToExpression.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -22,6 +23,25 @@ */ public final class SparqlAstToExpression { + /** + * Per-thread prefix map for the query currently being compiled. + * Set by {@link #setPrefixes}/{@link #clearPrefixes} in {@link CoreseAstQueryBuilder}. + */ + private static final ThreadLocal> QUERY_PREFIXES = new ThreadLocal<>(); + + /** Package-private: called by {@link CoreseAstQueryBuilder} before building each query. */ + static void setPrefixes(Map prefixes) { + QUERY_PREFIXES.set(prefixes); + } + + /** Package-private: called by {@link CoreseAstQueryBuilder} after building each query. */ + static void clearPrefixes() { + QUERY_PREFIXES.remove(); + } + + private static final String BLANK_NODE_VAR_PREFIX = "__bn_"; + + private SparqlAstToExpression() { } @@ -33,6 +53,8 @@ public static Expression convert(TermAst term) { case VarAst(String name) -> Variable.create(name); case LiteralAst(String lexical, String lang, String datatype) -> literalToConstant(lexical, lang, datatype); + case IriAst(String raw) when raw.startsWith(IOConstants.BLANK_NODE_PREFIX) -> + Variable.create(BLANK_NODE_VAR_PREFIX + raw.substring(IOConstants.BLANK_NODE_PREFIX.length())); case IriAst(String raw) -> iriToConstant(raw); case ConstraintAst c -> constraintToExpression(c); default -> throw new IllegalStateException("Unhandled TermAst: " + term.getClass()); @@ -54,8 +76,8 @@ public static Filter toNextFilter(FilterAst filterClause, WhereCompiler whereCom * Filters containing {@code EXISTS} / {@code NOT EXISTS} require * {@link #toNextFilter(TermAst, WhereCompiler)} so their graph pattern can be compiled. */ - public static Filter toNextFilter(TermAst filterExpression) { - return toNextFilter(filterExpression, null); + public static void toNextFilter(TermAst filterExpression) { + toNextFilter(filterExpression, null); } /** @@ -137,6 +159,16 @@ private static Constant iriToConstant(String rawIri) { if (raw.startsWith(IOConstants.BLANK_NODE_PREFIX)) { return Constant.createBlank(raw.substring(IOConstants.BLANK_NODE_PREFIX.length())); } + Map prefixes = QUERY_PREFIXES.get(); + if (prefixes != null) { + int colonIdx = raw.indexOf(':'); + if (colonIdx > 0) { + String ns = prefixes.get(raw.substring(0, colonIdx)); + if (ns != null) { + raw = ns + raw.substring(colonIdx + 1); + } + } + } return Constant.createResource(raw); } diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlAstToExpressionTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlAstToExpressionTest.java index 9eb68c7c1..36a5e114d 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlAstToExpressionTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlAstToExpressionTest.java @@ -24,7 +24,7 @@ void iriAstToExpression() { assertNotNull(iriNode); assertInstanceOf(Constant.class, iriNode); assertTrue(iriNode.isURI()); - assertEquals("http://ns.inria.fr/test/iri", ((Constant)iriNode).getLabel()); + assertEquals("http://ns.inria.fr/test/iri", iriNode.getLabel()); } @Test diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/BgpTermTypesE2ETest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/BgpTermTypesE2ETest.java new file mode 100644 index 000000000..698d303b6 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/BgpTermTypesE2ETest.java @@ -0,0 +1,327 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + +import fr.inria.corese.core.next.data.api.BNode; +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.api.result.BindingSet; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import fr.inria.corese.core.next.storagemanager.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * End-to-end tests for Basic Graph Patterns (BGP) across all RDF term types. + * + *

These tests verify that the pipeline correctly matches and projects:

+ *
    + *
  • plain string literals
  • + *
  • language-tagged literals (@en, @fr, …)
  • + *
  • typed literals (xsd:integer, xsd:decimal, xsd:boolean, xsd:date)
  • + *
  • blank nodes as subjects and objects
  • + *
  • multi-triple BGP join chains
  • + *
  • constants (IRIs) used directly in triple patterns
  • + *
+ */ +class BgpTermTypesE2ETest { + + private static final String EX = "http://example.org/"; + private static final String XSD = "http://www.w3.org/2001/XMLSchema#"; + private static final String NAME = EX + "name"; + private static final String AGE = EX + "age"; + private static final String KNOWS = EX + "knows"; + private static final String LABEL = EX + "label"; + private static final String ACTIVE = EX + "active"; + private static final String SCORE = EX + "score"; + + private CoreseValueFactory vf; + private MemoryStorageManager storage; + private NextSparqlPipelineExecutor executor; + + @BeforeEach + void setUp() { + vf = new CoreseValueFactory(); + storage = MemoryStorageManager.builder().build(); + executor = new NextSparqlPipelineExecutor(storage); + } + + @Test + @DisplayName("BGP matches and projects a plain string literal") + void bgpMatchesPlainStringLiteral() { + insert(iri(EX + "alice"), iri(NAME), vf.createLiteral("Alice")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?name WHERE { <" + EX + "alice> <" + NAME + "> ?name }")) { + + assertTrue(result.hasNext()); + assertEquals("Alice", result.next().getValue("name").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("BGP with constant literal in object position matches correctly") + void bgpWithConstantLiteralInObjectPosition() { + insert(iri(EX + "alice"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "bob"), iri(NAME), vf.createLiteral("Bob")); + + // Only the triple with "Alice" should match + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s WHERE { ?s <" + NAME + "> \"Alice\" }"); + + List subjects = collectSingleColumn(result, "s"); + assertEquals(List.of(EX + "alice"), subjects); + } + + @Test + @DisplayName("BGP projects a language-tagged literal with its language tag") + void bgpProjectsLanguageTaggedLiteral() { + insert(iri(EX + "paris"), iri(LABEL), vf.createLiteral("Paris", "fr")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?label WHERE { <" + EX + "paris> <" + LABEL + "> ?label }")) { + + assertTrue(result.hasNext()); + Value label = result.next().getValue("label"); + assertEquals("Paris", label.stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("BGP distinguishes language-tagged literals with different language tags") + void bgpDistinguishesLanguageTags() { + insert(iri(EX + "london"), iri(LABEL), vf.createLiteral("London", "en")); + insert(iri(EX + "london"), iri(LABEL), vf.createLiteral("Londres", "fr")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?label WHERE { <" + EX + "london> <" + LABEL + "> ?label }"); + + List labels = collectSingleColumn(result, "label"); + assertEquals(2, labels.size(), "Both language-tagged labels must appear"); + assertTrue(labels.contains("London")); + assertTrue(labels.contains("Londres")); + } + + @Test + @DisplayName("BGP matches and projects an xsd:integer literal") + void bgpMatchesIntegerLiteral() { + insert(iri(EX + "alice"), iri(AGE), vf.createLiteral("30", vf.createIRI(XSD + "integer"))); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?age WHERE { <" + EX + "alice> <" + AGE + "> ?age }")) { + + assertTrue(result.hasNext()); + Value age = result.next().getValue("age"); + assertEquals("30", age.stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("BGP with constant xsd:integer in object position matches the right triple") + void bgpWithConstantIntegerInObjectPosition() { + insert(iri(EX + "alice"), iri(AGE), vf.createLiteral("30", vf.createIRI(XSD + "integer"))); + insert(iri(EX + "bob"), iri(AGE), vf.createLiteral("25", vf.createIRI(XSD + "integer"))); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s WHERE { ?s <" + AGE + "> \"30\"^^<" + XSD + "integer> }"); + + List subjects = collectSingleColumn(result, "s"); + assertEquals(List.of(EX + "alice"), subjects); + } + + @Test + @DisplayName("BGP matches and projects an xsd:boolean literal") + void bgpMatchesBooleanLiteral() { + insert(iri(EX + "alice"), iri(ACTIVE), vf.createLiteral("true", vf.createIRI(XSD + "boolean"))); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?active WHERE { <" + EX + "alice> <" + ACTIVE + "> ?active }")) { + + assertTrue(result.hasNext()); + assertEquals("true", result.next().getValue("active").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("BGP matches and projects an xsd:decimal literal") + void bgpMatchesDecimalLiteral() { + insert(iri(EX + "alice"), iri(SCORE), vf.createLiteral("9.5", vf.createIRI(XSD + "decimal"))); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?score WHERE { <" + EX + "alice> <" + SCORE + "> ?score }")) { + + assertTrue(result.hasNext()); + assertEquals("9.5", result.next().getValue("score").stringValue()); + assertFalse(result.hasNext()); + } + } + + + @Test + @DisplayName("BGP matches a blank node in object position and re-uses it as subject") + void bgpJoinsThroughBlankNode() { + // alice --knows--> _:b0 --name--> "Bob" + BNode bnode = vf.createBNode(); + insert(iri(EX + "alice"), iri(KNOWS), bnode); + insert(bnode, iri(NAME), vf.createLiteral("Bob")); + + try (TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?name WHERE { + ?b . + ?b ?name . + } + """)) { + + assertTrue(result.hasNext()); + assertEquals("Bob", result.next().getValue("name").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("BGP projects a blank node variable") + void bgpProjectsBlankNodeVariable() { + BNode bnode = vf.createBNode(); + insert(iri(EX + "alice"), iri(KNOWS), bnode); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?b WHERE { <" + EX + "alice> <" + KNOWS + "> ?b }")) { + + assertTrue(result.hasNext()); + Value bVal = result.next().getValue("b"); + assertNotNull(bVal, "Blank node must be projected as a non-null value"); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("BGP with three-triple chain joins correctly") + void bgpThreeTripleChain() { + // alice --knows--> bob --knows--> carol --name--> "Carol" + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + insert(iri(EX + "bob"), iri(KNOWS), iri(EX + "carol")); + insert(iri(EX + "carol"), iri(NAME), vf.createLiteral("Carol")); + + try (TupleQueryResult result = executor.evaluateTuple(""" + SELECT ?name WHERE { + ?mid . + ?mid ?person . + ?person ?name . + } + """)) { + + assertTrue(result.hasNext()); + assertEquals("Carol", result.next().getValue("name").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("BGP with no matching triple returns empty result") + void bgpWithNoMatchReturnsEmpty() { + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s WHERE { ?s <" + NAME + "> ?name }")) { + + assertFalse(result.hasNext(), "No triple for NAME predicate — result must be empty"); + } + } + + @Test + @DisplayName("BGP with constant IRI subject filters to matching triples only") + void bgpWithConstantIriSubjectFilters() { + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + insert(iri(EX + "bob"), iri(KNOWS), iri(EX + "carol")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?o WHERE { <" + EX + "alice> <" + KNOWS + "> ?o }"); + + List objects = collectSingleColumn(result, "o"); + assertEquals(List.of(EX + "bob"), objects, "Only alice's outgoing knows must appear"); + } + + @Test + @DisplayName("BGP with multiple variables projects all columns") + void bgpProjectsMultipleColumns() { + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s ?p ?o WHERE { ?s ?p ?o }")) { + + assertEquals(List.of("s", "p", "o"), result.getBindingNames()); + assertTrue(result.hasNext()); + BindingSet bs = result.next(); + assertEquals(EX + "alice", bs.getValue("s").stringValue()); + assertEquals(KNOWS, bs.getValue("p").stringValue()); + assertEquals(EX + "bob", bs.getValue("o").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("Blank node label used twice in a pattern matches the same resource in both positions") + void blankNodeLabelReuseJoinsOnSameResource() { + BNode bnode = vf.createBNode(); + insert(bnode, iri(NAME), vf.createLiteral("Bob")); + insert(bnode, iri(AGE), vf.createLiteral("30")); + + // The query uses _:b twice — both occurrences must bind to the same blank node. + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?name ?age WHERE { _:b <" + NAME + "> ?name . _:b <" + AGE + "> ?age }")) { + assertTrue(result.hasNext(), "Pattern with _:b used twice must match when both triples share the same subject"); + var bs = result.next(); + assertEquals("Bob", bs.getValue("name").stringValue()); + assertEquals("30", bs.getValue("age").stringValue()); + assertFalse(result.hasNext(), "Exactly one solution expected"); + } + } + + @Test + @DisplayName("Different blank node labels in a pattern bind independently") + void differentBlankNodeLabelsBindIndependently() { + // _:a and _:b are different non-distinguished variables — they can bind to different resources. + BNode b1 = vf.createBNode(); + BNode b2 = vf.createBNode(); + insert(b1, iri(NAME), vf.createLiteral("Alice")); + insert(b2, iri(NAME), vf.createLiteral("Bob")); + + // _:a and _:b are distinct — both rows must appear + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?n1 ?n2 WHERE { _:a <" + NAME + "> ?n1 . _:b <" + NAME + "> ?n2 }")) { + int count = 0; + while (result.hasNext()) { result.next(); count++; } + // Should produce 4 rows: (Alice,Alice), (Alice,Bob), (Bob,Alice), (Bob,Bob) + // because _:a and _:b are independent variables (no constraint they differ) + assertEquals(4, count, "_:a and _:b are independent — all combinations expected"); + } + } + + private void insert(Resource subject, IRI predicate, Value object) { + storage.getMutationOperations() + .insertStatement(vf.createStatement(subject, predicate, object)); + } + + private IRI iri(String value) { + return vf.createIRI(value); + } + + private List collectSingleColumn(TupleQueryResult result, String varName) { + List values = new ArrayList<>(); + while (result.hasNext()) { + Value v = result.next().getValue(varName); + values.add(v == null ? null : v.stringValue()); + } + return values; + } +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ExecutionErrorHandlingE2ETest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ExecutionErrorHandlingE2ETest.java new file mode 100644 index 000000000..2447d0c35 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/ExecutionErrorHandlingE2ETest.java @@ -0,0 +1,323 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException; +import fr.inria.corese.core.next.query.api.exception.QueryValidationException; +import fr.inria.corese.core.next.query.api.exception.UnsupportedQueryFeatureException; +import fr.inria.corese.core.next.storagemanager.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * End-to-end tests for consistent error handling across the next SPARQL pipeline. + * + *

The pipeline must surface failures with the right exception type at the right + * stage (parse, bridge, evaluation) so callers can distinguish syntax errors, + * unsupported features, and runtime evaluation failures.

+ * + *

All tests use an in-memory storage manager and require no external dependencies.

+ */ +class ExecutionErrorHandlingE2ETest { + + private static final String EX = "http://example.org/"; + private static final String KNOWS = EX + "knows"; + + private CoreseValueFactory vf; + private MemoryStorageManager storage; + private NextSparqlPipelineExecutor executor; + + @BeforeEach + void setUp() { + vf = new CoreseValueFactory(); + storage = MemoryStorageManager.builder().build(); + executor = new NextSparqlPipelineExecutor(storage); + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + } + + + @Nested + @DisplayName("Syntax errors") + class SyntaxErrors { + + @Test + @DisplayName("Completely invalid SPARQL throws QuerySyntaxException") + void completelyInvalidSparqlThrowsSyntaxException() { + assertThrows(QuerySyntaxException.class, () -> { + try (var ignored = executor.evaluateTuple("THIS IS NOT SPARQL")) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("SELECT without WHERE clause throws QuerySyntaxException") + void selectWithoutWhereClauseThrowsSyntaxException() { + assertThrows(QuerySyntaxException.class, () -> { + try (var ignored = executor.evaluateTuple("SELECT ?s")) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("Unclosed curly brace throws QuerySyntaxException") + void unclosedCurlyBraceThrowsSyntaxException() { + assertThrows(QuerySyntaxException.class, () -> { + try (var ignored = executor.evaluateTuple("SELECT * WHERE { ?s ?p ?o")) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("Malformed IRI in triple pattern throws QuerySyntaxException") + void malformedIriThrowsSyntaxException() { + assertThrows(QuerySyntaxException.class, () -> { + try (var ignored = executor.evaluateTuple( + "SELECT * WHERE { ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("ASK with invalid syntax throws QuerySyntaxException") + void askWithInvalidSyntaxThrowsSyntaxException() { + assertThrows(QuerySyntaxException.class, + () -> executor.evaluateBoolean("ASK { ?s ?p")); + } + } + + + @Nested + @DisplayName("Wrong query form") + class WrongQueryForm { + + @Test + @DisplayName("evaluateTuple with an ASK query throws IllegalArgumentException") + void evaluateTupleWithAskThrowsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> { + try (var ignored = executor.evaluateTuple("ASK WHERE { ?s ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("evaluateBoolean with a SELECT query throws IllegalArgumentException") + void evaluateBooleanWithSelectThrowsIllegalArgument() { + assertThrows(IllegalArgumentException.class, + () -> executor.evaluateBoolean("SELECT * WHERE { ?s ?p ?o }")); + } + + @Test + @DisplayName("evaluateTuple with a CONSTRUCT query throws IllegalArgumentException") + void evaluateTupleWithConstructThrowsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> { + try (var ignored = executor.evaluateTuple( + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("evaluateBoolean with a CONSTRUCT query throws IllegalArgumentException") + void evaluateBooleanWithConstructThrowsIllegalArgument() { + assertThrows(IllegalArgumentException.class, + () -> executor.evaluateBoolean( + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")); + } + + @Test + @DisplayName("evaluateGraph with a SELECT query throws IllegalArgumentException") + void evaluateGraphWithSelectThrowsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> { + try (var ignored = executor.evaluateGraph("SELECT * WHERE { ?s ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + } + } + + + @Nested + @DisplayName("Unsupported features (bridge rejects)") + class UnsupportedFeatures { + + @Test + @DisplayName("SELECT REDUCED throws UnsupportedQueryFeatureException") + void selectReducedThrowsUnsupported() { + assertThrows(UnsupportedQueryFeatureException.class, () -> { + try (var ignored = executor.evaluateTuple( + "SELECT REDUCED * WHERE { ?s ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("SELECT with GROUP BY throws UnsupportedQueryFeatureException") + void selectWithGroupByThrowsUnsupported() { + assertThrows(UnsupportedQueryFeatureException.class, () -> { + try (var ignored = executor.evaluateTuple( + "SELECT ?s (COUNT(*) AS ?c) WHERE { ?s ?p ?o } GROUP BY ?s")) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("SELECT with HAVING is rejected before reaching evaluation") + void selectWithHavingIsRejectedByPipeline() { + // HAVING requires GROUP BY; GROUP BY is rejected at the bridge stage with + var ex = assertThrows(RuntimeException.class, () -> { + try (var ignored = executor.evaluateTuple( + "SELECT ?s (COUNT(*) AS ?c) WHERE { ?s ?p ?o } GROUP BY ?s HAVING (?c > 1)")) { + fail("Should have thrown before returning a result"); + } + }); + assertTrue( + ex instanceof UnsupportedQueryFeatureException + || ex instanceof QueryValidationException, + "Pipeline must reject HAVING queries — got: " + ex.getClass().getSimpleName()); + } + + @Test + @DisplayName("SELECT with expression alias throws UnsupportedQueryFeatureException") + void selectWithExpressionAliasThrowsUnsupported() { + assertThrows(UnsupportedQueryFeatureException.class, () -> { + try (var ignored = executor.evaluateTuple( + "SELECT (STR(?s) AS ?label) WHERE { ?s ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("SELECT with inline VALUES throws UnsupportedQueryFeatureException") + void selectWithInlineValuesThrowsUnsupported() { + assertThrows(UnsupportedQueryFeatureException.class, () -> { + try (var ignored = executor.evaluateTuple(""" + SELECT * WHERE { ?s ?p ?o } + VALUES ?s { } + """)) { + fail("Should have thrown before returning a result"); + } + }); + } + + @Test + @DisplayName("ASK with GROUP BY throws UnsupportedQueryFeatureException") + void askWithGroupByThrowsUnsupported() { + assertThrows(UnsupportedQueryFeatureException.class, + () -> executor.evaluateBoolean("ASK { ?s ?p ?o } GROUP BY ?s")); + } + + @Test + @DisplayName("Property path (sequence) throws UnsupportedQueryFeatureException") + void propertyPathSequenceThrowsUnsupported() { + assertThrows(UnsupportedQueryFeatureException.class, () -> { + try (var ignored = executor.evaluateTuple( + "SELECT ?o WHERE { ?s <" + KNOWS + ">/<" + KNOWS + "> ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + } + } + + + @Nested + @DisplayName("Empty results are not errors") + class EmptyResults { + + @Test + @DisplayName("SELECT with no matching triple returns an empty result, not an exception") + void selectWithNoMatchReturnsEmptyResult() { + try (var result = executor.evaluateTuple( + "SELECT * WHERE { ?s <" + EX + "unknown> ?o }")) { + assertFalse(result.hasNext(), "No match must yield an empty result set"); + } + } + + @Test + @DisplayName("ASK with no matching triple returns false, not an exception") + void askWithNoMatchReturnsFalse() { + boolean found = executor.evaluateBoolean( + "ASK WHERE { ?s <" + EX + "unknown> ?o }"); + assertFalse(found); + } + + @Test + @DisplayName("CONSTRUCT with no matching triple returns an empty graph, not an exception") + void constructWithNoMatchReturnsEmptyGraph() { + try (var result = executor.evaluateGraph( + "CONSTRUCT { ?s ?p ?o } WHERE { ?s <" + EX + "unknown> ?o }")) { + assertFalse(result.hasNext(), "No match must yield an empty graph result"); + } + } + } + + + @Nested + @DisplayName("Exception messages are informative") + class ExceptionMessages { + + @Test + @DisplayName("QuerySyntaxException message is non-empty") + void syntaxExceptionHasNonEmptyMessage() { + QuerySyntaxException ex = assertThrows(QuerySyntaxException.class, () -> { + try (var ignored = executor.evaluateTuple("NOT SPARQL")) { + fail("Should have thrown before returning a result"); + } + }); + assertNotNull(ex.getMessage()); + assertFalse(ex.getMessage().isBlank(), "Syntax exception message must not be blank"); + } + + @Test + @DisplayName("UnsupportedQueryFeatureException message is non-empty") + void unsupportedExceptionHasNonEmptyMessage() { + UnsupportedQueryFeatureException ex = assertThrows( + UnsupportedQueryFeatureException.class, () -> { + try (var ignored = executor.evaluateTuple( + "SELECT REDUCED * WHERE { ?s ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + assertNotNull(ex.getMessage()); + assertFalse(ex.getMessage().isBlank(), + "Unsupported feature message must not be blank"); + } + + @Test + @DisplayName("IllegalArgumentException message names the actual query form") + void illegalArgumentExceptionNamesQueryForm() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> { + try (var ignored = executor.evaluateTuple("ASK WHERE { ?s ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + assertNotNull(ex.getMessage()); + assertTrue(ex.getMessage().contains("ASK") || ex.getMessage().contains("SELECT"), + "Message should mention the query form involved"); + } + } + + + private void insert(Resource subject, IRI predicate, Value object) { + storage.getMutationOperations() + .insertStatement(vf.createStatement(subject, predicate, object)); + } + + private IRI iri(String value) { + return vf.createIRI(value); + } +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/SelectAskBgpComplianceE2ETest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/SelectAskBgpComplianceE2ETest.java new file mode 100644 index 000000000..4fc374a6c --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/SelectAskBgpComplianceE2ETest.java @@ -0,0 +1,352 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.api.exception.QueryValidationException; +import fr.inria.corese.core.next.query.api.result.BindingSet; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import fr.inria.corese.core.next.storagemanager.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Compliance tests for the SELECT / ASK / BGP socle — cases identified as missing + * after auditing the existing E2E suite against SPARQL 1.1 section 10 (Basic Graph + * Patterns), section 17.1 (Query forms) and the W3C SPARQL 1.1 test suite manifests. + * + *

Each nested class documents the spec reference that motivates the test.

+ */ +class SelectAskBgpComplianceE2ETest { + + private static final String EX = "http://example.org/"; + private static final String XSD = "http://www.w3.org/2001/XMLSchema#"; + private static final String KNOWS = EX + "knows"; + private static final String NAME = EX + "name"; + private static final String BORN = EX + "born"; + + private CoreseValueFactory vf; + private MemoryStorageManager storage; + private NextSparqlPipelineExecutor executor; + + @BeforeEach + void setUp() { + vf = new CoreseValueFactory(); + storage = MemoryStorageManager.builder().build(); + executor = new NextSparqlPipelineExecutor(storage); + } + + @Nested + @DisplayName("PREFIX declarations") + class PrefixDeclarations { + + @Test + @DisplayName("PREFIX in SELECT query resolves abbreviated IRIs correctly") + void prefixInSelectResolvesAbbreviatedIris() { + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + + try (TupleQueryResult result = executor.evaluateTuple(""" + PREFIX ex: + SELECT ?o WHERE { ex:alice ex:knows ?o } + """)) { + assertTrue(result.hasNext(), "PREFIX must be resolved — one result expected"); + assertEquals(EX + "bob", result.next().getValue("o").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("Multiple PREFIX declarations coexist in the same query") + void multiplePrefixDeclarationsCoexist() { + String foafName = "http://xmlns.com/foaf/0.1/name"; + insert(iri(EX + "alice"), iri(foafName), vf.createLiteral("Alice")); + + try (TupleQueryResult result = executor.evaluateTuple(""" + PREFIX ex: + PREFIX foaf: + SELECT ?name WHERE { ex:alice foaf:name ?name } + """)) { + assertTrue(result.hasNext()); + assertEquals("Alice", result.next().getValue("name").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("PREFIX in ASK query resolves abbreviated IRIs correctly") + void prefixInAskResolvesAbbreviatedIris() { + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + + assertTrue(executor.evaluateBoolean(""" + PREFIX ex: + ASK { ex:alice ex:knows ex:bob } + """)); + } + } + + @Nested + @DisplayName("ASK edge cases") + class AskEdgeCases { + + @Test + @DisplayName("ASK {} with empty WHERE body returns true — one empty solution exists") + void askEmptyBodyReturnsTrue() { + // Per spec, {} matches exactly one empty solution regardless of store content. + assertTrue(executor.evaluateBoolean("ASK {}"), + "ASK {} must return true: the empty pattern always matches once"); + } + + @Test + @DisplayName("ASK {} returns true even when the store is empty") + void askEmptyBodyReturnsTrueOnEmptyStore() { + // Store is empty — no data at all. + assertTrue(executor.evaluateBoolean("ASK {}"), + "ASK {} must return true on an empty store: the empty pattern always matches"); + } + + @Test + @DisplayName("ASK with two-triple join returns true when both triples match") + void askWithTwoTripleJoinReturnsTrueWhenBothMatch() { + // alice --knows--> bob --knows--> carol + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + insert(iri(EX + "bob"), iri(KNOWS), iri(EX + "carol")); + + assertTrue(executor.evaluateBoolean(""" + ASK { + ?mid . + ?mid + } + """)); + } + + @Test + @DisplayName("ASK with two-triple join returns false when the chain is broken") + void askWithTwoTripleJoinReturnsFalseWhenChainBroken() { + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + // bob does NOT know carol + + assertFalse(executor.evaluateBoolean(""" + ASK { + ?mid . + ?mid + } + """)); + } + } + + @Nested + @DisplayName("Empty WHERE body in SELECT") + class EmptyWhereBody { + + @Test + @DisplayName("SELECT * WHERE {} returns exactly one empty solution") + void selectStarEmptyWhereReturnsOneEmptySolution() { + // The empty pattern {} matches once, producing one solution with no bindings. + try (TupleQueryResult result = executor.evaluateTuple("SELECT * WHERE {}")) { + // Binding names: SELECT * on {} → no variables in scope → empty list + assertTrue(result.getBindingNames().isEmpty(), + "SELECT * on an empty pattern must produce no columns"); + assertTrue(result.hasNext(), "One empty solution must be returned"); + BindingSet bs = result.next(); + assertTrue(bs.getBindingNames().isEmpty(), + "The single solution must have no bound variables"); + assertFalse(result.hasNext(), "Only one solution must be returned"); + } + } + + @Test + @DisplayName("SELECT * WHERE {} returns one solution even when the store has data") + void selectStarEmptyWhereReturnsSolutionWhenStoreHasData() { + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + + try (TupleQueryResult result = executor.evaluateTuple("SELECT * WHERE {}")) { + assertTrue(result.hasNext(), + "One empty solution must be returned regardless of store content"); + result.next(); + assertFalse(result.hasNext(), "Only one solution must be returned"); + } + } + } + + @Nested + @DisplayName("Projected variable not visible in WHERE") + class ProjectedVariableNotInWhere { + + @Test + @DisplayName("SELECT ?x WHERE { ?s ?p ?o } throws when ?x is not in the body") + void selectProjectedVarNotInWhereThrows() { + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + + assertThrows(QueryValidationException.class, + () -> { + try (var ignored = executor.evaluateTuple("SELECT ?x WHERE { ?s ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }, + "A variable projected in SELECT but absent from WHERE must cause an error"); + } + + @Test + @DisplayName("Error message names the missing variable") + void selectProjectedVarNotInWhereMessageNamesVariable() { + insert(iri(EX + "alice"), iri(KNOWS), iri(EX + "bob")); + + QueryValidationException ex = assertThrows(QueryValidationException.class, + () -> { + try (var ignored = executor.evaluateTuple("SELECT ?missing WHERE { ?s ?p ?o }")) { + fail("Should have thrown before returning a result"); + } + }); + assertTrue(ex.getMessage().contains("missing"), + "Exception message must name the absent variable — got: " + ex.getMessage()); + } + } + + @Nested + @DisplayName("xsd:date and xsd:dateTime literals in BGP") + class DateLiterals { + + @Test + @DisplayName("BGP matches and projects an xsd:date literal") + void bgpMatchesDateLiteral() { + insert(iri(EX + "alice"), iri(BORN), + vf.createLiteral("1990-01-15", vf.createIRI(XSD + "date"))); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?born WHERE { <" + EX + "alice> <" + BORN + "> ?born }")) { + assertTrue(result.hasNext()); + assertEquals("1990-01-15", result.next().getValue("born").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("BGP with constant xsd:date in object position matches the right triple") + void bgpWithConstantDateInObjectPosition() { + insert(iri(EX + "alice"), iri(BORN), + vf.createLiteral("1990-01-15", vf.createIRI(XSD + "date"))); + insert(iri(EX + "bob"), iri(BORN), + vf.createLiteral("1985-06-20", vf.createIRI(XSD + "date"))); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s WHERE { ?s <" + BORN + "> \"1990-01-15\"^^<" + XSD + "date> }")) { + List subjects = collectSingleColumn(result, "s"); + assertEquals(List.of(EX + "alice"), subjects); + } + } + + @Test + @DisplayName("BGP matches and projects an xsd:dateTime literal") + void bgpMatchesDateTimeLiteral() { + insert(iri(EX + "event"), iri(BORN), + vf.createLiteral("2024-03-15T10:30:00", vf.createIRI(XSD + "dateTime"))); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?dt WHERE { <" + EX + "event> <" + BORN + "> ?dt }")) { + assertTrue(result.hasNext()); + assertEquals("2024-03-15T10:30:00", result.next().getValue("dt").stringValue()); + assertFalse(result.hasNext()); + } + } + } + + // ========================================================================= + // 6. DISTINCT combined with ORDER BY + // ========================================================================= + + @Nested + @DisplayName("DISTINCT combined with ORDER BY") + class DistinctWithOrderBy { + + @Test + @DisplayName("SELECT DISTINCT with ORDER BY deduplicates before sorting") + void selectDistinctWithOrderByDeduplicatesAndSorts() { + // alice and carol share the same name; bob is unique + insert(iri(EX + "alice"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "bob"), iri(NAME), vf.createLiteral("Bob")); + insert(iri(EX + "carol"), iri(NAME), vf.createLiteral("Alice")); // duplicate value + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT DISTINCT ?name WHERE { ?s <" + NAME + "> ?name } ORDER BY ?name")) { + List names = collectSingleColumn(result, "name"); + assertEquals(List.of("Alice", "Bob"), names, + "DISTINCT must collapse duplicates; ORDER BY must then sort"); + } + } + + @Test + @DisplayName("SELECT DISTINCT with ORDER BY DESC produces deduplicated descending results") + void selectDistinctWithOrderByDescDeduplicatesAndSortsDesc() { + insert(iri(EX + "a"), iri(NAME), vf.createLiteral("Charlie")); + insert(iri(EX + "b"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "c"), iri(NAME), vf.createLiteral("Charlie")); // duplicate + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT DISTINCT ?name WHERE { ?s <" + NAME + "> ?name } ORDER BY DESC(?name)")) { + List names = collectSingleColumn(result, "name"); + assertEquals(List.of("Charlie", "Alice"), names); + } + } + } + + @Nested + @DisplayName("ORDER BY with mixed RDF term types") + class OrderByMixedTypes { + + @Test + @DisplayName("ORDER BY places IRIs before plain literals (SPARQL order)") + void orderByPlacesIrisBeforeLiterals() { + insert(iri(EX + "r1"), iri(KNOWS), vf.createLiteral("a literal")); + insert(iri(EX + "r1"), iri(KNOWS), iri(EX + "anIri")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?o WHERE { <" + EX + "r1> <" + KNOWS + "> ?o } ORDER BY ?o")) { + List values = collectSingleColumn(result, "o"); + assertEquals(2, values.size(), "Both IRI and literal must be returned"); + // IRI must come before literal per SPARQL ordering + assertEquals(EX + "anIri", values.get(0), "IRI must sort before literal"); + assertEquals("a literal", values.get(1), "Literal must sort after IRI"); + } + } + + @Test + @DisplayName("ORDER BY literals of the same plain type sorts lexicographically") + void orderByLiteralsOfSamePlainTypeSortsLexicographically() { + insert(iri(EX + "r1"), iri(NAME), vf.createLiteral("Zara")); + insert(iri(EX + "r2"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "r3"), iri(NAME), vf.createLiteral("Mike")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?name WHERE { ?s <" + NAME + "> ?name } ORDER BY ?name")) { + List names = collectSingleColumn(result, "name"); + assertEquals(List.of("Alice", "Mike", "Zara"), names); + } + } + } + + private void insert(Resource subject, IRI predicate, Value object) { + storage.getMutationOperations() + .insertStatement(vf.createStatement(subject, predicate, object)); + } + + private IRI iri(String value) { + return vf.createIRI(value); + } + + private List collectSingleColumn(TupleQueryResult result, String varName) { + List values = new ArrayList<>(); + while (result.hasNext()) { + Value v = result.next().getValue(varName); + values.add(v == null ? null : v.stringValue()); + } + return values; + } +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/SelectModifiersE2ETest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/SelectModifiersE2ETest.java new file mode 100644 index 000000000..3e3d4aa01 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/SelectModifiersE2ETest.java @@ -0,0 +1,277 @@ +package fr.inria.corese.core.next.query.impl.sparql.execution; + +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.api.result.BindingSet; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import fr.inria.corese.core.next.storagemanager.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * End-to-end tests for SELECT solution modifiers: DISTINCT, ORDER BY, LIMIT, OFFSET. + * + *

All tests exercise the full pipeline: parser → AST → bridge → KGRAM → result adapter. + * The storage backend is always an in-memory store to keep tests self-contained.

+ */ +class SelectModifiersE2ETest { + + private static final String EX = "http://example.org/"; + private static final String TYPE = EX + "type"; + private static final String NAME = EX + "name"; + + private CoreseValueFactory vf; + private MemoryStorageManager storage; + private NextSparqlPipelineExecutor executor; + + @BeforeEach + void setUp() { + vf = new CoreseValueFactory(); + storage = MemoryStorageManager.builder().build(); + executor = new NextSparqlPipelineExecutor(storage); + } + + @Test + @DisplayName("SELECT DISTINCT on multi-column result removes duplicate solution mappings") + void selectDistinctMultiColumnRemovesDuplicateSolutionMappings() { + String AGE = EX + "age"; + insert(iri(EX + "alice"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "alice"), iri(AGE), vf.createLiteral("30")); + insert(iri(EX + "carol"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "carol"), iri(AGE), vf.createLiteral("30")); + insert(iri(EX + "bob"), iri(NAME), vf.createLiteral("Bob")); + insert(iri(EX + "bob"), iri(AGE), vf.createLiteral("25")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT DISTINCT ?name ?age WHERE { ?s <" + NAME + "> ?name ; <" + AGE + "> ?age }")) { + List> rows = new ArrayList<>(); + while (result.hasNext()) { + var bs = result.next(); + rows.add(List.of( + bs.getValue("name").stringValue(), + bs.getValue("age").stringValue() + )); + } + assertEquals(2, rows.size(), + "DISTINCT must collapse (Alice,30) duplicate — expected 2 distinct rows, got: " + rows); + long aliceCount = rows.stream().filter(r -> r.getFirst().equals("Alice")).count(); + assertEquals(1, aliceCount, "(Alice,30) must appear exactly once after DISTINCT"); + } + } + + @Test + @DisplayName("SELECT DISTINCT preserves rows with same first column but different second column") + void selectDistinctMultiColumnPreservesPartiallyDifferentRows() { + // (Alice, 30) and (Alice, 31) differ in age → both must be kept + String AGE = EX + "age"; + insert(iri(EX + "alice1"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "alice1"), iri(AGE), vf.createLiteral("30")); + insert(iri(EX + "alice2"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "alice2"), iri(AGE), vf.createLiteral("31")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT DISTINCT ?name ?age WHERE { ?s <" + NAME + "> ?name ; <" + AGE + "> ?age }")) { + int count = 0; + while (result.hasNext()) { result.next(); count++; } + assertEquals(2, count, "(Alice,30) and (Alice,31) differ in ?age — both must survive DISTINCT"); + } + } + + @Test + @DisplayName("SELECT DISTINCT removes duplicate values") + void selectDistinctRemovesDuplicates() { + // alice and bob both have the same type → would produce two rows for ?t without DISTINCT + insert(iri(EX + "alice"), iri(TYPE), iri(EX + "Person")); + insert(iri(EX + "bob"), iri(TYPE), iri(EX + "Person")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT DISTINCT ?t WHERE { ?s <" + TYPE + "> ?t }"); + + List types = collectSingleColumn(result, "t"); + assertEquals(1, types.size(), "DISTINCT should collapse two identical ?t values to one"); + assertEquals(EX + "Person", types.getFirst()); + } + + @Test + @DisplayName("SELECT DISTINCT preserves all rows when values differ") + void selectDistinctKeepsDistinctRows() { + insert(iri(EX + "alice"), iri(TYPE), iri(EX + "Person")); + insert(iri(EX + "alice"), iri(TYPE), iri(EX + "Agent")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT DISTINCT ?t WHERE { <" + EX + "alice> <" + TYPE + "> ?t }"); + + List types = collectSingleColumn(result, "t"); + assertEquals(2, types.size(), "Two distinct type values must both appear"); + assertTrue(types.contains(EX + "Person")); + assertTrue(types.contains(EX + "Agent")); + } + + @Test + @DisplayName("SELECT without DISTINCT returns duplicates") + void selectWithoutDistinctReturnsDuplicates() { + insert(iri(EX + "alice"), iri(TYPE), iri(EX + "Person")); + insert(iri(EX + "bob"), iri(TYPE), iri(EX + "Person")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?t WHERE { ?s <" + TYPE + "> ?t }"); + + List types = collectSingleColumn(result, "t"); + assertEquals(2, types.size(), "Without DISTINCT both rows should appear"); + } + + + @Test + @DisplayName("ORDER BY ASC sorts string literals in ascending lexicographic order") + void orderByAscSortsLexicographically() { + insert(iri(EX + "c"), iri(NAME), vf.createLiteral("Charlie")); + insert(iri(EX + "a"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "b"), iri(NAME), vf.createLiteral("Bob")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?name WHERE { ?s <" + NAME + "> ?name } ORDER BY ASC(?name)"); + + List names = collectSingleColumn(result, "name"); + assertEquals(List.of("Alice", "Bob", "Charlie"), names); + } + + @Test + @DisplayName("ORDER BY DESC sorts string literals in descending lexicographic order") + void orderByDescSortsLexicographically() { + insert(iri(EX + "c"), iri(NAME), vf.createLiteral("Charlie")); + insert(iri(EX + "a"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "b"), iri(NAME), vf.createLiteral("Bob")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?name WHERE { ?s <" + NAME + "> ?name } ORDER BY DESC(?name)"); + + List names = collectSingleColumn(result, "name"); + assertEquals(List.of("Charlie", "Bob", "Alice"), names); + } + + @Test + @DisplayName("ORDER BY without ASC/DESC defaults to ascending order") + void orderByDefaultIsAscending() { + insert(iri(EX + "c"), iri(NAME), vf.createLiteral("Charlie")); + insert(iri(EX + "a"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "b"), iri(NAME), vf.createLiteral("Bob")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?name WHERE { ?s <" + NAME + "> ?name } ORDER BY ?name"); + + List names = collectSingleColumn(result, "name"); + assertEquals(List.of("Alice", "Bob", "Charlie"), names); + } + + @Test + @DisplayName("LIMIT 1 returns at most one result") + void limitOneReturnsOneRow() { + insert(iri(EX + "alice"), iri(TYPE), iri(EX + "Person")); + insert(iri(EX + "bob"), iri(TYPE), iri(EX + "Person")); + insert(iri(EX + "carol"), iri(TYPE), iri(EX + "Person")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s WHERE { ?s <" + TYPE + "> <" + EX + "Person> } LIMIT 1"); + + List subjects = collectSingleColumn(result, "s"); + assertEquals(1, subjects.size(), "LIMIT 1 must return exactly one row"); + } + + @Test + @DisplayName("LIMIT larger than result set returns all results") + void limitLargerThanResultSetReturnsAll() { + insert(iri(EX + "alice"), iri(TYPE), iri(EX + "Person")); + insert(iri(EX + "bob"), iri(TYPE), iri(EX + "Person")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s WHERE { ?s <" + TYPE + "> <" + EX + "Person> } LIMIT 100"); + + List subjects = collectSingleColumn(result, "s"); + assertEquals(2, subjects.size(), "LIMIT larger than actual results must return all rows"); + } + + @Test + @DisplayName("LIMIT 0 returns no results") + void limitZeroReturnsNoResults() { + insert(iri(EX + "alice"), iri(TYPE), iri(EX + "Person")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s WHERE { ?s <" + TYPE + "> <" + EX + "Person> } LIMIT 0")) { + + assertFalse(result.hasNext(), "LIMIT 0 must return an empty result set"); + } + } + + @Test + @DisplayName("ORDER BY + OFFSET skips the first N results") + void orderByWithOffsetSkipsLeadingRows() { + insert(iri(EX + "a"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "b"), iri(NAME), vf.createLiteral("Bob")); + insert(iri(EX + "c"), iri(NAME), vf.createLiteral("Charlie")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?name WHERE { ?s <" + NAME + "> ?name } ORDER BY ?name OFFSET 1"); + + List names = collectSingleColumn(result, "name"); + assertEquals(List.of("Bob", "Charlie"), names, "OFFSET 1 must skip the first row"); + } + + @Test + @DisplayName("ORDER BY + LIMIT + OFFSET returns a sub-page of results") + void orderByLimitOffsetReturnsPaginatedResults() { + insert(iri(EX + "a"), iri(NAME), vf.createLiteral("Alice")); + insert(iri(EX + "b"), iri(NAME), vf.createLiteral("Bob")); + insert(iri(EX + "c"), iri(NAME), vf.createLiteral("Charlie")); + insert(iri(EX + "d"), iri(NAME), vf.createLiteral("Diana")); + + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?name WHERE { ?s <" + NAME + "> ?name } ORDER BY ?name LIMIT 2 OFFSET 1"); + + List names = collectSingleColumn(result, "name"); + assertEquals(List.of("Bob", "Charlie"), names, + "LIMIT 2 OFFSET 1 must return the second and third rows of the ordered set"); + } + + @Test + @DisplayName("OFFSET beyond result set returns empty result") + void offsetBeyondResultSetReturnsEmpty() { + insert(iri(EX + "alice"), iri(TYPE), iri(EX + "Person")); + + try (TupleQueryResult result = executor.evaluateTuple( + "SELECT ?s WHERE { ?s <" + TYPE + "> ?o } ORDER BY ?s OFFSET 10")) { + + assertFalse(result.hasNext(), "OFFSET beyond result count must return an empty result set"); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void insert(Resource subject, IRI predicate, Value object) { + storage.getMutationOperations() + .insertStatement(vf.createStatement(subject, predicate, object)); + } + + private IRI iri(String value) { + return vf.createIRI(value); + } + + private List collectSingleColumn(TupleQueryResult result, String varName) { + List values = new ArrayList<>(); + while (result.hasNext()) { + BindingSet bs = result.next(); + Value v = bs.getValue(varName); + values.add(v == null ? null : v.stringValue()); + } + return values; + } +}