diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java index ee47050bd..2424e4107 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java @@ -7,12 +7,15 @@ */ package de.ii.xtraplatform.features.sql.app; +import de.ii.xtraplatform.cql.domain.And; import de.ii.xtraplatform.cql.domain.BinaryScalarOperation; import de.ii.xtraplatform.cql.domain.Cql2Expression; import de.ii.xtraplatform.cql.domain.CqlNode; import de.ii.xtraplatform.cql.domain.CqlVisitorCopy; import de.ii.xtraplatform.cql.domain.ImmutableInResultSet; import de.ii.xtraplatform.cql.domain.InResultSet; +import de.ii.xtraplatform.cql.domain.Not; +import de.ii.xtraplatform.cql.domain.Or; import de.ii.xtraplatform.features.domain.ImmutableMultiFeatureQuery; import de.ii.xtraplatform.features.domain.ImmutableSubQuery; import de.ii.xtraplatform.features.domain.MultiFeatureQuery; @@ -74,6 +77,7 @@ public MultiFeatureQuery materialize(MultiFeatureQuery query) { } Map> materialized = new HashMap<>(); + int shortCircuited = 0; // materialize level by level: within a level the producers are independent and run concurrently // (bounded by the connection pool). SQL is built single-threaded between levels, so the filter // encoder is never invoked concurrently and the materialized map is only mutated on this @@ -83,6 +87,15 @@ public MultiFeatureQuery materialize(MultiFeatureQuery query) { Map valueTypes = new HashMap<>(); for (String name : level) { InResultSet node = sets.get(name); + // if a dependency has already materialized to no members in a position that forces this + // producer's filter false, the producer can only be empty too — record the empty set and + // skip its query. Consumers encode IN () for it either way, so the output is unchanged. + if (node.getProducerFilter().isPresent() + && isProvablyEmpty(node.getProducerFilter().get(), materialized)) { + materialized.put(name, List.of()); + shortCircuited++; + continue; + } InResultSet prepared = node.getProducerFilter().isPresent() ? new ImmutableInResultSet.Builder() @@ -121,6 +134,13 @@ public MultiFeatureQuery materialize(MultiFeatureQuery query) { } } + if (shortCircuited > 0 && LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Short-circuited {} of {} result-set producer(s) with an empty dependency.", + shortCircuited, + sets.size()); + } + List rewritten = query.getQueries().stream() .map( @@ -213,6 +233,69 @@ private static Cql2Expression applyMaterialized( return (Cql2Expression) expression.accept(new ApplyMaterialized(materialized)); } + /** + * True if the filter can only ever be false given the result sets materialized so far, i.e. it + * references a set that materialized to no members in a boolean position that forces the whole + * filter false. Conservative: any leaf whose value is not pinned by an empty set is treated as + * indeterminate, so a producer is skipped only when boolean algebra guarantees an empty result. + */ + private static boolean isProvablyEmpty( + Cql2Expression filter, Map> materialized) { + return truth(filter, materialized) == Truth.FALSE; + } + + private enum Truth { + TRUE, + FALSE, + UNKNOWN + } + + private static Truth truth(CqlNode node, Map> materialized) { + if (node instanceof InResultSet) { + List values = materialized.get(((InResultSet) node).getSetName()); + // a materialized-empty set makes the IN / A_OVERLAPS predicate always false; a non-empty or + // not-yet-materialized (oversized) set is indeterminate at the query level + return values != null && values.isEmpty() ? Truth.FALSE : Truth.UNKNOWN; + } + if (node instanceof And) { + Truth result = Truth.TRUE; + for (Cql2Expression child : ((And) node).getArgs()) { + Truth childTruth = truth(child, materialized); + if (childTruth == Truth.FALSE) { + return Truth.FALSE; + } + if (childTruth == Truth.UNKNOWN) { + result = Truth.UNKNOWN; + } + } + return result; + } + if (node instanceof Or) { + Truth result = Truth.FALSE; + for (Cql2Expression child : ((Or) node).getArgs()) { + Truth childTruth = truth(child, materialized); + if (childTruth == Truth.TRUE) { + return Truth.TRUE; + } + if (childTruth == Truth.UNKNOWN) { + result = Truth.UNKNOWN; + } + } + return result; + } + if (node instanceof Not) { + Truth child = truth(((Not) node).getArgs().get(0), materialized); + if (child == Truth.TRUE) { + return Truth.FALSE; + } + if (child == Truth.FALSE) { + return Truth.TRUE; + } + return Truth.UNKNOWN; + } + return Truth.UNKNOWN; + } + private static Object coerce(Object value, SchemaBase.Type type) { if (!(value instanceof String)) { return value; diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/ResultSetMaterializerSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/ResultSetMaterializerSpec.groovy index 701b6cbce..567e531a0 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/ResultSetMaterializerSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/ResultSetMaterializerSpec.groovy @@ -12,6 +12,7 @@ import de.ii.xtraplatform.cql.domain.Cql2Expression import de.ii.xtraplatform.cql.domain.Eq import de.ii.xtraplatform.cql.domain.ImmutableInResultSet import de.ii.xtraplatform.cql.domain.InResultSet +import de.ii.xtraplatform.cql.domain.Or import de.ii.xtraplatform.cql.domain.Property import de.ii.xtraplatform.cql.domain.ScalarLiteral import de.ii.xtraplatform.crs.domain.OgcCrs @@ -116,6 +117,63 @@ class ResultSetMaterializerSpec extends Specification { node.getMaterializedValues().isEmpty() } + def 'a producer whose dependency materialized empty is short-circuited and not run'() { + given: + def s1 = resolved("s1", "simple", Eq.of(Property.of("id"), ScalarLiteral.of("foo"))) + def s2 = resolved("s2", "simple", s1) + def sqlClient = Mock(SqlClient) + def materializer = new ResultSetMaterializer({ -> sqlClient } as Supplier, filterEncoder, 100000) + + when: + def result = materializer.materialize(query(s2)) + + then: + // only the s1 producer runs; s2 is skipped because its dependency s1 is empty + 1 * sqlClient.run(_, _) >> CompletableFuture.completedFuture([]) + def node = (InResultSet) result.getQueries().get(0).getFilters().get(0) + node.getMaterializedValues().get() == [] + } + + def 'a producer whose dependency is non-empty is still run'() { + given: + def s1 = resolved("s1", "simple", Eq.of(Property.of("id"), ScalarLiteral.of("foo"))) + def s2 = resolved("s2", "simple", s1) + def sqlClient = Mock(SqlClient) + def materializer = new ResultSetMaterializer({ -> sqlClient } as Supplier, filterEncoder, 100000) + + when: + def result = materializer.materialize(query(s2)) + + then: + 2 * sqlClient.run(_, _) >>> [ + CompletableFuture.completedFuture([row("a")]), + CompletableFuture.completedFuture([row("b"), row("c")]) + ] + def node = (InResultSet) result.getQueries().get(0).getFilters().get(0) + node.getMaterializedValues().get() == ["b", "c"] + } + + def 'an empty dependency in an OR with another term does not short-circuit'() { + given: + def s1 = resolved("s1", "simple", Eq.of(Property.of("id"), ScalarLiteral.of("foo"))) + def s2 = resolved("s2", "simple", + Or.of([s1, Eq.of(Property.of("id"), ScalarLiteral.of("bar"))] as List)) + def sqlClient = Mock(SqlClient) + def materializer = new ResultSetMaterializer({ -> sqlClient } as Supplier, filterEncoder, 100000) + + when: + def result = materializer.materialize(query(s2)) + + then: + // s1 is empty but the OR's other term is indeterminate, so s2 must still run + 2 * sqlClient.run(_, _) >>> [ + CompletableFuture.completedFuture([]), + CompletableFuture.completedFuture([row("b")]) + ] + def node = (InResultSet) result.getQueries().get(0).getFilters().get(0) + node.getMaterializedValues().get() == ["b"] + } + def 'a query without result sets is returned unchanged'() { given: def filter = Eq.of(Property.of("id"), ScalarLiteral.of("foo"))