From 73b021175bbc97cae140d06b7986e5291bc1a497 Mon Sep 17 00:00:00 2001 From: "AD\\aabdoun" Date: Tue, 29 Sep 2026 15:16:10 +0200 Subject: [PATCH 1/2] fix(sparql): apply AST simplification for nested OPTIONAL group filter scoping (#613) WhereCompiler.compileGroup() is restored to its original form; the transformation now lives entirely at the AST layer. --- .../impl/sparql/bridge/AstSimplifier.java | 88 +++++++++++++++++++ .../impl/sparql/bridge/WhereCompiler.java | 16 ++-- .../NextSparqlPipelineScopeExecutorTest.java | 59 +++++++++++++ 3 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstSimplifier.java diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstSimplifier.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstSimplifier.java new file mode 100644 index 000000000..cfaa5b07d --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstSimplifier.java @@ -0,0 +1,88 @@ +package fr.inria.corese.core.next.query.impl.sparql.bridge; + +import fr.inria.corese.core.next.query.impl.sparql.ast.GraphAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.GroupGraphPatternAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.MinusAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.OptionalAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.PatternAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.ServiceAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.UnionAst; + +import java.util.ArrayList; +import java.util.List; + +/** + * Applies the SPARQL simplified-group transformation on the AST. + * + *

Per SPARQL 1.1 algebra (§18.2.2), a nested {@link GroupGraphPatternAst} + * whose top-level elements contain no structural operators (OPTIONAL, MINUS, + * UNION) is simple and therefore transparent: its contents are merged + * directly into the enclosing group ({@code { { P } } ≡ { P_elements }}).

+ * + *

This transformation is applied before compilation so that FILTERs inside + * such groups are visible as direct children of the compiled pattern and can be + * correctly detected and deferred to merge time by the OPTIONAL evaluation + * machinery ({@code Exp.optional()}).

+ */ +final class AstSimplifier { + + /** + * Returns a new {@link GroupGraphPatternAst} with the first leading simple + * nested group flattened into the enclosing group, applied recursively. + * + *

Only the first element of a group is eligible for flattening. + * A FILTER in a nested group that follows other patterns (e.g. + * {@code { BGP . { FILTER } }}) must remain scoped to its inner group and + * cannot see variables bound by the preceding BGP — this is the W3C + * {@code filter-nested-2} semantics. Flattening only the leading element + * mirrors the {@code body.size() == 0} condition in + * {@link WhereCompiler#compileGroup}.

+ */ + GroupGraphPatternAst simplify(GroupGraphPatternAst group) { + List result = new ArrayList<>(); + boolean canFlattenNext = true; + for (PatternAst pattern : group.patterns()) { + PatternAst simplified = simplifyPattern(pattern); + if (canFlattenNext + && simplified instanceof GroupGraphPatternAst nested + && isSimple(nested)) { + // First leading simple nested group is transparent: inline its elements + result.addAll(nested.patterns()); + } else { + result.add(simplified); + } + canFlattenNext = false; + } + return new GroupGraphPatternAst(result); + } + + private PatternAst simplifyPattern(PatternAst pattern) { + return switch (pattern) { + case GroupGraphPatternAst g -> simplify(g); + case OptionalAst(PatternAst inner) -> + new OptionalAst(inner instanceof GroupGraphPatternAst g ? simplify(g) : inner); + case MinusAst(GroupGraphPatternAst inner) -> new MinusAst(simplify(inner)); + case UnionAst(GroupGraphPatternAst left, GroupGraphPatternAst right) -> + new UnionAst(simplify(left), simplify(right)); + case GraphAst(TermAst name, GroupGraphPatternAst g) -> + new GraphAst(name, simplify(g)); + case ServiceAst(TermAst endpoint, boolean silent, GroupGraphPatternAst g) -> + new ServiceAst(endpoint, silent, simplify(g)); + default -> pattern; + }; + } + + /** + * A group is simple (transparent) when none of its top-level + * elements is a structural operator (OPTIONAL, MINUS, UNION). + */ + private boolean isSimple(GroupGraphPatternAst group) { + for (PatternAst p : group.patterns()) { + if (p instanceof OptionalAst || p instanceof MinusAst || p instanceof UnionAst) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java index 548f323e9..e137ae9d1 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java @@ -84,16 +84,17 @@ Set inScopeVariables() { */ public Exp compile(GroupGraphPatternAst where) { Objects.requireNonNull(where, "where"); - if (inScopeVariables.isEmpty()) { - inScopeVariables = Set.copyOf( - new fr.inria.corese.core.next.query.impl.sparql.parser.semantic.support.VariableScopeAnalyzer() - .collectVisibleVariables(where)); - } Exp cached = compiledPatternCache.get(where); if (cached != null) { return cached; } - Exp compiled = compileGroup(where); + GroupGraphPatternAst simplified = new AstSimplifier().simplify(where); + if (inScopeVariables.isEmpty()) { + inScopeVariables = Set.copyOf( + new fr.inria.corese.core.next.query.impl.sparql.parser.semantic.support.VariableScopeAnalyzer() + .collectVisibleVariables(simplified)); + } + Exp compiled = compileGroup(simplified); compiledPatternCache.put(where, compiled); return compiled; } @@ -158,7 +159,8 @@ case MinusAst(GroupGraphPatternAst pattern) -> { body.add(minusExp); } case GroupGraphPatternAst nested -> { - Exp joined = Exp.create(Type.JOIN, body, compile(nested)); + Exp compiledNested = compile(nested); + Exp joined = Exp.create(Type.JOIN, body, compiledNested); body = Exp.create(Type.AND); body.add(joined); } diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineScopeExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineScopeExecutorTest.java index 02b88c1df..b5bc4a9b3 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineScopeExecutorTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineScopeExecutorTest.java @@ -3,11 +3,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import fr.inria.corese.core.next.data.api.term.BNode; +import fr.inria.corese.core.next.data.api.term.IRI; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -59,6 +62,62 @@ void blankNodeStillJoinsTriplesAcrossExistsFilter(String filter) { } } + /** + * dawg-optional-filter-005-simplified (W3C SPARQL 1.0 / SPARQL 1.1 simplified semantics): + * In {@code OPTIONAL { { BGP . FILTER(?outerVar = x) } }}, the inner {@code { }} is + * transparent — it does not create an evaluation-scope barrier for outer variables. + * The FILTER is postponed and evaluated at merge time (when both the left solution + * carrying {@code ?outerVar} and the right solution carrying the BGP bindings are + * available), giving the correct result: only the row where the FILTER passes + * receives the optional binding. + * + *

This is the "simplified" reading chosen by Corese, aligned with the + * SPARQL 1.1 algebra where an inner group graph pattern with no structural + * sub-patterns (OPTIONAL/MINUS/UNION) flattens into its parent.

+ */ + @Test + @DisplayName("FILTER referencing outer variable inside nested OPTIONAL group — simplified semantics (issue #613)") + void filterReferencingOuterVarInNestedOptionalGroupIsSimplified() { + IRI dcTitle = iri("http://purl.org/dc/elements/1.1/title"); + IRI xPrice = iri("http://example.org/ns#price"); + IRI book1 = iri("http://example.org/books#book1"); + IRI book2 = iri("http://example.org/books#book2"); + IRI book3 = iri("http://example.org/books#book3"); + + insert(book1, dcTitle, valueFactory.createLiteral("TITLE 1")); + insert(book1, xPrice, valueFactory.createLiteral(10)); + insert(book2, dcTitle, valueFactory.createLiteral("TITLE 2")); + insert(book2, xPrice, valueFactory.createLiteral(20)); + insert(book3, dcTitle, valueFactory.createLiteral("TITLE 3")); + + try (var result = executor.evaluateTuple(""" + PREFIX dc: + PREFIX x: + SELECT ?title ?price + WHERE { + ?book dc:title ?title . + OPTIONAL { + { ?book x:price ?price . + FILTER (?title = "TITLE 2") . } + } + } + """)) { + var rows = result.stream().toList(); + assertEquals(3, rows.size(), "All three titles must appear"); + // Only TITLE 2 passes the filter — it alone gets a price binding + long withPrice = rows.stream().filter(r -> r.getValue("price") != null).count(); + assertEquals(1, withPrice, "Exactly one row (TITLE 2) should have a price binding"); + var title2Row = rows.stream() + .filter(r -> "TITLE 2".equals(r.getValue("title").stringValue())) + .findFirst().orElseThrow(); + assertEquals("20", title2Row.getValue("price").stringValue()); + rows.stream() + .filter(r -> !"TITLE 2".equals(r.getValue("title").stringValue())) + .forEach(r -> assertNull(r.getValue("price"), + "Titles other than TITLE 2 must not bind ?price")); + } + } + @Test void constructLabelCreatesAFreshNodeInsteadOfReusingTheWhereBinding() { try (var result = executor.evaluateGraph(""" From ae71654be35d29fff416631046445049e9004945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?= Date: Fri, 2 Oct 2026 09:33:51 +0200 Subject: [PATCH 2/2] fix(sparql): preserve nested filter scope and return zero for empty AVG --- .../impl/sparql/bridge/AstSimplifier.java | 88 -------------- .../bridge/NativeAggregateEvaluator.java | 3 +- .../impl/sparql/bridge/WhereCompiler.java | 11 +- ...xtSparqlPipelineAggregateExecutorTest.java | 3 +- .../NextSparqlPipelineScopeExecutorTest.java | 111 +++++++++++++----- 5 files changed, 92 insertions(+), 124 deletions(-) delete mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstSimplifier.java diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstSimplifier.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstSimplifier.java deleted file mode 100644 index cfaa5b07d..000000000 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/AstSimplifier.java +++ /dev/null @@ -1,88 +0,0 @@ -package fr.inria.corese.core.next.query.impl.sparql.bridge; - -import fr.inria.corese.core.next.query.impl.sparql.ast.GraphAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.GroupGraphPatternAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.MinusAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.OptionalAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.PatternAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.ServiceAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst; -import fr.inria.corese.core.next.query.impl.sparql.ast.UnionAst; - -import java.util.ArrayList; -import java.util.List; - -/** - * Applies the SPARQL simplified-group transformation on the AST. - * - *

Per SPARQL 1.1 algebra (§18.2.2), a nested {@link GroupGraphPatternAst} - * whose top-level elements contain no structural operators (OPTIONAL, MINUS, - * UNION) is simple and therefore transparent: its contents are merged - * directly into the enclosing group ({@code { { P } } ≡ { P_elements }}).

- * - *

This transformation is applied before compilation so that FILTERs inside - * such groups are visible as direct children of the compiled pattern and can be - * correctly detected and deferred to merge time by the OPTIONAL evaluation - * machinery ({@code Exp.optional()}).

- */ -final class AstSimplifier { - - /** - * Returns a new {@link GroupGraphPatternAst} with the first leading simple - * nested group flattened into the enclosing group, applied recursively. - * - *

Only the first element of a group is eligible for flattening. - * A FILTER in a nested group that follows other patterns (e.g. - * {@code { BGP . { FILTER } }}) must remain scoped to its inner group and - * cannot see variables bound by the preceding BGP — this is the W3C - * {@code filter-nested-2} semantics. Flattening only the leading element - * mirrors the {@code body.size() == 0} condition in - * {@link WhereCompiler#compileGroup}.

- */ - GroupGraphPatternAst simplify(GroupGraphPatternAst group) { - List result = new ArrayList<>(); - boolean canFlattenNext = true; - for (PatternAst pattern : group.patterns()) { - PatternAst simplified = simplifyPattern(pattern); - if (canFlattenNext - && simplified instanceof GroupGraphPatternAst nested - && isSimple(nested)) { - // First leading simple nested group is transparent: inline its elements - result.addAll(nested.patterns()); - } else { - result.add(simplified); - } - canFlattenNext = false; - } - return new GroupGraphPatternAst(result); - } - - private PatternAst simplifyPattern(PatternAst pattern) { - return switch (pattern) { - case GroupGraphPatternAst g -> simplify(g); - case OptionalAst(PatternAst inner) -> - new OptionalAst(inner instanceof GroupGraphPatternAst g ? simplify(g) : inner); - case MinusAst(GroupGraphPatternAst inner) -> new MinusAst(simplify(inner)); - case UnionAst(GroupGraphPatternAst left, GroupGraphPatternAst right) -> - new UnionAst(simplify(left), simplify(right)); - case GraphAst(TermAst name, GroupGraphPatternAst g) -> - new GraphAst(name, simplify(g)); - case ServiceAst(TermAst endpoint, boolean silent, GroupGraphPatternAst g) -> - new ServiceAst(endpoint, silent, simplify(g)); - default -> pattern; - }; - } - - /** - * A group is simple (transparent) when none of its top-level - * elements is a structural operator (OPTIONAL, MINUS, UNION). - */ - private boolean isSimple(GroupGraphPatternAst group) { - for (PatternAst p : group.patterns()) { - if (p instanceof OptionalAst || p instanceof MinusAst || p instanceof UnionAst) { - return false; - } - } - return true; - } -} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeAggregateEvaluator.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeAggregateEvaluator.java index ce31815db..0736c9352 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeAggregateEvaluator.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeAggregateEvaluator.java @@ -146,7 +146,8 @@ private static DatatypeValue evaluateAvg( } if (!hasMappings) { - throw new QueryTypeErrorException("AVG is undefined for empty group"); + // SPARQL 1.1 §18.5.1.4 defines AVG of the empty multiset as integer zero. + return context.values().createLiteral(BigInteger.ZERO); } if (!accumulator.hasValues()) { throw new QueryTypeErrorException("AVG is undefined when all values are unbound"); diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java index e137ae9d1..85f4cf209 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/WhereCompiler.java @@ -88,13 +88,12 @@ public Exp compile(GroupGraphPatternAst where) { if (cached != null) { return cached; } - GroupGraphPatternAst simplified = new AstSimplifier().simplify(where); if (inScopeVariables.isEmpty()) { inScopeVariables = Set.copyOf( new fr.inria.corese.core.next.query.impl.sparql.parser.semantic.support.VariableScopeAnalyzer() - .collectVisibleVariables(simplified)); + .collectVisibleVariables(where)); } - Exp compiled = compileGroup(simplified); + Exp compiled = compileGroup(where); compiledPatternCache.put(where, compiled); return compiled; } @@ -159,8 +158,10 @@ case MinusAst(GroupGraphPatternAst pattern) -> { body.add(minusExp); } case GroupGraphPatternAst nested -> { - Exp compiledNested = compile(nested); - Exp joined = Exp.create(Type.JOIN, body, compiledNested); + // Preserve the group boundary until filters have been scoped. In + // OPTIONAL { { P FILTER(...) } }, the filter belongs to the + // inner group, not to the OPTIONAL left-join condition. + Exp joined = Exp.create(Type.JOIN, body, compile(nested)); body = Exp.create(Type.AND); body.add(joined); } 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 index d510451b9..ecf8dff14 100644 --- 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 @@ -248,7 +248,8 @@ void implicitAggregateOnEmptyDatasetReturnsOneRow() { var row = result.next(); assertEquals("0", row.getValue("count").stringValue()); assertEquals("0", row.getValue("sum").stringValue()); - assertFalse(row.hasBinding("avg")); + assertEquals("0", row.getValue("avg").stringValue()); + assertEquals(XSDDatatype.INTEGER.getIRI(), ((Literal) row.getValue("avg")).getDatatype()); assertEquals("", row.getValue("gc").stringValue()); assertFalse(result.hasNext()); } diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineScopeExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineScopeExecutorTest.java index b5bc4a9b3..1056bc05f 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineScopeExecutorTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineScopeExecutorTest.java @@ -7,6 +7,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import fr.inria.corese.core.next.data.api.term.BNode; import fr.inria.corese.core.next.data.api.term.IRI; @@ -63,31 +65,22 @@ void blankNodeStillJoinsTriplesAcrossExistsFilter(String filter) { } /** - * dawg-optional-filter-005-simplified (W3C SPARQL 1.0 / SPARQL 1.1 simplified semantics): - * In {@code OPTIONAL { { BGP . FILTER(?outerVar = x) } }}, the inner {@code { }} is - * transparent — it does not create an evaluation-scope barrier for outer variables. - * The FILTER is postponed and evaluated at merge time (when both the left solution - * carrying {@code ?outerVar} and the right solution carrying the BGP bindings are - * available), giving the correct result: only the row where the FILTER passes - * receives the optional binding. - * - *

This is the "simplified" reading chosen by Corese, aligned with the - * SPARQL 1.1 algebra where an inner group graph pattern with no structural - * sub-patterns (OPTIONAL/MINUS/UNION) flattens into its parent.

+ * Regression for dawg-optional-filter-005-not-simplified: nested filters + * retain their group scope before algebra simplification (SPARQL 1.1 §18.2.2). */ @Test - @DisplayName("FILTER referencing outer variable inside nested OPTIONAL group — simplified semantics (issue #613)") - void filterReferencingOuterVarInNestedOptionalGroupIsSimplified() { + @DisplayName("FILTER referencing outer variable inside nested OPTIONAL group — preserved group scope (issue #613)") + void filterReferencingOuterVarInNestedOptionalGroupKeepsItsScope() { IRI dcTitle = iri("http://purl.org/dc/elements/1.1/title"); - IRI xPrice = iri("http://example.org/ns#price"); - IRI book1 = iri("http://example.org/books#book1"); - IRI book2 = iri("http://example.org/books#book2"); - IRI book3 = iri("http://example.org/books#book3"); + IRI xPrice = iri("http://example.org/ns#price"); + IRI book1 = iri("http://example.org/books#book1"); + IRI book2 = iri("http://example.org/books#book2"); + IRI book3 = iri("http://example.org/books#book3"); insert(book1, dcTitle, valueFactory.createLiteral("TITLE 1")); - insert(book1, xPrice, valueFactory.createLiteral(10)); + insert(book1, xPrice, valueFactory.createLiteral(10)); insert(book2, dcTitle, valueFactory.createLiteral("TITLE 2")); - insert(book2, xPrice, valueFactory.createLiteral(20)); + insert(book2, xPrice, valueFactory.createLiteral(20)); insert(book3, dcTitle, valueFactory.createLiteral("TITLE 3")); try (var result = executor.evaluateTuple(""" @@ -104,17 +97,77 @@ void filterReferencingOuterVarInNestedOptionalGroupIsSimplified() { """)) { var rows = result.stream().toList(); assertEquals(3, rows.size(), "All three titles must appear"); - // Only TITLE 2 passes the filter — it alone gets a price binding - long withPrice = rows.stream().filter(r -> r.getValue("price") != null).count(); - assertEquals(1, withPrice, "Exactly one row (TITLE 2) should have a price binding"); - var title2Row = rows.stream() - .filter(r -> "TITLE 2".equals(r.getValue("title").stringValue())) + assertEquals(Set.of("TITLE 1", "TITLE 2", "TITLE 3"), + rows.stream().map(r -> r.getValue("title").stringValue()) + .collect(Collectors.toSet())); + rows.forEach(row -> assertNull(row.getValue("price"), + "The nested filter cannot use ?title from the outer group")); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "{ ?s ?p ?o FILTER(?x = 1) } ?a ?b ?c", + "?a ?b ?c { ?s ?p ?o FILTER(?x = 1) }", + "{ { ?s ?p ?o FILTER(?x = 1) } ?a ?b ?c }" + }) + void nestedOptionalFilterKeepsItsScopeAcrossSiblingPatterns(String pattern) { + try (var result = executor.evaluateTuple(""" + SELECT * WHERE { + VALUES ?x { 1 } + OPTIONAL { %s } + } + """.formatted(pattern))) { + var rows = result.stream().toList(); + assertEquals(1, rows.size()); + assertEquals("1", rows.get(0).getValue("x").stringValue()); + for (String variable : List.of("s", "p", "o", "a", "b", "c")) { + assertNull(rows.get(0).getValue(variable), + "The nested filter cannot use the outer binding of ?x"); + } + } + } + + @ParameterizedTest + @ValueSource(strings = { + "{ ?s ?p ?o FILTER(?x = 1) }", + "{ { ?s ?p ?o FILTER(?x = 1) } }" + }) + void nestedOptionalWrappersPreserveFilterScope(String pattern) { + try (var result = executor.evaluateTuple(""" + SELECT * WHERE { + VALUES ?x { 1 2 } + OPTIONAL { %s } + } + """.formatted(pattern))) { + var rows = result.stream().toList(); + assertEquals(2, rows.size()); + for (var row : rows) { + assertNull(row.getValue("s")); + assertNull(row.getValue("p")); + assertNull(row.getValue("o")); + } + } + } + + @Test + void directOptionalFilterCanUseOuterBindings() { + try (var result = executor.evaluateTuple(""" + SELECT * WHERE { + VALUES ?x { 1 2 } + OPTIONAL { ?s ?p ?o FILTER(?x = 1) } + } + """)) { + var rows = result.stream().toList(); + assertEquals(2, rows.size()); + var matching = rows.stream().filter(r -> "1".equals(r.getValue("x").stringValue())) + .findFirst().orElseThrow(); + assertEquals(iri(ALICE), matching.getValue("s")); + assertEquals(iri(KNOWS), matching.getValue("p")); + assertEquals(iri(BOB), matching.getValue("o")); + var nonMatching = rows.stream().filter(r -> "2".equals(r.getValue("x").stringValue())) .findFirst().orElseThrow(); - assertEquals("20", title2Row.getValue("price").stringValue()); - rows.stream() - .filter(r -> !"TITLE 2".equals(r.getValue("title").stringValue())) - .forEach(r -> assertNull(r.getValue("price"), - "Titles other than TITLE 2 must not bind ?price")); + assertNull(nonMatching.getValue("s")); } }