Skip to content

Commit 2d68f85

Browse files
push down binary comparisons on date and date nanos union type fields
1 parent 00b6229 commit 2d68f85

File tree

13 files changed

+665
-77
lines changed

13 files changed

+665
-77
lines changed

x-pack/plugin/esql/qa/testFixtures/src/main/resources/union_types.csv-spec

Lines changed: 309 additions & 23 deletions
Large diffs are not rendered by default.

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import org.elasticsearch.xpack.esql.Column;
1919
import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
2020
import org.elasticsearch.xpack.esql.VerificationException;
21+
import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
2122
import org.elasticsearch.xpack.esql.analysis.AnalyzerRules.ParameterizedAnalyzerRule;
2223
import org.elasticsearch.xpack.esql.common.Failure;
2324
import org.elasticsearch.xpack.esql.core.capabilities.Resolvables;
@@ -1797,11 +1798,15 @@ private static LogicalPlan planWithoutSyntheticAttributes(LogicalPlan plan) {
17971798
private static class ImplicitCastingForUnionTypedFields extends ParameterizedRule<LogicalPlan, LogicalPlan, AnalyzerContext> {
17981799
@Override
17991800
public LogicalPlan apply(LogicalPlan plan, AnalyzerContext context) {
1801+
if (EsqlCapabilities.Cap.IMPLICIT_CASTING_DATE_AND_DATE_NANOS.isEnabled() == false) {
1802+
return plan;
1803+
}
18001804
// This rule should be applied after ResolveUnionTypes, so that the InvalidMappedFields with explicit casting are converted into
1801-
// MultiTypeEsField, and don't be double cast here.
1805+
// MultiTypeEsField, and don't get double cast here.
18021806
Map<FieldAttribute, Alias> invalidMappedFieldCasted = new HashMap<>();
18031807
LogicalPlan transformedPlan = plan.transformUp(LogicalPlan.class, p -> {
1804-
if (p instanceof UnaryPlan == false && p instanceof LookupJoin == false) {
1808+
// exclude LookupJoin for now, as it doesn't support date_nanos as join key yet
1809+
if (p instanceof UnaryPlan == false) {
18051810
return p;
18061811
}
18071812
Set<FieldAttribute> invalidMappedFields = invalidMappedFieldsInLogicalPlan(p);
@@ -1829,11 +1834,10 @@ public LogicalPlan apply(LogicalPlan plan, AnalyzerContext context) {
18291834
// If there are new aliases created, create a new eval child with new aliases for the current plan。
18301835
// How many children does a LogicalPlan have? Only deal with UnaryPlan and LookupJoin for now.
18311836
if (newAliases.isEmpty() == false) { // create a new eval child plan
1832-
if (p instanceof UnaryPlan u) { // unary plan
1833-
Eval eval = new Eval(u.source(), u.child(), newAliases.values().stream().toList());
1834-
p = u.replaceChild(eval);
1835-
}
1836-
// TODO Lookup join does not work on date_nanos field today, joining on a date_nanos field does not find a match.
1837+
UnaryPlan u = (UnaryPlan) p; // this must be a unary plan, as it is checked at the beginning of plan loop
1838+
Eval eval = new Eval(u.source(), u.child(), newAliases.values().stream().toList());
1839+
p = u.replaceChild(eval);
1840+
// TODO Lookup join does not work on date_nanos field yet, joining on a date_nanos field does not find a match.
18371841
// And lookup up join is a special case as a lookup join has two children, after date_nanos is supported as a join
18381842
// key, the transformation needs to take it into account.
18391843
}
@@ -1899,7 +1903,10 @@ private static Expression castInvalidMappedField(DataType targetType, FieldAttri
18991903
*/
19001904
private static Set<FieldAttribute> invalidMappedFieldsInLogicalPlan(LogicalPlan plan) {
19011905
Set<FieldAttribute> fas = new HashSet<>();
1902-
if (plan instanceof EsRelation == false) { // not all the union typed fields from indices need to be cast implicitly
1906+
// Invalid mapped fields are legal at EsRelation level, as long as they are not used elsewhere. In the final output, if they
1907+
// have not been dropped, implicit cast will be added for them, so that we can return not null values, the implicit casting is
1908+
// deferred to when the fields are used or returned.
1909+
if (plan instanceof EsRelation == false) {
19031910
plan.forEachExpression(FieldAttribute.class, fa -> {
19041911
if (fa.field() instanceof InvalidMappedField) {
19051912
fas.add(fa);
@@ -1939,7 +1946,8 @@ private static LogicalPlan castInvalidMappedFieldInFinalOutput(LogicalPlan logic
19391946
logicalPlan = unary.replaceChild(eval);
19401947
}
19411948
// TODO LookupJoin is a binary plan, it does not create a new field, ideally adding an Eval on top of it should be fine,
1942-
// however because the output of a LookupJoin does not include InvalidMappedFields, it is a bug need to be addressed
1949+
// however because the output of a LookupJoin does not include InvalidMappedFields even the LHS output has
1950+
// InvalidMappedFields, it is a bug need to be addressed
19431951
}
19441952
}
19451953
return logicalPlan;

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/Range.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.elasticsearch.xpack.esql.core.expression.Expression;
1515
import org.elasticsearch.xpack.esql.core.expression.FoldContext;
1616
import org.elasticsearch.xpack.esql.core.expression.Literal;
17+
import org.elasticsearch.xpack.esql.core.expression.TypedAttribute;
1718
import org.elasticsearch.xpack.esql.core.expression.function.scalar.ScalarFunction;
1819
import org.elasticsearch.xpack.esql.core.expression.predicate.operator.comparison.BinaryComparison;
1920
import org.elasticsearch.xpack.esql.core.querydsl.query.Query;
@@ -279,7 +280,13 @@ private RangeQuery translate(TranslatorHandler handler) {
279280
}
280281
}
281282
logger.trace("Building range query with format string [{}]", format);
282-
return new RangeQuery(source(), handler.nameOf(value), l, includeLower(), u, includeUpper(), format, zoneId);
283+
// This is a similar check as in EsqlBinaryComparison
284+
// Extract the real field name from MultiTypeEsField, and use it in the push down query if it is found
285+
TypedAttribute attribute = LucenePushdownPredicates.checkIsPushableAttribute(value);
286+
String name = handler.nameOf(attribute);
287+
String fieldNameFromMultiTypeEsField = LucenePushdownPredicates.extractFieldNameFromMultiTypeEsField(attribute);
288+
name = fieldNameFromMultiTypeEsField != null ? fieldNameFromMultiTypeEsField : name;
289+
return new RangeQuery(source(), name, l, includeLower(), u, includeUpper(), format, zoneId);
283290
}
284291

285292
@Override

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/EsqlBinaryComparison.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,9 @@ public Expression singleValueField() {
378378
private Query translate(TranslatorHandler handler) {
379379
TypedAttribute attribute = LucenePushdownPredicates.checkIsPushableAttribute(left());
380380
String name = handler.nameOf(attribute);
381+
// Extract the real field name from MultiTypeEsField, and use it in the push down query if it is found
382+
String fieldNameFromMultiTypeEsField = LucenePushdownPredicates.extractFieldNameFromMultiTypeEsField(attribute);
383+
name = fieldNameFromMultiTypeEsField != null ? fieldNameFromMultiTypeEsField : name;
381384
Object value = valueOf(FoldContext.small() /* TODO remove me */, right());
382385
String format = null;
383386
boolean isDateLiteralComparison = false;
@@ -452,7 +455,10 @@ private Query translate(TranslatorHandler handler) {
452455
return new RangeQuery(source(), name, null, false, value, true, format, zoneId);
453456
}
454457
if (this instanceof Equals || this instanceof NotEquals) {
455-
name = LucenePushdownPredicates.pushableAttributeName(attribute);
458+
// Extract the real field name from MultiTypeEsField, and use it in the push down query if it is found
459+
name = fieldNameFromMultiTypeEsField != null
460+
? fieldNameFromMultiTypeEsField
461+
: LucenePushdownPredicates.pushableAttributeName(attribute);
456462

457463
Query query;
458464
if (isDateLiteralComparison) {

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/LucenePushdownPredicates.java

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,22 @@
77

88
package org.elasticsearch.xpack.esql.optimizer.rules.physical.local;
99

10+
import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
1011
import org.elasticsearch.xpack.esql.core.expression.Expression;
1112
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
1213
import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute;
1314
import org.elasticsearch.xpack.esql.core.expression.TypedAttribute;
1415
import org.elasticsearch.xpack.esql.core.type.DataType;
16+
import org.elasticsearch.xpack.esql.core.type.MultiTypeEsField;
1517
import org.elasticsearch.xpack.esql.core.util.Check;
18+
import org.elasticsearch.xpack.esql.expression.function.scalar.convert.AbstractConvertFunction;
1619
import org.elasticsearch.xpack.esql.stats.SearchStats;
1720

21+
import java.util.Map;
22+
import java.util.function.Predicate;
23+
24+
import static org.elasticsearch.xpack.esql.core.type.DataType.DATE_NANOS;
25+
1826
/**
1927
* When deciding if a filter or topN can be pushed down to Lucene, we need to check a few things on the field.
2028
* Exactly what is checked depends on the type of field and the query. For example, we have the following possible combinations:
@@ -94,6 +102,46 @@ static String pushableAttributeName(TypedAttribute attribute) {
94102
: attribute.name();
95103
}
96104

105+
/**
106+
* Extract the real field name from a MultiTypeEsField, limit to MultiTypeEsField that has date_nanos type only.
107+
*
108+
* For example, the name of a MultiTypeEsField can be $$myfield$converted_to$date_nanos, and the real field name extract from the
109+
* MultiTypeEsField is myfield, this method return myfield given a MultiTypeEsField.
110+
*
111+
* If the real field name is found, and the original field data types contain only date and date_nanos types, return the real field
112+
* name, so that the real field name will be used to check for eligibility of being pushed down, and the real field name will be used
113+
* in the push down query, instead of the name of the MultiTypeEsField, which should not match any field in an index.
114+
*
115+
* This method can be extended to support the other data types in the future if there is a need.
116+
*/
117+
static String extractFieldNameFromMultiTypeEsField(TypedAttribute attribute) {
118+
if (EsqlCapabilities.Cap.IMPLICIT_CASTING_DATE_AND_DATE_NANOS.isEnabled()
119+
&& attribute instanceof FieldAttribute fa
120+
&& fa.field() instanceof MultiTypeEsField multiTypeEsField
121+
&& fa.dataType() == DATE_NANOS
122+
&& // limit to casting to date_nanos only
123+
mixedDateAndDateNanosOnly(multiTypeEsField, DataType::isMillisOrNanos) // limit to mixed date and date_nanos only
124+
) {
125+
return fa.fieldName();
126+
}
127+
return null;
128+
}
129+
130+
/**
131+
* Check if the original field types in a MultiTypeEsField satisfy the required data types defined in the predicate.
132+
*/
133+
private static boolean mixedDateAndDateNanosOnly(MultiTypeEsField multiTypeEsField, Predicate<DataType> predicate) {
134+
Map<String, Expression> indexToConversionExpressions = multiTypeEsField.getIndexToConversionExpressions();
135+
for (Map.Entry<String, Expression> entry : indexToConversionExpressions.entrySet()) {
136+
Expression conversionFunction = entry.getValue();
137+
if (conversionFunction instanceof AbstractConvertFunction abstractConvertFunction
138+
&& predicate.test(abstractConvertFunction.field().dataType()) == false) {
139+
return false;
140+
}
141+
}
142+
return true;
143+
}
144+
97145
/**
98146
* The default implementation of this has no access to SearchStats, so it can only make decisions based on the FieldAttribute itself.
99147
* In particular, it assumes TEXT fields have no exact subfields (underlying keyword field),
@@ -131,10 +179,14 @@ public boolean hasExactSubfield(FieldAttribute attr) {
131179

132180
@Override
133181
public boolean isIndexedAndHasDocValues(FieldAttribute attr) {
182+
// If this is a MultiTypeEsField cast to date_nanos, make it eligible for being pushed down by checking the real
183+
// field name against SearchStats
184+
String fieldNameFromMultiTypeEsField = LucenePushdownPredicates.extractFieldNameFromMultiTypeEsField(attr);
185+
String name = fieldNameFromMultiTypeEsField != null ? fieldNameFromMultiTypeEsField : attr.name();
134186
// We still consider the value of isAggregatable here, because some fields like ScriptFieldTypes are always aggregatable
135187
// But this could hide issues with fields that are not indexed but are aggregatable
136188
// This is the original behaviour for ES|QL, but is it correct?
137-
return attr.field().isAggregatable() || stats.isIndexed(attr.name()) && stats.hasDocValues(attr.name());
189+
return attr.field().isAggregatable() || stats.isIndexed(name) && stats.hasDocValues(name);
138190
}
139191

140192
@Override

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/Enrich.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
import org.elasticsearch.xpack.esql.core.expression.ReferenceAttribute;
3333
import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
3434
import org.elasticsearch.xpack.esql.core.tree.Source;
35-
import org.elasticsearch.xpack.esql.core.type.DataType;
3635
import org.elasticsearch.xpack.esql.index.EsIndex;
3736
import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
3837
import org.elasticsearch.xpack.esql.plan.GeneratingPlan;
@@ -209,7 +208,6 @@ public boolean expressionsResolved() {
209208
return policyName.resolved()
210209
&& matchField instanceof EmptyAttribute == false // matchField not defined in the query, needs to be resolved from the policy
211210
&& matchField.resolved()
212-
&& matchField.dataType() != DataType.UNSUPPORTED
213211
&& Resolvables.resolved(enrichFields());
214212
}
215213

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/Eval.java

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.elasticsearch.xpack.esql.capabilities.PostAnalysisVerificationAware;
1414
import org.elasticsearch.xpack.esql.capabilities.TelemetryAware;
1515
import org.elasticsearch.xpack.esql.common.Failures;
16+
import org.elasticsearch.xpack.esql.core.capabilities.Resolvables;
1617
import org.elasticsearch.xpack.esql.core.expression.Alias;
1718
import org.elasticsearch.xpack.esql.core.expression.Attribute;
1819
import org.elasticsearch.xpack.esql.core.expression.AttributeMap;
@@ -131,12 +132,7 @@ private List<Alias> renameAliases(List<Alias> originalAttributes, List<String> n
131132

132133
@Override
133134
public boolean expressionsResolved() {
134-
for (Alias a : fields) {
135-
if (a.resolved() == false || a.dataType() == DataType.UNSUPPORTED) {
136-
return false;
137-
}
138-
}
139-
return true;
135+
return Resolvables.resolved(fields);
140136
}
141137

142138
@Override

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/join/JoinConfig.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import org.elasticsearch.common.io.stream.Writeable;
1313
import org.elasticsearch.xpack.esql.core.capabilities.Resolvables;
1414
import org.elasticsearch.xpack.esql.core.expression.Attribute;
15-
import org.elasticsearch.xpack.esql.core.type.DataType;
1615

1716
import java.io.IOException;
1817
import java.util.List;
@@ -51,7 +50,6 @@ public boolean expressionsResolved() {
5150
return type.resolved()
5251
&& Resolvables.resolved(matchFields)
5352
&& Resolvables.resolved(leftFields)
54-
&& Resolvables.resolved(rightFields)
55-
&& leftFields.stream().noneMatch(e -> e.dataType() == DataType.UNSUPPORTED);
53+
&& Resolvables.resolved(rightFields);
5654
}
5755
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/TranslatorHandler.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
1616
import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute;
1717
import org.elasticsearch.xpack.esql.core.querydsl.query.Query;
18+
import org.elasticsearch.xpack.esql.optimizer.rules.physical.local.LucenePushdownPredicates;
1819
import org.elasticsearch.xpack.esql.querydsl.query.SingleValueQuery;
1920

2021
/**
@@ -41,7 +42,10 @@ public Query asQuery(Expression e) {
4142
private static Query wrapFunctionQuery(Expression field, Query query) {
4243
if (field instanceof FieldAttribute fa) {
4344
fa = fa.getExactInfo().hasExact() ? fa.exactAttribute() : fa;
44-
return new SingleValueQuery(query, fa.name());
45+
// Extract the real field name from MultiTypeEsField, and use it in the push down query if it is found
46+
String fieldNameFromMultiTypeEsField = LucenePushdownPredicates.extractFieldNameFromMultiTypeEsField(fa);
47+
String fieldName = fieldNameFromMultiTypeEsField != null ? fieldNameFromMultiTypeEsField : fa.name();
48+
return new SingleValueQuery(query, fieldName);
4549
}
4650
if (field instanceof MetadataAttribute) {
4751
return query; // MetadataAttributes are always single valued

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTestUtils.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import org.elasticsearch.inference.TaskType;
1212
import org.elasticsearch.xpack.core.enrich.EnrichPolicy;
1313
import org.elasticsearch.xpack.esql.EsqlTestUtils;
14+
import org.elasticsearch.xpack.esql.core.type.EsField;
15+
import org.elasticsearch.xpack.esql.core.type.InvalidMappedField;
1416
import org.elasticsearch.xpack.esql.enrich.ResolvedEnrichPolicy;
1517
import org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry;
1618
import org.elasticsearch.xpack.esql.index.EsIndex;
@@ -24,8 +26,10 @@
2426
import org.elasticsearch.xpack.esql.session.Configuration;
2527

2628
import java.util.ArrayList;
29+
import java.util.LinkedHashMap;
2730
import java.util.List;
2831
import java.util.Map;
32+
import java.util.Set;
2933

3034
import static org.elasticsearch.xpack.core.enrich.EnrichPolicy.GEO_MATCH_TYPE;
3135
import static org.elasticsearch.xpack.core.enrich.EnrichPolicy.MATCH_TYPE;
@@ -209,4 +213,26 @@ public static void loadEnrichPolicyResolution(EnrichResolution enrich, String po
209213
public static IndexResolution tsdbIndexResolution() {
210214
return loadMapping("tsdb-mapping.json", "test");
211215
}
216+
217+
public static IndexResolution indexWithDateDateNanosUnionType() {
218+
// this method is shared by AnalyzerTest, QueryTranslatorTests and LocalPhysicalPlanOptimizerTests
219+
String dateDateNanos = "date_and_date_nanos"; // mixed date and date_nanos
220+
String dateDateNanosLong = "date_and_date_nanos_and_long"; // mixed date, date_nanos and long
221+
LinkedHashMap<String, Set<String>> typesToIndices1 = new LinkedHashMap<>();
222+
typesToIndices1.put("date", Set.of("index1", "index2"));
223+
typesToIndices1.put("date_nanos", Set.of("index3"));
224+
LinkedHashMap<String, Set<String>> typesToIndices2 = new LinkedHashMap<>();
225+
typesToIndices2.put("date", Set.of("index1"));
226+
typesToIndices2.put("date_nanos", Set.of("index2"));
227+
typesToIndices2.put("long", Set.of("index3"));
228+
EsField dateDateNanosField = new InvalidMappedField(dateDateNanos, typesToIndices1);
229+
EsField dateDateNanosLongField = new InvalidMappedField(dateDateNanosLong, typesToIndices2);
230+
EsIndex index = new EsIndex(
231+
"test*",
232+
Map.of(dateDateNanos, dateDateNanosField, dateDateNanosLong, dateDateNanosLongField),
233+
Map.of("index1", IndexMode.STANDARD, "index2", IndexMode.STANDARD, "index3", IndexMode.STANDARD)
234+
);
235+
return IndexResolution.valid(index);
236+
237+
}
212238
}

0 commit comments

Comments
 (0)