diff --git a/jena-arq/src/main/java/org/apache/jena/sparql/algebra/optimize/TransformFilterDisjunction.java b/jena-arq/src/main/java/org/apache/jena/sparql/algebra/optimize/TransformFilterDisjunction.java
index 44d4614c63e..3eb703f6daf 100644
--- a/jena-arq/src/main/java/org/apache/jena/sparql/algebra/optimize/TransformFilterDisjunction.java
+++ b/jena-arq/src/main/java/org/apache/jena/sparql/algebra/optimize/TransformFilterDisjunction.java
@@ -27,13 +27,20 @@
import java.util.Set ;
import org.apache.jena.atlas.logging.Log ;
+import org.apache.jena.graph.Node ;
import org.apache.jena.sparql.algebra.Op ;
import org.apache.jena.sparql.algebra.TransformCopy ;
import org.apache.jena.sparql.algebra.op.OpDisjunction ;
import org.apache.jena.sparql.algebra.op.OpFilter ;
+import org.apache.jena.sparql.core.Var ;
+import org.apache.jena.sparql.expr.E_Equals ;
import org.apache.jena.sparql.expr.E_LogicalOr ;
+import org.apache.jena.sparql.expr.E_SameTerm ;
import org.apache.jena.sparql.expr.Expr ;
+import org.apache.jena.sparql.expr.ExprEvalException ;
+import org.apache.jena.sparql.expr.ExprFunction2 ;
import org.apache.jena.sparql.expr.ExprList ;
+import org.apache.jena.sparql.expr.NodeValue ;
/**
* Filter disjunction. This covers the case of
@@ -42,6 +49,14 @@
* where either or both of {@code expr1} and {@code expr2} are equalities that help
* ground the pattern. This includes {@code ?x IN (....)} so this optimization can a
* significant improvement.
+ *
+ * The rewrite evaluates the pattern once per disjunct, so it is only sound when at
+ * most one disjunct can be true of any one solution; otherwise a solution satisfying
+ * several disjuncts is returned once per satisfied disjunct where the filter returns
+ * it once. Repeated disjuncts are dropped first, and the rewrite is then applied only
+ * when every disjunct tests the same variable against a constant ({@code =} or
+ * {@code sameTerm}) and no one term can satisfy two of those tests. Any other
+ * disjunction is left as a filter.
*/
public class TransformFilterDisjunction extends TransformCopy {
@@ -66,9 +81,6 @@ public Op transform(OpFilter opFilter, final Op subOp) {
ExprList exprList2 = new ExprList();
Op newOp = subOp;
- // remember what's been seen so that FILTER(?x = || ?x = ) does not
- // result in two transforms.
- Set doneSoFar = new HashSet<>();
for ( Expr expr : exprList ) {
if ( !isDisjunction(expr) ) {
@@ -77,26 +89,23 @@ public Op transform(OpFilter opFilter, final Op subOp) {
continue;
}
-// // Relies on expression equality.
-// if ( doneSoFar.contains(expr) )
-// continue ;
-// // Must be canonical: ?x = is the same as = ?x
-// doneSoFar.add(expr) ;
-
Op op2 = expandDisjunction(expr, newOp);
- if ( op2 != null )
- newOp = op2;
+ if ( op2 == null ) {
+ // A disjunction this transform can not rewrite soundly.
+ // Leave it as a filter expression.
+ exprList2.add(expr);
+ continue;
+ }
+ newOp = op2;
}
+ if ( newOp == subOp )
+ // No disjunction was expanded.
+ return super.transform(opFilter, subOp);
+
if ( exprList2.isEmpty() )
return newOp;
- // There should have been at least on disjunction.
- if ( newOp == subOp ) {
- Log.warn(this, "FilterDisjunction assumption failure: didn't find a disjunction after all");
- return super.transform(opFilter, subOp);
- }
-
// Put the non-disjunctions outside the disjunction and the pattern rewrite.
Op opOther = OpFilter.filterBy(exprList2, newOp);
if ( opOther instanceof OpFilter ) {
@@ -113,9 +122,27 @@ private boolean isDisjunction(Expr expr) {
return (expr instanceof E_LogicalOr);
}
+ /**
+ * Expand a disjunction into a union of the pattern grounded per disjunct, or null
+ * when that is not possible or not sound.
+ */
public static Op expandDisjunction(Expr expr, Op subOp) {
List exprList = explodeDisjunction(new ArrayList(), expr);
+ // (A || A) is A: drop repeated disjuncts rather than build identical branches.
+ // Generated queries really do contain the same disjunct twice - LDBC SPB writes
+ // FILTER(?pf = :c || ?pf = :c) - and a single disjunct then grounds the pattern.
+ List distinct = new ArrayList<>(exprList.size());
+ Set seen = new HashSet<>();
+ for ( Expr e : exprList ) {
+ if ( seen.add(dedupKey(e)) )
+ distinct.add(e);
+ }
+ exprList = distinct;
+
+ if ( !isSafeDisjunction(exprList) )
+ return null;
+
// All disjunctions - some can be done efficiently via assignments,
// some can not (value tests).
List exprList2 = null;
@@ -148,6 +175,138 @@ public static Op expandDisjunction(Expr expr, Op subOp) {
return op;
}
+ /**
+ * Is at most one disjunct true of any one solution? Each branch of the expansion
+ * re-evaluates the pattern, so a solution that satisfies {@code k} disjuncts comes
+ * back {@code k} times where the filter returns it once. For example
+ *
+ * FILTER(?x = :c || ?x != :d)
+ * must not be expanded: a solution with {@code ?x = :c} satisfies both disjuncts.
+ *
+ * The safe case is: every disjunct tests one and the same variable against a
+ * constant, and the constants are pairwise known not to be satisfied by the same
+ * term. Constants whose comparison is indeterminate (an unknown datatype, a
+ * timezone-less date) are treated as possibly equal.
+ */
+ private static boolean isSafeDisjunction(List exprList) {
+ Var var = null;
+ List constants = new ArrayList<>(exprList.size());
+ for ( Expr e : exprList ) {
+ NodeValue constant = constantTestedAgainst(e, var);
+ if ( constant == null )
+ return false;
+ if ( var == null )
+ var = singleVariable(e);
+ constants.add(constant);
+ }
+
+ // This runs once per query at optimize time, not per solution, but ?x IN (...)
+ // lists can be long in generated queries so avoid the pairwise comparison where
+ // there is a cheaper test. For IRIs and blank nodes NVCompare.sameValueAs is
+ // sameTerm, so they have a canonical representative and distinctness is set
+ // membership. Literals have no such representative - numeric comparison promotes
+ // to the wider of the two types, which makes value equality a property of the
+ // pair and not transitive - so any pair involving one stays pairwise.
+ List literals = new ArrayList<>();
+ List nonLiterals = new ArrayList<>();
+ Set distinctTerms = new HashSet<>();
+ for ( int i = 0 ; i < constants.size() ; i++ ) {
+ NodeValue nv = constants.get(i);
+ if ( nv.isIRI() || nv.isBlank() ) {
+ if ( !distinctTerms.add(nv.asNode()) )
+ // The same term twice: both disjuncts are true of that term.
+ return false;
+ nonLiterals.add(i);
+ } else
+ literals.add(i);
+ }
+
+ for ( int a = 0 ; a < literals.size() ; a++ ) {
+ int i = literals.get(a);
+ for ( int b = a + 1 ; b < literals.size() ; b++ ) {
+ if ( !provablyExclusive(exprList, constants, i, literals.get(b)) )
+ return false;
+ }
+ for ( int j : nonLiterals ) {
+ if ( !provablyExclusive(exprList, constants, i, j) )
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Can no one term satisfy both disjuncts? {@code sameTerm} matches by term, so two
+ * {@code sameTerm} tests exclude each other exactly when the terms differ; value
+ * distinctness is not enough because {@code NaN} is not value-equal to itself, yet
+ * every term equal to {@code NaN} satisfies both. Where at least one disjunct is
+ * {@code =}, a solution satisfying both makes the constants value-equal, so proving
+ * the values different proves the disjuncts exclusive.
+ */
+ private static boolean provablyExclusive(List exprList, List constants, int i, int j) {
+ return provablyExclusive(exprList.get(i), constants.get(i), exprList.get(j), constants.get(j));
+ }
+
+ /*package*/ static boolean provablyExclusive(Expr e1, NodeValue nv1, Expr e2, NodeValue nv2) {
+ if ( e1 instanceof E_SameTerm && e2 instanceof E_SameTerm )
+ return !nv1.asNode().equals(nv2.asNode());
+ return provablyDistinctValues(nv1, nv2);
+ }
+
+ /**
+ * A key equating disjuncts that are the same test. {@code =} and {@code sameTerm}
+ * are symmetric, so a variable/constant test keys on the operator, the variable and
+ * the constant and not on the argument order - FILTER(sameTerm(?x, :c) ||
+ * sameTerm(:c, ?x)) is one test written twice. Any other shape keys on itself.
+ */
+ private static Object dedupKey(Expr e) {
+ NodeValue constant = constantTestedAgainst(e, null);
+ if ( constant == null )
+ return e;
+ return List.of(e.getClass(), singleVariable(e), constant.asNode());
+ }
+
+ /**
+ * The constant of a {@code variable = constant} or {@code sameTerm(variable, constant)}
+ * disjunct (either argument order), where the variable is {@code var} - or any
+ * variable when {@code var} is null. Null when the disjunct has another shape.
+ */
+ private static NodeValue constantTestedAgainst(Expr e, Var var) {
+ if ( !(e instanceof E_Equals) && !(e instanceof E_SameTerm) )
+ return null;
+ ExprFunction2 test = (ExprFunction2)e;
+ Expr left = test.getArg1();
+ Expr right = test.getArg2();
+ Expr varExpr = null;
+ Expr constExpr = null;
+ if ( left.isVariable() && right.isConstant() ) {
+ varExpr = left;
+ constExpr = right;
+ } else if ( right.isVariable() && left.isConstant() ) {
+ varExpr = right;
+ constExpr = left;
+ } else
+ return null;
+ if ( var != null && !var.equals(varExpr.asVar()) )
+ return null;
+ return constExpr.getConstant();
+ }
+
+ /** The variable of a disjunct {@link #constantTestedAgainst} accepted. */
+ private static Var singleVariable(Expr e) {
+ ExprFunction2 test = (ExprFunction2)e;
+ return test.getArg1().isVariable() ? test.getArg1().asVar() : test.getArg2().asVar();
+ }
+
+ /*package*/ static boolean provablyDistinctValues(NodeValue nv1, NodeValue nv2) {
+ try {
+ return NodeValue.notSameValueAs(nv1, nv2);
+ } catch (ExprEvalException ex) {
+ // Indeterminate comparison: can not prove the disjuncts mutually exclusive.
+ return false;
+ }
+ }
+
/** Explode an expr into a list of disjunctions */
private static List explodeDisjunction(List exprList, Expr expr) {
if ( !(expr instanceof E_LogicalOr) ) {
diff --git a/jena-arq/src/test/java/org/apache/jena/sparql/algebra/optimize/TestTransformFilters.java b/jena-arq/src/test/java/org/apache/jena/sparql/algebra/optimize/TestTransformFilters.java
index 53163864d4d..db6f183446b 100644
--- a/jena-arq/src/test/java/org/apache/jena/sparql/algebra/optimize/TestTransformFilters.java
+++ b/jena-arq/src/test/java/org/apache/jena/sparql/algebra/optimize/TestTransformFilters.java
@@ -23,14 +23,33 @@
import static org.apache.jena.sparql.algebra.optimize.TransformTests.check;
import static org.apache.jena.sparql.algebra.optimize.TransformTests.testOp;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
+import org.apache.jena.atlas.iterator.Iter;
import org.apache.jena.atlas.lib.StrUtils;
+import org.apache.jena.datatypes.xsd.XSDDatatype;
+import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.NodeFactory;
+import org.apache.jena.query.Query;
+import org.apache.jena.query.QueryExecution;
+import org.apache.jena.query.QueryExecutionFactory;
+import org.apache.jena.query.QueryFactory;
+import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.sparql.algebra.Op;
import org.apache.jena.sparql.algebra.Transform;
import org.apache.jena.sparql.algebra.TransformCopy;
import org.apache.jena.sparql.algebra.op.OpTable;
+import org.apache.jena.sparql.core.Var;
+import org.apache.jena.sparql.expr.E_Equals;
+import org.apache.jena.sparql.expr.E_SameTerm;
+import org.apache.jena.sparql.expr.Expr;
+import org.apache.jena.sparql.expr.ExprVar;
+import org.apache.jena.sparql.expr.NodeValue;
+import org.apache.jena.sparql.sse.SSE;
/** Tests of transforms related to filters */
public class TestTransformFilters
@@ -272,23 +291,18 @@ public Op transform(OpTable opTable) {
")");
}
+ // A solution with ?x = satisfies both disjuncts, so expanding would return
+ // it from both branches where the filter returns it once. Not transformed.
@Test public void disjunction02() {
testOp("(filter (|| (= ?x ) (!= ?x )) (bgp ( ?s ?p ?x)) )",
t_disjunction,
- "(disjunction ",
- "(assign ((?x )) (bgp ( ?s ?p )))",
- "(filter (!= ?x ) (bgp ( ?s ?p ?x)))",
- ")");
+ (String[])null);
}
@Test public void disjunction03() {
testOp("(filter (|| (!= ?x ) (= ?x )) (bgp ( ?s ?p ?x)) )",
t_disjunction,
- // Note - reordering of disjunction terms.
- "(disjunction ",
- "(assign ((?x )) (bgp ( ?s ?p )))",
- "(filter (!= ?x ) (bgp ( ?s ?p ?x)))",
- ")");
+ (String[])null);
}
@Test public void disjunction04() {
@@ -300,33 +314,187 @@ public Op transform(OpTable opTable) {
@Test public void disjunction05() {
testOp("(filter (exprlist (|| (= ?x ) (!= ?x ))) (bgp ( ?s ?p ?x)) )",
t_disjunction,
- " (disjunction",
- " (assign ((?x )) (bgp ( ?s ?p )))",
- " (filter (!= ?x ) (bgp ( ?s ?p ?x)))",
- ")"
- );
+ (String[])null);
}
@Test public void disjunction06() {
testOp("(filter (exprlist (lang ?x) (|| (= ?x ) (!= ?x ))) (bgp ( ?s ?p ?x)) )",
t_disjunction,
- "(filter (lang ?x)",
- " (disjunction",
- " (assign ((?x )) (bgp ( ?s ?p )))",
- " (filter (!= ?x ) (bgp ( ?s ?p ?x)))",
- "))"
- );
+ (String[])null);
}
@Test public void disjunction07() {
testOp("(filter (exprlist (|| (= ?x ) (!= ?x )) (lang ?x) ) (bgp ( ?s ?p ?x)) )",
t_disjunction,
- "(filter (lang ?x)",
- " (disjunction",
- " (assign ((?x )) (bgp ( ?s ?p )))",
- " (filter (!= ?x ) (bgp ( ?s ?p ?x)))",
- "))"
- );
+ (String[])null);
+ }
+
+ // (A || A) is A: the repeated disjunct is dropped — two identical branches would
+ // return every solution twice — and the single equality then grounds the pattern.
+ @Test public void disjunction08() {
+ testOp("(filter (|| (= ?x ) (= ?x )) (bgp ( ?s ?p ?x)) )",
+ t_disjunction,
+ "(assign ((?x )) (bgp ( ?s ?p )))");
+ }
+
+ // The same, for a disjunct shape the transform cannot ground: deduplicated to a
+ // single disjunct, there is nothing to expand and the filter is left alone.
+ @Test public void disjunction08a() {
+ testOp("(filter (|| (!= ?x ) (!= ?x )) (bgp ( ?s ?p ?x)) )",
+ t_disjunction,
+ (String[])null);
+ }
+
+ // Different terms, same value: a solution with ?x = 1 satisfies both disjuncts.
+ @Test public void disjunction09() {
+ testOp("(filter (|| (= ?x 1) (= ?x \"01\"^^)) (bgp ( ?s ?p ?x)) )",
+ t_disjunction,
+ (String[])null);
+ }
+
+ // Different variables: a solution can satisfy both disjuncts.
+ @Test public void disjunction10() {
+ testOp("(filter (|| (= ?x ) (= ?y )) (bgp ( ?s ?p ?x) (?s ?q ?y)) )",
+ t_disjunction,
+ (String[])null);
+ }
+
+ // Simple literals are pairwise distinct values, so the expansion is sound.
+ @Test public void disjunction11() {
+ testOp("(filter (|| (= ?x \"a\") (= ?x \"b\")) (bgp ( ?s ?p ?x)) )",
+ t_disjunction,
+ "(disjunction ",
+ "(assign ((?x \"a\")) (bgp ( ?s ?p \"a\")))",
+ "(assign ((?x \"b\")) (bgp ( ?s ?p \"b\")))",
+ ")");
+ }
+
+ // An IRI and a number are distinct values, so the ?x IN ( 2) shape keeps its
+ // expansion; the numeric equality is not substitutable and stays a filter branch.
+ @Test public void disjunction12() {
+ testOp("(filter (|| (= ?x ) (= ?x 2)) (bgp ( ?s ?p ?x)) )",
+ t_disjunction,
+ "(disjunction ",
+ "(assign ((?x )) (bgp ( ?s ?p )))",
+ "(filter (= ?x 2) (bgp ( ?s ?p ?x)))",
+ ")");
+ }
+
+ // The expansion must not change the number of results: each solution once,
+ // however many disjuncts it satisfies. Executed, not matched on plan shape,
+ // with the default optimizer.
+ @Test public void disjunctionMultiplicity01() {
+ checkDisjunctionRowCount("FILTER(?x = || ?x = )", 1);
+ }
+
+ // Only s1 qualifies (x=a passes both disjuncts; x=b passes neither) — and it must
+ // come back once. The unsound expansion returned it from both branches.
+ @Test public void disjunctionMultiplicity02() {
+ checkDisjunctionRowCount("FILTER(?x = || ?x != )", 1);
+ }
+
+ @Test public void disjunctionMultiplicity03() {
+ checkDisjunctionRowCount("FILTER(?x = || ?x = )", 2);
+ }
+
+ private static void checkDisjunctionRowCount(String filter, int expected) {
+ Graph graph = SSE.parseGraph(StrUtils.strjoinNL
+ ("(graph"
+ ," (triple )"
+ ," (triple )"
+ ,")"));
+ String queryString = "SELECT * { ?s ?x " + filter + " }";
+ Query query = QueryFactory.create(queryString);
+ try ( QueryExecution qExec = QueryExecutionFactory.create(query, ModelFactory.createModelForGraph(graph)) ) {
+ long count = Iter.count(qExec.execSelect());
+ assertEquals(expected, count, ()->"Row count differs from filter semantics: "+filter);
+ }
+ }
+
+ // GH-4160: the exclusivity test must not judge sameTerm disjuncts by value
+ // distinctness. NaN is not value-equal to itself, so the two symmetric writings of
+ // one sameTerm test were taken to be mutually exclusive and the row came back twice.
+ @Test public void disjunctionMultiplicity04() {
+ checkRowCount(numericGraph(),
+ "FILTER( sameTerm(?x, \"NaN\"^^xsd:double) || sameTerm(\"NaN\"^^xsd:double, ?x) )",
+ 1);
+ }
+
+ // The mixed IRI/literal case still expands - the IRI branch grounds the pattern and
+ // the double is left as a filter - and returns each solution once.
+ @Test public void disjunctionMultiplicity05() {
+ checkRowCount(numericGraph(),
+ "FILTER( ?x = \"1.5\"^^xsd:double || ?x = )",
+ 1);
+ }
+
+ private static Graph numericGraph() {
+ return SSE.parseGraph(StrUtils.strjoinNL
+ ("(graph"
+ ," (triple \"NaN\"^^xsd:double)"
+ ," (triple \"1.5\"^^xsd:double)"
+ ,")"));
+ }
+
+ private static void checkRowCount(Graph graph, String filter, int expected) {
+ String queryString = "PREFIX xsd: \n"
+ + "SELECT * { ?s ?x " + filter + " }";
+ Query query = QueryFactory.create(queryString);
+ try ( QueryExecution qExec = QueryExecutionFactory.create(query, ModelFactory.createModelForGraph(graph)) ) {
+ long count = Iter.count(qExec.execSelect());
+ assertEquals(expected, count, ()->"Row count differs from filter semantics: "+filter);
+ }
+ }
+
+ private static NodeValue nvDouble(String lex) { return NodeValue.makeNode(NodeFactory.createLiteralDT(lex, XSDDatatype.XSDdouble)); }
+ private static NodeValue nvInteger(String lex) { return NodeValue.makeNode(NodeFactory.createLiteralDT(lex, XSDDatatype.XSDinteger)); }
+ private static NodeValue nvDateTime(String lex) { return NodeValue.makeNode(NodeFactory.createLiteralDT(lex, XSDDatatype.XSDdateTime)); }
+ private static NodeValue nvIRI(String uri) { return NodeValue.makeNode(NodeFactory.createURI(uri)); }
+
+ // provablyDistinctValues is value distinctness. NaN is not value-equal to itself -
+ // the reason sameTerm disjuncts may not be judged by it.
+ @Test public void provablyDistinctValuesNaN() {
+ assertTrue(TransformFilterDisjunction.provablyDistinctValues(nvDouble("NaN"), nvDouble("NaN")));
+ }
+
+ // Value-equal constants with different terms: not distinct, so not exclusive.
+ @Test public void provablyDistinctValuesSameValue() {
+ assertFalse(TransformFilterDisjunction.provablyDistinctValues(nvInteger("1"), nvInteger("01")));
+ assertFalse(TransformFilterDisjunction.provablyDistinctValues(nvInteger("1"), nvDouble("1.0")));
+ }
+
+ @Test public void provablyDistinctValuesIRIs() {
+ assertTrue(TransformFilterDisjunction.provablyDistinctValues(nvIRI("http://example/a"), nvIRI("http://example/b")));
+ assertFalse(TransformFilterDisjunction.provablyDistinctValues(nvIRI("http://example/a"), nvIRI("http://example/a")));
+ }
+
+ // A timezone-less dateTime against one with a timezone is indeterminate, so the
+ // constants can not be proved different.
+ @Test public void provablyDistinctValuesIndeterminate() {
+ assertFalse(TransformFilterDisjunction.provablyDistinctValues(nvDateTime("2000-01-01T00:00:00"),
+ nvDateTime("2000-01-01T00:00:00Z")));
+ }
+
+ // sameTerm matches by term: identical terms are never exclusive, and differing
+ // terms always are, whatever the value comparison says.
+ @Test public void provablyExclusiveSameTerm() {
+ Expr vx = new ExprVar(Var.alloc("x"));
+ NodeValue nan = nvDouble("NaN");
+ assertFalse(TransformFilterDisjunction.provablyExclusive(new E_SameTerm(vx, nan), nan,
+ new E_SameTerm(nan, vx), nan));
+ NodeValue i1 = nvInteger("1"), i01 = nvInteger("01");
+ assertTrue(TransformFilterDisjunction.provablyExclusive(new E_SameTerm(vx, i1), i1,
+ new E_SameTerm(vx, i01), i01));
+ }
+
+ // Where a disjunct is "=", value distinctness is the right test.
+ @Test public void provablyExclusiveEquals() {
+ Expr vx = new ExprVar(Var.alloc("x"));
+ NodeValue i1 = nvInteger("1"), i01 = nvInteger("01");
+ assertFalse(TransformFilterDisjunction.provablyExclusive(new E_Equals(vx, i1), i1,
+ new E_Equals(vx, i01), i01));
+ assertTrue(TransformFilterDisjunction.provablyExclusive(new E_Equals(vx, nvIRI("http://example/a")), nvIRI("http://example/a"),
+ new E_SameTerm(vx, nvIRI("http://example/b")), nvIRI("http://example/b")));
}
@Test public void oneOf1() {