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 548f323e9..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 @@ -84,15 +84,15 @@ Set inScopeVariables() { */ public Exp compile(GroupGraphPatternAst where) { Objects.requireNonNull(where, "where"); + Exp cached = compiledPatternCache.get(where); + if (cached != null) { + return cached; + } 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); compiledPatternCache.put(where, compiled); return compiled; @@ -158,6 +158,9 @@ case MinusAst(GroupGraphPatternAst pattern) -> { body.add(minusExp); } case GroupGraphPatternAst nested -> { + // 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 02b88c1df..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 @@ -3,11 +3,16 @@ 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 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; +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 +64,113 @@ void blankNodeStillJoinsTriplesAcrossExistsFilter(String filter) { } } + /** + * 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 — 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"); + + 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"); + 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(); + assertNull(nonMatching.getValue("s")); + } + } + @Test void constructLabelCreatesAFreshNodeInsteadOfReusingTheWhereBinding() { try (var result = executor.evaluateGraph("""