diff --git a/docs/changelog/127355.yaml b/docs/changelog/127355.yaml
new file mode 100644
index 0000000000000..28ead562ed8c5
--- /dev/null
+++ b/docs/changelog/127355.yaml
@@ -0,0 +1,5 @@
+pr: 127355
+summary: '`text ==` and `text !=` pushdown'
+area: ES|QL
+type: enhancement
+issues: []
diff --git a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushQueriesIT.java b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushQueriesIT.java
index 272f884addc4c..518b097c6604f 100644
--- a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushQueriesIT.java
+++ b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/PushQueriesIT.java
@@ -7,10 +7,12 @@
package org.elasticsearch.xpack.esql.qa.single_node;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters;
import org.elasticsearch.client.Request;
import org.elasticsearch.client.Response;
+import org.elasticsearch.client.ResponseException;
import org.elasticsearch.test.ListMatcher;
import org.elasticsearch.test.MapMatcher;
import org.elasticsearch.test.TestClustersThreadFilter;
@@ -27,6 +29,7 @@
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
+import java.util.stream.Stream;
import static org.elasticsearch.test.ListMatcher.matchesList;
import static org.elasticsearch.test.MapMatcher.assertMap;
@@ -48,50 +51,161 @@ public class PushQueriesIT extends ESRestTestCase {
@ClassRule
public static ElasticsearchCluster cluster = Clusters.testCluster();
- public void testPushEqualityOnDefaults() throws IOException {
+ @ParametersFactory(argumentFormatting = "%1s")
+ public static List args() {
+ return Stream.of("auto", "text", "match_only_text", "semantic_text").map(s -> new Object[] { s }).toList();
+ }
+
+ private final String type;
+
+ public PushQueriesIT(String type) {
+ this.type = type;
+ }
+
+ public void testEquality() throws IOException {
String value = "v".repeat(between(0, 256));
- testPushQuery(value, """
+ String esqlQuery = """
FROM test
| WHERE test == "%value"
- """, "*:*", true, true);
+ """;
+ String luceneQuery = switch (type) {
+ case "text", "auto" -> "#test.keyword:%value -_ignored:test.keyword";
+ case "match_only_text" -> "*:*";
+ case "semantic_text" -> "FieldExistsQuery [field=_primary_term]";
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ boolean filterInCompute = switch (type) {
+ case "text", "auto" -> false;
+ case "match_only_text", "semantic_text" -> true;
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ testPushQuery(value, esqlQuery, luceneQuery, filterInCompute, true);
}
- public void testPushEqualityOnDefaultsTooBigToPush() throws IOException {
+ public void testEqualityTooBigToPush() throws IOException {
String value = "a".repeat(between(257, 1000));
- testPushQuery(value, """
+ String esqlQuery = """
FROM test
| WHERE test == "%value"
- """, "*:*", true, true);
+ """;
+ String luceneQuery = switch (type) {
+ case "text", "auto", "match_only_text" -> "*:*";
+ case "semantic_text" -> "FieldExistsQuery [field=_primary_term]";
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ testPushQuery(value, esqlQuery, luceneQuery, true, true);
+ }
+
+ /**
+ * Turns into an {@code IN} which isn't currently pushed.
+ */
+ public void testEqualityOrTooBig() throws IOException {
+ String value = "v".repeat(between(0, 256));
+ String tooBig = "a".repeat(between(257, 1000));
+ String esqlQuery = """
+ FROM test
+ | WHERE test == "%value" OR test == "%tooBig"
+ """.replace("%tooBig", tooBig);
+ String luceneQuery = switch (type) {
+ case "text", "auto", "match_only_text" -> "*:*";
+ case "semantic_text" -> "FieldExistsQuery [field=_primary_term]";
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ testPushQuery(value, esqlQuery, luceneQuery, true, true);
+ }
+
+ public void testEqualityOrOther() throws IOException {
+ String value = "v".repeat(between(0, 256));
+ String esqlQuery = """
+ FROM test
+ | WHERE test == "%value" OR foo == 2
+ """;
+ String luceneQuery = switch (type) {
+ case "text", "auto" -> "(#test.keyword:%value -_ignored:test.keyword) foo:[2 TO 2]";
+ case "match_only_text" -> "*:*";
+ case "semantic_text" -> "FieldExistsQuery [field=_primary_term]";
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ boolean filterInCompute = switch (type) {
+ case "text", "auto" -> false;
+ case "match_only_text", "semantic_text" -> true;
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ testPushQuery(value, esqlQuery, luceneQuery, filterInCompute, true);
}
- public void testPushInequalityOnDefaults() throws IOException {
+ public void testEqualityAndOther() throws IOException {
String value = "v".repeat(between(0, 256));
- testPushQuery(value, """
+ String esqlQuery = """
+ FROM test
+ | WHERE test == "%value" AND foo == 1
+ """;
+ String luceneQuery = switch (type) {
+ case "text", "auto" -> "#test.keyword:%value -_ignored:test.keyword #foo:[1 TO 1]";
+ case "match_only_text" -> "foo:[1 TO 1]";
+ case "semantic_text" ->
+ /*
+ * single_value_match is here because there are extra documents hiding in the index
+ * that don't have the `foo` field.
+ */
+ "#foo:[1 TO 1] #single_value_match(foo)";
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ boolean filterInCompute = switch (type) {
+ case "text", "auto" -> false;
+ case "match_only_text", "semantic_text" -> true;
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ testPushQuery(value, esqlQuery, luceneQuery, filterInCompute, true);
+ }
+
+ public void testInequality() throws IOException {
+ String value = "v".repeat(between(0, 256));
+ String esqlQuery = """
FROM test
| WHERE test != "%different_value"
- """, "*:*", true, true);
+ """;
+ String luceneQuery = switch (type) {
+ case "text", "auto" -> "(-test.keyword:%different_value #*:*) _ignored:test.keyword";
+ case "match_only_text" -> "*:*";
+ case "semantic_text" -> "FieldExistsQuery [field=_primary_term]";
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ testPushQuery(value, esqlQuery, luceneQuery, true, true);
}
- public void testPushInequalityOnDefaultsTooBigToPush() throws IOException {
+ public void testInequalityTooBigToPush() throws IOException {
String value = "a".repeat(between(257, 1000));
- testPushQuery(value, """
+ String esqlQuery = """
FROM test
| WHERE test != "%value"
- """, "*:*", true, false);
+ """;
+ String luceneQuery = switch (type) {
+ case "text", "auto", "match_only_text" -> "*:*";
+ case "semantic_text" -> "FieldExistsQuery [field=_primary_term]";
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ testPushQuery(value, esqlQuery, luceneQuery, true, false);
}
- public void testPushCaseInsensitiveEqualityOnDefaults() throws IOException {
+ public void testCaseInsensitiveEquality() throws IOException {
String value = "a".repeat(between(0, 256));
- testPushQuery(value, """
+ String esqlQuery = """
FROM test
| WHERE TO_LOWER(test) == "%value"
- """, "*:*", true, true);
+ """;
+ String luceneQuery = switch (type) {
+ case "text", "auto", "match_only_text" -> "*:*";
+ case "semantic_text" -> "FieldExistsQuery [field=_primary_term]";
+ default -> throw new UnsupportedOperationException("unknown type [" + type + "]");
+ };
+ testPushQuery(value, esqlQuery, luceneQuery, true, true);
}
private void testPushQuery(String value, String esqlQuery, String luceneQuery, boolean filterInCompute, boolean found)
throws IOException {
indexValue(value);
- String differentValue = randomValueOtherThan(value, () -> randomAlphaOfLength(value.length() == 0 ? 1 : value.length()));
+ String differentValue = randomValueOtherThan(value, () -> randomAlphaOfLength(value.isEmpty() ? 1 : value.length()));
String replacedQuery = esqlQuery.replaceAll("%value", value).replaceAll("%different_value", differentValue);
RestEsqlTestCase.RequestObjectBuilder builder = requestObjectBuilder().query(replacedQuery + "\n| KEEP test");
@@ -148,15 +262,43 @@ private void testPushQuery(String value, String esqlQuery, String luceneQuery, b
}
private void indexValue(String value) throws IOException {
+ try {
+ // Delete the index if it has already been created.
+ client().performRequest(new Request("DELETE", "test"));
+ } catch (ResponseException e) {
+ if (e.getResponse().getStatusLine().getStatusCode() != 404) {
+ throw e;
+ }
+ }
+
Request createIndex = new Request("PUT", "test");
- createIndex.setJsonEntity("""
+ String json = """
{
"settings": {
"index": {
"number_of_shards": 1
}
- }
- }""");
+ }""";
+ if (false == "auto".equals(type)) {
+ json += """
+ ,
+ "mappings": {
+ "properties": {
+ "test": {
+ "type": "%type",
+ "fields": {
+ "keyword": {
+ "type": "keyword",
+ "ignore_above": 256
+ }
+ }
+ }
+ }
+ }
+ }""".replace("%type", type);
+ }
+ json += "}";
+ createIndex.setJsonEntity(json);
Response createResponse = client().performRequest(createIndex);
assertThat(
entityToMap(createResponse.getEntity(), XContentType.JSON),
@@ -167,7 +309,7 @@ private void indexValue(String value) throws IOException {
bulk.addParameter("refresh", "");
bulk.setJsonEntity(String.format(Locale.ROOT, """
{"create":{"_index":"test"}}
- {"test":"%s"}
+ {"test":"%s","foo":1}
""", value));
Response bulkResponse = client().performRequest(bulk);
assertThat(entityToMap(bulkResponse.getEntity(), XContentType.JSON), matchesMap().entry("errors", false).extraOk());
@@ -190,4 +332,10 @@ private static String checkOperatorProfile(Map o, String query)
protected String getTestRestCluster() {
return cluster.getHttpAddresses();
}
+
+ @Override
+ protected boolean preserveClusterUponCompletion() {
+ // Preserve the cluser to speed up the semantic_text tests
+ return true;
+ }
}
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/data/mv_text.csv b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/data/mv_text.csv
index 222cc42d27cde..ee3873c339ada 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/data/mv_text.csv
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/data/mv_text.csv
@@ -3,3 +3,4 @@
2023-10-23T13:55:01.544Z,Connected to 10.1.0.1
2023-10-23T13:55:01.545Z,[Connected to 10.1.0.1, More than one hundred characters long so it isn't indexed by the sub keyword field with ignore_above:100]
2023-10-23T13:55:01.546Z,More than one hundred characters long so it isn't indexed by the sub keyword field with ignore_above:100
+2023-10-23T13:55:01.547Z,[More than one hundred characters long so it isn't indexed by the sub keyword field with ignore_above:100,Second than one hundred characters long so it isn't indexed by the sub keyword field with ignore_above:100]
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/capabilities/TranslationAware.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/capabilities/TranslationAware.java
index 893ed26a1ab42..1a2fd81db4b6c 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/capabilities/TranslationAware.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/capabilities/TranslationAware.java
@@ -7,20 +7,34 @@
package org.elasticsearch.xpack.esql.capabilities;
+import org.elasticsearch.compute.lucene.LuceneTopNSourceOperator;
+import org.elasticsearch.compute.operator.FilterOperator;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.querydsl.query.Query;
import org.elasticsearch.xpack.esql.optimizer.rules.physical.local.LucenePushdownPredicates;
import org.elasticsearch.xpack.esql.planner.TranslatorHandler;
/**
- * Expressions implementing this interface can get called on data nodes to provide an Elasticsearch/Lucene query.
+ * Expressions implementing this interface are asked provide an
+ * Elasticsearch/Lucene query as part of the data node optimizations.
*/
public interface TranslationAware {
/**
- * Indicates whether the expression can be translated or not.
- * Usually checks whether the expression arguments are actual fields that exist in Lucene.
+ * Can this instance be translated or not? Usually checks whether the
+ * expression arguments are actual fields that exist in Lucene. See {@link Translatable}
+ * for precisely what can be signaled from this method.
*/
- boolean translatable(LucenePushdownPredicates pushdownPredicates);
+ Translatable translatable(LucenePushdownPredicates pushdownPredicates);
+
+ /**
+ * Is an {@link Expression} translatable?
+ */
+ static TranslationAware.Translatable translatable(Expression exp, LucenePushdownPredicates lucenePushdownPredicates) {
+ if (exp instanceof TranslationAware aware) {
+ return aware.translatable(lucenePushdownPredicates);
+ }
+ return TranslationAware.Translatable.NO;
+ }
/**
* Translates the implementing expression into a Query.
@@ -42,4 +56,112 @@ interface SingleValueTranslationAware extends TranslationAware {
*/
Expression singleValueField();
}
+
+ /**
+ * How is this expression translatable?
+ */
+ enum Translatable {
+ /**
+ * Not translatable at all. Calling {@link TranslationAware#asQuery} is an error.
+ * The expression will stay in the query plan and be filtered via a {@link FilterOperator}.
+ * Imagine {@code kwd == "a"} when {@code kwd} is configured without a search index.
+ */
+ NO(FinishedTranslatable.NO),
+ /**
+ * Entirely translatable into a lucene query. Calling {@link TranslationAware#asQuery}
+ * will produce a query that matches all documents matching this expression and
+ * only documents matching this expression. Imagine {@code kwd == "a"}
+ * when {@code kwd} has a search index and doc values - which is the
+ * default configuration. This will entirely remove the clause from the
+ * {@code WHERE}, removing the entire {@link FilterOperator} if it's empty. Sometimes
+ * this allows us to push the entire top-n operation to lucene with
+ * a {@link LuceneTopNSourceOperator}.
+ */
+ YES(FinishedTranslatable.YES),
+ /**
+ * Translation requires a recheck. Calling {@link TranslationAware#asQuery} will
+ * produce a query that matches all documents matching this expression but might
+ * match more documents that do not match the expression. This will cause us to
+ * push a query to lucene and keep the query in the query plan,
+ * rechecking it via a {@link FilterOperator}. This can never push the entire
+ * top-n to Lucene, but it's still quite a lot better than the full scan from
+ * {@link #NO}.
+ *
+ * Imagine {@code kwd == "a"} where {@code kwd} has a search index but doesn't
+ * have doc values. In that case we can find candidate matches in lucene but
+ * can't tell if those docs are single-valued. If they are multivalued they'll
+ * still match the query but won't match the expression. Thus, the double-checking.
+ * Technically we could just check for single-valued-ness in
+ * this case, but it's simpler to
+ *
+ */
+ RECHECK(FinishedTranslatable.RECHECK),
+ /**
+ * The same as {@link #YES}, but if this expression is negated it turns into {@link #RECHECK}.
+ * This comes up when pushing {@code NOT(text == "a")} to {@code text.keyword} which can
+ * have ignored fields.
+ */
+ YES_BUT_RECHECK_NEGATED(FinishedTranslatable.YES);
+
+ private final FinishedTranslatable finish;
+
+ Translatable(FinishedTranslatable finish) {
+ this.finish = finish;
+ }
+
+ /**
+ * Translate into a {@link FinishedTranslatable} which never
+ * includes {@link #YES_BUT_RECHECK_NEGATED}.
+ */
+ public FinishedTranslatable finish() {
+ return finish;
+ }
+
+ public Translatable negate() {
+ if (this == YES_BUT_RECHECK_NEGATED) {
+ return RECHECK;
+ }
+ return this;
+ }
+
+ /**
+ * Merge two {@link TranslationAware#translatable} results.
+ */
+ public Translatable merge(Translatable rhs) {
+ return switch (this) {
+ case NO -> NO;
+ case YES -> switch (rhs) {
+ case NO -> NO;
+ case YES -> YES;
+ case RECHECK -> RECHECK;
+ case YES_BUT_RECHECK_NEGATED -> YES_BUT_RECHECK_NEGATED;
+ };
+ case RECHECK -> switch (rhs) {
+ case NO -> NO;
+ case YES, RECHECK, YES_BUT_RECHECK_NEGATED -> RECHECK;
+ };
+ case YES_BUT_RECHECK_NEGATED -> switch (rhs) {
+ case NO -> NO;
+ case YES, YES_BUT_RECHECK_NEGATED -> YES_BUT_RECHECK_NEGATED;
+ case RECHECK -> RECHECK;
+ };
+ };
+ }
+
+ }
+
+ enum FinishedTranslatable {
+ /**
+ * See {@link Translatable#YES}.
+ */
+ YES,
+ /**
+ * See {@link Translatable#NO}.
+ */
+ NO,
+ /**
+ * See {@link Translatable#RECHECK}.
+ */
+ RECHECK;
+ }
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextFunction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextFunction.java
index 2d86d7a604b36..8fdb305804c04 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextFunction.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextFunction.java
@@ -156,9 +156,9 @@ public boolean equals(Object obj) {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
// In isolation, full text functions are pushable to source. We check if there are no disjunctions in Or conditions
- return true;
+ return Translatable.YES;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ip/CIDRMatch.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ip/CIDRMatch.java
index 7ffae77483882..547022e0fbde5 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ip/CIDRMatch.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ip/CIDRMatch.java
@@ -179,8 +179,8 @@ protected NodeInfo extends Expression> info() {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return pushdownPredicates.isPushableFieldAttribute(ipField) && Expressions.foldable(matches);
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return pushdownPredicates.isPushableFieldAttribute(ipField) && Expressions.foldable(matches) ? Translatable.YES : Translatable.NO;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/spatial/BinarySpatialFunction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/spatial/BinarySpatialFunction.java
index cfb317234b1d1..437973705f343 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/spatial/BinarySpatialFunction.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/spatial/BinarySpatialFunction.java
@@ -13,6 +13,7 @@
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.geometry.Geometry;
import org.elasticsearch.lucene.spatial.CoordinateEncoder;
+import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
import org.elasticsearch.xpack.esql.core.expression.TypeResolutions;
@@ -273,12 +274,14 @@ protected Geometry fromBytesRef(BytesRef bytesRef) {
/**
* Push-down to Lucene is only possible if one field is an indexed spatial field, and the other is a constant spatial or string column.
*/
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
+ public TranslationAware.Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
// The use of foldable here instead of SpatialEvaluatorFieldKey.isConstant is intentional to match the behavior of the
// Lucene pushdown code in EsqlTranslationHandler::SpatialRelatesTranslator
// We could enhance both places to support ReferenceAttributes that refer to constants, but that is a larger change
return isPushableSpatialAttribute(left(), pushdownPredicates) && right().foldable()
- || isPushableSpatialAttribute(right(), pushdownPredicates) && left().foldable();
+ || isPushableSpatialAttribute(right(), pushdownPredicates) && left().foldable()
+ ? TranslationAware.Translatable.YES
+ : TranslationAware.Translatable.NO;
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/spatial/SpatialRelatesFunction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/spatial/SpatialRelatesFunction.java
index f9164a0768209..4af5230e9d328 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/spatial/SpatialRelatesFunction.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/spatial/SpatialRelatesFunction.java
@@ -181,7 +181,7 @@ protected void processPointDocValuesAndSource(
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
return super.translatable(pushdownPredicates); // only for the explicit Override, as only this subclass implements TranslationAware
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/EndsWith.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/EndsWith.java
index 5b8dc911b7c6c..52dedcb670372 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/EndsWith.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/EndsWith.java
@@ -139,8 +139,8 @@ public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return pushdownPredicates.isPushableAttribute(str) && suffix.foldable();
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return pushdownPredicates.isPushableAttribute(str) && suffix.foldable() ? Translatable.YES : Translatable.NO;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/RLike.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/RLike.java
index 0950cc4a3a8ec..43c4705d1c750 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/RLike.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/RLike.java
@@ -107,8 +107,8 @@ public EvalOperator.ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvalua
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return pushdownPredicates.isPushableFieldAttribute(field());
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return pushdownPredicates.isPushableFieldAttribute(field()) ? Translatable.YES : Translatable.NO;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/StartsWith.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/StartsWith.java
index 518d920ab4ee2..f457bacf44268 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/StartsWith.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/StartsWith.java
@@ -136,8 +136,8 @@ public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return pushdownPredicates.isPushableAttribute(str) && prefix.foldable();
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return pushdownPredicates.isPushableAttribute(str) && prefix.foldable() ? Translatable.YES : Translatable.NO;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/WildcardLike.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/WildcardLike.java
index 1dd2460ea72a1..3f90f9b70766f 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/WildcardLike.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/WildcardLike.java
@@ -119,8 +119,8 @@ public EvalOperator.ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvalua
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return pushdownPredicates.isPushableAttribute(field());
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return pushdownPredicates.isPushableAttribute(field()) ? Translatable.YES : Translatable.NO;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/Range.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/Range.java
index 30ea63d0e473d..6427449add799 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/Range.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/Range.java
@@ -215,8 +215,8 @@ public boolean equals(Object obj) {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return pushdownPredicates.isPushableAttribute(value) && lower.foldable() && upper.foldable();
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return pushdownPredicates.isPushableAttribute(value) && lower.foldable() && upper.foldable() ? Translatable.YES : Translatable.NO;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/logical/BinaryLogic.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/logical/BinaryLogic.java
index e457d3523e9c4..ebe17d325ccf7 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/logical/BinaryLogic.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/logical/BinaryLogic.java
@@ -82,11 +82,8 @@ protected boolean isCommutative() {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return left() instanceof TranslationAware leftAware
- && leftAware.translatable(pushdownPredicates)
- && right() instanceof TranslationAware rightAware
- && rightAware.translatable(pushdownPredicates);
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return TranslationAware.translatable(left(), pushdownPredicates).merge(TranslationAware.translatable(right(), pushdownPredicates));
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/logical/Not.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/logical/Not.java
index 4fc43e4c3a9cf..9f14e291fe7a7 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/logical/Not.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/logical/Not.java
@@ -100,8 +100,8 @@ static Expression negate(Expression exp) {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return field() instanceof TranslationAware aware && aware.translatable(pushdownPredicates);
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return TranslationAware.translatable(field(), pushdownPredicates).negate();
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/nulls/IsNotNull.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/nulls/IsNotNull.java
index 7b183f44c4aae..5c7492b0a64dd 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/nulls/IsNotNull.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/nulls/IsNotNull.java
@@ -75,7 +75,7 @@ public UnaryScalarFunction negate() {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
return IsNull.isTranslatable(field(), pushdownPredicates);
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/nulls/IsNull.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/nulls/IsNull.java
index 9e393ddb05e9a..b41b20b673b86 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/nulls/IsNull.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/nulls/IsNull.java
@@ -72,12 +72,14 @@ public UnaryScalarFunction negate() {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
return isTranslatable(field(), pushdownPredicates);
}
- protected static boolean isTranslatable(Expression field, LucenePushdownPredicates pushdownPredicates) {
- return LucenePushdownPredicates.isPushableTextFieldAttribute(field) || pushdownPredicates.isPushableFieldAttribute(field);
+ protected static Translatable isTranslatable(Expression field, LucenePushdownPredicates pushdownPredicates) {
+ return LucenePushdownPredicates.isPushableTextFieldAttribute(field) || pushdownPredicates.isPushableFieldAttribute(field)
+ ? Translatable.YES
+ : Translatable.NO;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/Equals.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/Equals.java
index 75f47615bad6e..01f57342da1f0 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/Equals.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/Equals.java
@@ -128,11 +128,11 @@ public Equals(Source source, Expression left, Expression right, ZoneId zoneId) {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
if (right() instanceof Literal lit) {
- if (false && left().dataType() == DataType.TEXT && left() instanceof FieldAttribute fa) {
+ if (left().dataType() == DataType.TEXT && left() instanceof FieldAttribute fa) {
if (pushdownPredicates.canUseEqualityOnSyntheticSourceDelegate(fa, ((BytesRef) lit.value()).utf8ToString())) {
- return true;
+ return Translatable.YES_BUT_RECHECK_NEGATED;
}
}
}
@@ -142,8 +142,7 @@ public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
@Override
public Query asQuery(LucenePushdownPredicates pushdownPredicates, TranslatorHandler handler) {
if (right() instanceof Literal lit) {
- // Disabled because it cased a bug with !=. Fix incoming shortly.
- if (false && left().dataType() == DataType.TEXT && left() instanceof FieldAttribute fa) {
+ if (left().dataType() == DataType.TEXT && left() instanceof FieldAttribute fa) {
String value = ((BytesRef) lit.value()).utf8ToString();
if (pushdownPredicates.canUseEqualityOnSyntheticSourceDelegate(fa, value)) {
String name = handler.nameOf(fa);
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/EsqlBinaryComparison.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/EsqlBinaryComparison.java
index 542c696fd3521..69ef99ba04d15 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/EsqlBinaryComparison.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/EsqlBinaryComparison.java
@@ -328,16 +328,16 @@ public static String formatIncompatibleTypesMessage(DataType leftType, DataType
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
if (right().foldable()) {
if (pushdownPredicates.isPushableFieldAttribute(left())) {
- return true;
+ return Translatable.YES;
}
if (LucenePushdownPredicates.isPushableMetadataAttribute(left())) {
- return this instanceof Equals || this instanceof NotEquals;
+ return this instanceof Equals || this instanceof NotEquals ? Translatable.YES : Translatable.NO;
}
}
- return false;
+ return Translatable.NO;
}
/**
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/In.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/In.java
index 3f5d20f794e4c..c38d7abd7243e 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/In.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/In.java
@@ -461,8 +461,8 @@ static boolean process(BitSet nulls, BitSet mvs, BytesRef lhs, BytesRef[] rhs) {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return pushdownPredicates.isPushableAttribute(value) && Expressions.foldable(list());
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return pushdownPredicates.isPushableAttribute(value) && Expressions.foldable(list()) ? Translatable.YES : Translatable.NO;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/InsensitiveEquals.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/InsensitiveEquals.java
index 8eda96236f504..ccd2f5bbbad5b 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/InsensitiveEquals.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/InsensitiveEquals.java
@@ -102,8 +102,8 @@ public Boolean fold(FoldContext ctx) {
}
@Override
- public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
- return pushdownPredicates.isPushableFieldAttribute(left()) && right().foldable();
+ public Translatable translatable(LucenePushdownPredicates pushdownPredicates) {
+ return pushdownPredicates.isPushableFieldAttribute(left()) && right().foldable() ? Translatable.YES : Translatable.NO;
}
@Override
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/EnableSpatialDistancePushdown.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/EnableSpatialDistancePushdown.java
index 13f18f4b2bf4d..1e976ca2e6263 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/EnableSpatialDistancePushdown.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/EnableSpatialDistancePushdown.java
@@ -12,6 +12,7 @@
import org.elasticsearch.geometry.Geometry;
import org.elasticsearch.geometry.Point;
import org.elasticsearch.geometry.utils.WellKnownBinary;
+import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
import org.elasticsearch.xpack.esql.core.expression.Alias;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
import org.elasticsearch.xpack.esql.core.expression.AttributeMap;
@@ -42,8 +43,8 @@
import java.util.List;
import java.util.Map;
+import static org.elasticsearch.xpack.esql.capabilities.TranslationAware.translatable;
import static org.elasticsearch.xpack.esql.expression.predicate.Predicates.splitAnd;
-import static org.elasticsearch.xpack.esql.optimizer.rules.physical.local.PushFiltersToSource.canPushToSource;
import static org.elasticsearch.xpack.esql.optimizer.rules.physical.local.PushFiltersToSource.getAliasReplacedBy;
/**
@@ -106,7 +107,8 @@ private FilterExec rewrite(
}
return comparison;
});
- if (rewritten.equals(filterExec.condition()) == false && canPushToSource(rewritten, lucenePushdownPredicates)) {
+ if (rewritten.equals(filterExec.condition()) == false
+ && translatable(rewritten, lucenePushdownPredicates).finish() == TranslationAware.FinishedTranslatable.YES) {
return new FilterExec(filterExec.source(), esQueryExec, rewritten);
}
return filterExec;
@@ -156,7 +158,8 @@ private PhysicalPlan rewriteBySplittingFilter(
// Find and rewrite any binary comparisons that involve a distance function and a literal
var rewritten = rewriteDistanceFilters(ctx, resExp, distances);
// If all pushable StDistance functions were found and re-written, we need to re-write the FILTER/EVAL combination
- if (rewritten.equals(resExp) == false && canPushToSource(rewritten, lucenePushdownPredicates)) {
+ if (rewritten.equals(resExp) == false
+ && translatable(rewritten, lucenePushdownPredicates).finish() == TranslationAware.FinishedTranslatable.YES) {
pushable.add(rewritten);
} else {
nonPushable.add(exp);
@@ -183,7 +186,8 @@ private PhysicalPlan rewriteBySplittingFilter(
private Map getPushableDistances(List aliases, LucenePushdownPredicates lucenePushdownPredicates) {
Map distances = new LinkedHashMap<>();
aliases.forEach(alias -> {
- if (alias.child() instanceof StDistance distance && distance.translatable(lucenePushdownPredicates)) {
+ if (alias.child() instanceof StDistance distance
+ && distance.translatable(lucenePushdownPredicates).finish() == TranslationAware.FinishedTranslatable.YES) {
distances.put(alias.id(), distance);
} else if (alias.child() instanceof ReferenceAttribute ref && distances.containsKey(ref.id())) {
StDistance distance = distances.get(ref.id());
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java
index 8d0c88d9c399d..1f8341c4768d2 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java
@@ -8,7 +8,6 @@
package org.elasticsearch.xpack.esql.optimizer.rules.physical.local;
import org.elasticsearch.index.query.QueryBuilder;
-import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
import org.elasticsearch.xpack.esql.core.expression.Alias;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
import org.elasticsearch.xpack.esql.core.expression.AttributeMap;
@@ -36,6 +35,7 @@
import java.util.List;
import static java.util.Arrays.asList;
+import static org.elasticsearch.xpack.esql.capabilities.TranslationAware.translatable;
import static org.elasticsearch.xpack.esql.expression.predicate.Predicates.splitAnd;
import static org.elasticsearch.xpack.esql.planner.TranslatorHandler.TRANSLATOR_HANDLER;
@@ -57,7 +57,14 @@ private static PhysicalPlan planFilterExec(FilterExec filterExec, EsQueryExec qu
List pushable = new ArrayList<>();
List nonPushable = new ArrayList<>();
for (Expression exp : splitAnd(filterExec.condition())) {
- (canPushToSource(exp, pushdownPredicates) ? pushable : nonPushable).add(exp);
+ switch (translatable(exp, pushdownPredicates).finish()) {
+ case NO -> nonPushable.add(exp);
+ case YES -> pushable.add(exp);
+ case RECHECK -> {
+ pushable.add(exp);
+ nonPushable.add(exp);
+ }
+ }
}
return rewrite(pushdownPredicates, filterExec, queryExec, pushable, nonPushable, List.of());
}
@@ -74,7 +81,14 @@ private static PhysicalPlan planFilterExec(
List nonPushable = new ArrayList<>();
for (Expression exp : splitAnd(filterExec.condition())) {
Expression resExp = exp.transformUp(ReferenceAttribute.class, r -> aliasReplacedBy.resolve(r, r));
- (canPushToSource(resExp, pushdownPredicates) ? pushable : nonPushable).add(exp);
+ switch (translatable(resExp, pushdownPredicates).finish()) {
+ case NO -> nonPushable.add(exp);
+ case YES -> pushable.add(exp);
+ case RECHECK -> {
+ nonPushable.add(exp);
+ nonPushable.add(exp);
+ }
+ }
}
// Replace field references with their actual field attributes
pushable.replaceAll(e -> e.transformDown(ReferenceAttribute.class, r -> aliasReplacedBy.resolve(r, r)));
@@ -202,18 +216,4 @@ else if ((other instanceof GreaterThan || other instanceof GreaterThanOrEqual)
}
return changed ? CollectionUtils.combine(others, bcs, ranges) : pushable;
}
-
- /**
- * Check if the given expression can be pushed down to the source.
- * This version of the check is called when we do not have SearchStats available. It assumes no exact subfields for TEXT fields,
- * and makes the indexed/doc-values check using the isAggregatable flag only, which comes from field-caps, represents the field state
- * over the entire cluster (is not node specific), and has risks for indexed=false/doc_values=true fields.
- */
- public static boolean canPushToSource(Expression exp) {
- return canPushToSource(exp, LucenePushdownPredicates.DEFAULT);
- }
-
- static boolean canPushToSource(Expression exp, LucenePushdownPredicates lucenePushdownPredicates) {
- return exp instanceof TranslationAware aware && aware.translatable(lucenePushdownPredicates);
- }
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushStatsToSource.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushStatsToSource.java
index d7abc58bc7300..ed97ec5553fcc 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushStatsToSource.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushStatsToSource.java
@@ -10,6 +10,7 @@
import org.elasticsearch.core.Tuple;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
import org.elasticsearch.xpack.esql.core.expression.Alias;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
import org.elasticsearch.xpack.esql.core.expression.AttributeMap;
@@ -33,7 +34,7 @@
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
-import static org.elasticsearch.xpack.esql.optimizer.rules.physical.local.PushFiltersToSource.canPushToSource;
+import static org.elasticsearch.xpack.esql.capabilities.TranslationAware.translatable;
import static org.elasticsearch.xpack.esql.plan.physical.EsStatsQueryExec.StatsType.COUNT;
import static org.elasticsearch.xpack.esql.planner.TranslatorHandler.TRANSLATOR_HANDLER;
@@ -107,7 +108,10 @@ private Tuple, List> pushableStats(
// That's because stats pushdown only works for 1 agg function (without BY); but in that case, filters
// are extracted into a separate filter node upstream from the aggregation (and hopefully pushed into
// the EsQueryExec separately).
- if (canPushToSource(count.filter()) == false) {
+ if (translatable(
+ count.filter(),
+ LucenePushdownPredicates.DEFAULT
+ ) != TranslationAware.Translatable.YES) {
return null; // can't push down
}
var countFilter = TRANSLATOR_HANDLER.asQuery(LucenePushdownPredicates.DEFAULT, count.filter());
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/PlannerUtils.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/PlannerUtils.java
index f77532285c379..3593b90c9662f 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/PlannerUtils.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/PlannerUtils.java
@@ -20,6 +20,7 @@
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.SearchExecutionContext;
import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
+import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
import org.elasticsearch.xpack.esql.core.expression.AttributeSet;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.FoldContext;
@@ -64,8 +65,8 @@
import static org.elasticsearch.index.mapper.MappedFieldType.FieldExtractPreference.DOC_VALUES;
import static org.elasticsearch.index.mapper.MappedFieldType.FieldExtractPreference.EXTRACT_SPATIAL_BOUNDS;
import static org.elasticsearch.index.mapper.MappedFieldType.FieldExtractPreference.NONE;
+import static org.elasticsearch.xpack.esql.capabilities.TranslationAware.translatable;
import static org.elasticsearch.xpack.esql.core.util.Queries.Clause.FILTER;
-import static org.elasticsearch.xpack.esql.optimizer.rules.physical.local.PushFiltersToSource.canPushToSource;
import static org.elasticsearch.xpack.esql.planner.TranslatorHandler.TRANSLATOR_HANDLER;
public class PlannerUtils {
@@ -245,7 +246,9 @@ static QueryBuilder detectFilter(PhysicalPlan plan, Predicate fieldName)
boolean matchesField = refsBuilder.removeIf(e -> fieldName.test(e.name()));
// the expression only contains the target reference
// and the expression is pushable (functions can be fully translated)
- if (matchesField && refsBuilder.isEmpty() && canPushToSource(exp)) {
+ if (matchesField
+ && refsBuilder.isEmpty()
+ && translatable(exp, LucenePushdownPredicates.DEFAULT).finish() == TranslationAware.FinishedTranslatable.YES) {
matches.add(exp);
}
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/SingleValueQuery.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/SingleValueQuery.java
index 3af78787808f0..912af6663a9dc 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/SingleValueQuery.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/SingleValueQuery.java
@@ -17,7 +17,9 @@
import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.compute.lucene.LuceneSourceOperator;
import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.FilterOperator;
import org.elasticsearch.compute.operator.Warnings;
import org.elasticsearch.compute.querydsl.query.SingleValueMatchQuery;
import org.elasticsearch.index.mapper.IgnoredFieldMapper;
@@ -50,6 +52,9 @@
* for now we're going to always wrap so we can always push. When we find cases
* where double checking is better we'll try that.
*
+ *
+ * NOTE: This will only work with {@code text} fields.
+ *
*/
public class SingleValueQuery extends Query {
public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
@@ -60,7 +65,7 @@ public class SingleValueQuery extends Query {
private final Query next;
private final String field;
- private final boolean useSyntheticSourceDelegate;
+ private final UseSyntheticSourceDelegate useSyntheticSourceDelegate;
/**
* Build.
@@ -71,6 +76,10 @@ public class SingleValueQuery extends Query {
* we often want to use its delegate.
*/
public SingleValueQuery(Query next, String field, boolean useSyntheticSourceDelegate) {
+ this(next, field, useSyntheticSourceDelegate ? UseSyntheticSourceDelegate.YES : UseSyntheticSourceDelegate.NO);
+ }
+
+ public SingleValueQuery(Query next, String field, UseSyntheticSourceDelegate useSyntheticSourceDelegate) {
super(next.source());
this.next = next;
this.field = field;
@@ -79,9 +88,11 @@ public SingleValueQuery(Query next, String field, boolean useSyntheticSourceDele
@Override
protected AbstractBuilder asBuilder() {
- return useSyntheticSourceDelegate
- ? new SyntheticSourceDelegateBuilder(next.toQueryBuilder(), field, next.source())
- : new Builder(next.toQueryBuilder(), field, next.source());
+ return switch (useSyntheticSourceDelegate) {
+ case NO -> new Builder(next.toQueryBuilder(), field, next.source());
+ case YES -> new SyntheticSourceDelegateBuilder(next.toQueryBuilder(), field, next.source());
+ case YES_NEGATED -> new NegatedSyntheticSourceDelegateBuilder(next.toQueryBuilder(), field, next.source());
+ };
}
@Override
@@ -91,7 +102,11 @@ protected String innerToString() {
@Override
public SingleValueQuery negate(Source source) {
- return new SingleValueQuery(next.negate(source), field, useSyntheticSourceDelegate);
+ return new SingleValueQuery(next.negate(source), field, switch (useSyntheticSourceDelegate) {
+ case NO -> UseSyntheticSourceDelegate.NO;
+ case YES -> UseSyntheticSourceDelegate.YES_NEGATED;
+ case YES_NEGATED -> UseSyntheticSourceDelegate.YES;
+ });
}
@Override
@@ -188,6 +203,28 @@ protected final boolean doEquals(AbstractBuilder other) {
protected final int doHashCode() {
return Objects.hash(next, field);
}
+
+ protected final org.apache.lucene.search.Query simple(MappedFieldType ft, SearchExecutionContext context) throws IOException {
+ SingleValueMatchQuery singleValueQuery = new SingleValueMatchQuery(
+ context.getForField(ft, MappedFieldType.FielddataOperation.SEARCH),
+ Warnings.createWarnings(
+ DriverContext.WarningsMode.COLLECT,
+ source().source().getLineNumber(),
+ source().source().getColumnNumber(),
+ source().text()
+ ),
+ "single-value function encountered multi-value"
+ );
+ org.apache.lucene.search.Query rewrite = singleValueQuery.rewrite(context.searcher());
+ if (rewrite instanceof MatchAllDocsQuery) {
+ // nothing to filter
+ return next().toQuery(context);
+ }
+ BooleanQuery.Builder builder = new BooleanQuery.Builder();
+ builder.add(next().toQuery(context), BooleanClause.Occur.FILTER);
+ builder.add(rewrite, BooleanClause.Occur.FILTER);
+ return builder.build();
+ }
}
/**
@@ -227,25 +264,7 @@ protected final org.apache.lucene.search.Query doToQuery(SearchExecutionContext
if (ft == null) {
return new MatchNoDocsQuery("missing field [" + field() + "]");
}
- SingleValueMatchQuery singleValueQuery = new SingleValueMatchQuery(
- context.getForField(ft, MappedFieldType.FielddataOperation.SEARCH),
- Warnings.createWarnings(
- DriverContext.WarningsMode.COLLECT,
- source().source().getLineNumber(),
- source().source().getColumnNumber(),
- source().text()
- ),
- "single-value function encountered multi-value"
- );
- org.apache.lucene.search.Query rewrite = singleValueQuery.rewrite(context.searcher());
- if (rewrite instanceof MatchAllDocsQuery) {
- // nothing to filter
- return next().toQuery(context);
- }
- BooleanQuery.Builder builder = new BooleanQuery.Builder();
- builder.add(next().toQuery(context), BooleanClause.Occur.FILTER);
- builder.add(rewrite, BooleanClause.Occur.FILTER);
- return builder.build();
+ return simple(ft, context);
}
@Override
@@ -255,7 +274,7 @@ protected AbstractBuilder rewrite(QueryBuilder next) {
}
/**
- * Builds a {@code bool} query combining the "next" query, a {@link SingleValueMatchQuery},
+ * Builds a {@code bool} query ANDing the "next" query, a {@link SingleValueMatchQuery},
* and a {@link TermQuery} making sure we didn't ignore any values. Three total queries.
* This is only used if the "next" query matches fields that would not be ignored. Read all
* the paragraphs below to understand it. It's tricky!
@@ -344,6 +363,92 @@ protected AbstractBuilder rewrite(QueryBuilder next) {
}
}
+ /**
+ * Builds a query matching either ignored values OR the union of {@code next} query
+ * and {@link SingleValueMatchQuery}. Three total queries. This is used to generate
+ * candidate matches for queries like {@code NOT(a == "b")} where some values of {@code a}
+ * are not indexed. In fact, let's use that as an example.
+ *
+ * In that case you use a query for {@code a != "b"} as the "next" query. Then
+ * this query will find all documents where {@code a} is single valued and
+ * {@code == "b"} AND all documents that have ignored some values of {@code a}.
+ * This produces candidate matches for {@code NOT(a == "b")}.
+ * It'll find documents like:
+ *
+ *
+ * "a"
+ * ignored_value
+ * ["a", ignored_value]
+ * [ignored_value1, ignored_value2]
+ * ["b", ignored_field]
+ *
+ *
+ * The first and second of those should match {@code NOT(a == "b")}.
+ * The last three should be rejected. So! When using this query you must
+ * push this query to the {@link LuceneSourceOperator} and
+ * retain it in the {@link FilterOperator}.
+ *
+ *
+ * This will not find:
+ *
+ *
+ *
+ * And that's also great! These can't match {@code NOT(a == "b")}
+ *
+ */
+ public static class NegatedSyntheticSourceDelegateBuilder extends AbstractBuilder {
+ NegatedSyntheticSourceDelegateBuilder(QueryBuilder next, String field, Source source) {
+ super(next, field, source);
+ }
+
+ @Override
+ public String getWriteableName() {
+ throw new UnsupportedOperationException("Not serialized");
+ }
+
+ @Override
+ protected void doXContent(XContentBuilder builder, Params params) throws IOException {
+ builder.startObject("negated_" + ENTRY.name);
+ builder.field("field", field() + ":synthetic_source_delegate");
+ builder.field("next", next(), params);
+ builder.field("source", source().toString());
+ builder.endObject();
+ }
+
+ @Override
+ public TransportVersion getMinimalSupportedVersion() {
+ throw new UnsupportedOperationException("Not serialized");
+ }
+
+ @Override
+ protected final org.apache.lucene.search.Query doToQuery(SearchExecutionContext context) throws IOException {
+ MappedFieldType ft = context.getFieldType(field());
+ if (ft == null) {
+ return new MatchNoDocsQuery("missing field [" + field() + "]");
+ }
+ ft = ((TextFieldMapper.TextFieldType) ft).syntheticSourceDelegate();
+ org.apache.lucene.search.Query svNext = simple(ft, context);
+
+ org.apache.lucene.search.Query ignored = new TermQuery(new org.apache.lucene.index.Term(IgnoredFieldMapper.NAME, ft.name()));
+ ignored = ignored.rewrite(context.searcher());
+ if (ignored instanceof MatchNoDocsQuery) {
+ return svNext;
+ }
+
+ BooleanQuery.Builder builder = new BooleanQuery.Builder();
+ builder.add(svNext, BooleanClause.Occur.SHOULD);
+ builder.add(ignored, BooleanClause.Occur.SHOULD);
+ return builder.build();
+ }
+
+ @Override
+ protected AbstractBuilder rewrite(QueryBuilder next) {
+ return new Builder(next, field(), source());
+ }
+ }
+
/**
* Write a {@link Source} including the text in it.
*/
@@ -364,4 +469,10 @@ static Source readOldSource(StreamInput in) throws IOException {
String text = in.readString();
return new Source(new Location(line, charPositionInLine), text);
}
+
+ public enum UseSyntheticSourceDelegate {
+ NO,
+ YES,
+ YES_NEGATED;
+ }
}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/EndsWithTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/EndsWithTests.java
index e4bec34f5bfb7..d0a68f4986efd 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/EndsWithTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/EndsWithTests.java
@@ -11,6 +11,7 @@
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
import org.elasticsearch.xpack.esql.core.expression.Literal;
@@ -113,7 +114,7 @@ public void testLuceneQuery_AllLiterals_NonTranslatable() {
new Literal(Source.EMPTY, "test", DataType.KEYWORD)
);
- assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(false));
+ assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(TranslationAware.Translatable.NO));
}
public void testLuceneQuery_NonFoldableSuffix_NonTranslatable() {
@@ -123,7 +124,7 @@ public void testLuceneQuery_NonFoldableSuffix_NonTranslatable() {
new FieldAttribute(Source.EMPTY, "field", new EsField("suffix", DataType.KEYWORD, Map.of(), true))
);
- assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(false));
+ assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(TranslationAware.Translatable.NO));
}
public void testLuceneQuery_NonFoldableSuffix_Translatable() {
@@ -133,7 +134,7 @@ public void testLuceneQuery_NonFoldableSuffix_Translatable() {
new Literal(Source.EMPTY, "a*b?c\\", DataType.KEYWORD)
);
- assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(true));
+ assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(TranslationAware.Translatable.YES));
var query = function.asQuery(LucenePushdownPredicates.DEFAULT, TranslatorHandler.TRANSLATOR_HANDLER);
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/StartsWithTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/StartsWithTests.java
index 8321ccee79fa0..bbbb717f03eb6 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/StartsWithTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/StartsWithTests.java
@@ -11,6 +11,7 @@
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
import org.elasticsearch.xpack.esql.core.expression.Literal;
@@ -73,7 +74,7 @@ public void testLuceneQuery_AllLiterals_NonTranslatable() {
new Literal(Source.EMPTY, "test", DataType.KEYWORD)
);
- assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(false));
+ assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(TranslationAware.Translatable.NO));
}
public void testLuceneQuery_NonFoldablePrefix_NonTranslatable() {
@@ -83,7 +84,7 @@ public void testLuceneQuery_NonFoldablePrefix_NonTranslatable() {
new FieldAttribute(Source.EMPTY, "field", new EsField("prefix", DataType.KEYWORD, Map.of(), true))
);
- assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(false));
+ assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(TranslationAware.Translatable.NO));
}
public void testLuceneQuery_NonFoldablePrefix_Translatable() {
@@ -93,7 +94,7 @@ public void testLuceneQuery_NonFoldablePrefix_Translatable() {
new Literal(Source.EMPTY, "a*b?c\\", DataType.KEYWORD)
);
- assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(true));
+ assertThat(function.translatable(LucenePushdownPredicates.DEFAULT), equalTo(TranslationAware.Translatable.YES));
var query = function.asQuery(LucenePushdownPredicates.DEFAULT, TranslatorHandler.TRANSLATOR_HANDLER);
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java
index d8da50585bdfa..ab43ee6c90c10 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java
@@ -59,6 +59,7 @@
import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
import org.elasticsearch.xpack.esql.core.expression.ReferenceAttribute;
import org.elasticsearch.xpack.esql.core.expression.predicate.operator.comparison.BinaryComparison;
+import org.elasticsearch.xpack.esql.core.querydsl.query.NotQuery;
import org.elasticsearch.xpack.esql.core.tree.Node;
import org.elasticsearch.xpack.esql.core.tree.Source;
import org.elasticsearch.xpack.esql.core.type.DataType;
@@ -7795,7 +7796,6 @@ public void testReductionPlanForAggs() {
}
public void testEqualsPushdownToDelegate() {
- assumeFalse("disabled from bug", true);
var optimized = optimizedPlan(physicalPlan("""
FROM test
| WHERE job == "v"
@@ -7824,6 +7824,33 @@ public void testEqualsPushdownToDelegateTooBig() {
as(limit2.child(), FilterExec.class);
}
+ public void testNotEqualsPushdownToDelegate() {
+ var optimized = optimizedPlan(physicalPlan("""
+ FROM test
+ | WHERE job != "v"
+ """, testDataLimitedRaw), SEARCH_STATS_SHORT_DELEGATES);
+ var limit = as(optimized, LimitExec.class);
+ var exchange = as(limit.child(), ExchangeExec.class);
+ var project = as(exchange.child(), ProjectExec.class);
+ var extract = as(project.child(), FieldExtractExec.class);
+ var limit2 = as(extract.child(), LimitExec.class);
+ var filter = as(limit2.child(), FilterExec.class);
+ var extract2 = as(filter.child(), FieldExtractExec.class);
+ var query = as(extract2.child(), EsQueryExec.class);
+ assertThat(
+ query.query(),
+ equalTo(
+ new BoolQueryBuilder().filter(
+ new SingleValueQuery(
+ new NotQuery(Source.EMPTY, new EqualsSyntheticSourceDelegate(Source.EMPTY, "job", "v")),
+ "job",
+ SingleValueQuery.UseSyntheticSourceDelegate.YES_NEGATED
+ ).toQueryBuilder()
+ )
+ )
+ );
+ }
+
/*
* LimitExec[1000[INTEGER]]
* \_ExchangeExec[[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gender{f}#4, hire_date{f}#9, job{f}#10, job.raw{f}#11, langua
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/SingleValueQueryNegateTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/SingleValueQueryNegateTests.java
index d79c4a0a518ec..232cd9b804b18 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/SingleValueQueryNegateTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/SingleValueQueryNegateTests.java
@@ -25,7 +25,15 @@ public void testNot() {
var sv = new SingleValueQuery(new MatchAll(Source.EMPTY), "foo", useSyntheticSourceDelegate);
assertThat(
sv.negate(Source.EMPTY),
- equalTo(new SingleValueQuery(new NotQuery(Source.EMPTY, new MatchAll(Source.EMPTY)), "foo", useSyntheticSourceDelegate))
+ equalTo(
+ new SingleValueQuery(
+ new NotQuery(Source.EMPTY, new MatchAll(Source.EMPTY)),
+ "foo",
+ useSyntheticSourceDelegate
+ ? SingleValueQuery.UseSyntheticSourceDelegate.YES_NEGATED
+ : SingleValueQuery.UseSyntheticSourceDelegate.NO
+ )
+ )
);
}