Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e5e330c
Propagate synthetic attributes directly through projections
MattAlp Nov 10, 2025
e60ab67
WIP
MattAlp Nov 10, 2025
0b19ab8
Copy/paste error nit
MattAlp Nov 10, 2025
b8e36c2
Catch other copy/paste issue
MattAlp Nov 11, 2025
5dcbf67
WIP projection test
MattAlp Nov 11, 2025
72d52a0
Rewrite synthetic attribute projection test
MattAlp Nov 11, 2025
bc7d1c6
Update docs/changelog/137923.yaml
MattAlp Nov 11, 2025
f95727e
Merge branch 'main' into propagate-converted-fields-thru-projections
MattAlp Nov 11, 2025
9fe0e66
Update x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/…
MattAlp Dec 22, 2025
b54e75b
Update x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/…
MattAlp Dec 22, 2025
d54be9d
Update analyzer tests to clean up projection of unsupported expressio…
MattAlp Dec 22, 2025
f48470a
Added testUnionTypesResolvePastProjections to logical plan optimizer …
MattAlp Dec 22, 2025
95af4aa
Create CSV tests for union type casting thru project
MattAlp Dec 22, 2025
d36d60a
Correct the retention & rejection of synthetic & unresolved attributes
MattAlp Dec 23, 2025
f0169ec
Add tests for retention of unsupported & simultaneously-cast fields
MattAlp Dec 23, 2025
44c323f
Merge remote-tracking branch 'origin/main' into propagate-converted-f…
MattAlp Dec 23, 2025
ba72a11
Update EsIndex constructors and parser method after merge
MattAlp Dec 23, 2025
c6b2a00
Test-generated diff for inline cast
MattAlp Dec 23, 2025
47cc7f8
Merge branch 'main' into propagate-converted-fields-thru-projections
MattAlp Jan 10, 2026
99fb27a
Merge branch 'main' into propagate-converted-fields-thru-projections
MattAlp Jan 14, 2026
36154ca
Make Project transformation conditional within ResolveUnionTypes
MattAlp Jan 15, 2026
3f41fbd
Merge branch 'main' into propagate-converted-fields-thru-projections
MattAlp Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/changelog/137923.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 137923
summary: Propagate converted fields through projections
area: ES|QL
type: bug
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -2548,3 +2548,25 @@ true | 1990-08-01T00:00:00.000Z
true | 1990-09-01T00:00:00.000Z
true | 1990-12-01T00:00:00.000Z
;

unionTypesResolvePastProjections
required_capability: union_types
required_capability: casting_operator
required_capability: union_types_resolve_past_projections

FROM apps, apps_short
| KEEP id, name
| MV_EXPAND name
| EVAL id = id::integer
| KEEP id, name
| SORT id ASC, name ASC
| LIMIT 5
;

id:integer | name:keyword
1 | aaaaa
1 | aaaaa
2 | bbbbb
2 | bbbbb
3 | ccccc
;
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,13 @@ public enum Cap {
*/
UNION_TYPES_NUMERIC_WIDENING,

/**
* Fix for resolving union type casts past projections (KEEP) and MV_EXPAND operations.
* Ensures that casting a union type field works correctly when the field has been projected
* and expanded through MV_EXPAND. See #137923
*/
UNION_TYPES_RESOLVE_PAST_PROJECTIONS,

/**
* Fix a parsing issue where numbers below Long.MIN_VALUE threw an exception instead of parsing as doubles.
* see <a href="https://github.com/elastic/elasticsearch/issues/104323"> Parsing large numbers is inconsistent #104323 </a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2109,7 +2109,7 @@ private LogicalPlan doRule(LogicalPlan plan, List<Attribute.IdIgnoringWrapper> u
* and thereby get used in FieldExtractExec
*/
private static LogicalPlan addGeneratedFieldsToEsRelations(LogicalPlan plan, List<FieldAttribute> unionFieldAttributes) {
return plan.transformDown(EsRelation.class, esr -> {
var res = plan.transformDown(EsRelation.class, esr -> {
List<Attribute> missing = new ArrayList<>();
for (FieldAttribute fa : unionFieldAttributes) {
// Using outputSet().contains looks by NameId, resp. uses semanticEquals.
Expand All @@ -2123,6 +2123,25 @@ private static LogicalPlan addGeneratedFieldsToEsRelations(LogicalPlan plan, Lis
}
return esr;
});
if (res.equals(plan) == false) {
res = res.transformUp(Project.class, p -> {
List<Attribute> syntheticAttributesToCarryOver = new ArrayList<>();
for (Attribute attr : p.inputSet()) {
if (attr.synthetic() && p.outputSet().contains(attr) == false) {
syntheticAttributesToCarryOver.add(attr);
}
}

if (syntheticAttributesToCarryOver.isEmpty()) {
return p;
}

List<NamedExpression> newProjections = new ArrayList<>(p.projections());
newProjections.addAll(syntheticAttributesToCarryOver);
return new Project(p.source(), p.child(), newProjections);
});
}
return res;
}

private Expression resolveConvertFunction(ConvertFunction convert, List<Attribute.IdIgnoringWrapper> unionFieldAttributes) {
Expand Down Expand Up @@ -2286,16 +2305,17 @@ private static Expression typeSpecificConvert(ConvertFunction convert, Source so
*/
private static class UnionTypesCleanup extends Rule<LogicalPlan, LogicalPlan> {
public LogicalPlan apply(LogicalPlan plan) {
LogicalPlan planWithCheckedUnionTypes = plan.transformUp(

// We start by dropping synthetic attributes if the plan is resolved
LogicalPlan cleanPlan = plan.resolved() ? planWithoutSyntheticAttributes(plan) : plan;

// If not, we apply checkUnresolved to the field attributes of the original plan, resulting in unsupported attributes
// This removes attributes such as converted types if they are aliased, but retains them otherwise, while also guaranteeing that
// unsupported / unresolved fields can be explicitly retained
return cleanPlan.transformUp(
LogicalPlan.class,
p -> p.transformExpressionsOnly(FieldAttribute.class, UnionTypesCleanup::checkUnresolved)
);

// To drop synthetic attributes at the end, we need to compute the plan's output.
// This is only legal to do if the plan is resolved.
return planWithCheckedUnionTypes.resolved()
? planWithoutSyntheticAttributes(planWithCheckedUnionTypes)
: planWithCheckedUnionTypes;
}

static Attribute checkUnresolved(FieldAttribute fa) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
import java.nio.charset.StandardCharsets;
import java.time.Period;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -4510,6 +4511,107 @@ public void testBucketWithIntervalInStringInGroupingReferencedInAggregation() {
assertEquals(oneYear, literal);
}

public void testProjectionForUnionTypeResolution() {
LinkedHashMap<String, Set<String>> typesToIndices = new LinkedHashMap<>();
typesToIndices.put("keyword", Set.of("union_index_1"));
typesToIndices.put("integer", Set.of("union_index_2"));

EsField idField = new InvalidMappedField("id", typesToIndices);
EsField fooField = new EsField("foo", DataType.KEYWORD, Map.of(), true, EsField.TimeSeriesFieldType.NONE);

EsIndex index = new EsIndex(
"union_index*",
Map.of("id", idField, "foo", fooField), // Updated mapping keys
Map.of("union_index_1", IndexMode.STANDARD, "union_index_2", IndexMode.STANDARD),
Map.of(),
Map.of(),
Set.of()
);
IndexResolution resolution = IndexResolution.valid(index);
Analyzer analyzer = analyzer(resolution);

String query = "FROM union_index* | KEEP id, foo | MV_EXPAND foo | EVAL id = id::keyword";
LogicalPlan plan = analyze(query, analyzer);

Project project = as(plan, Project.class);
Eval eval = as(project.child().children().getFirst(), Eval.class);
FieldAttribute convertedFa = as(eval.output().get(1), FieldAttribute.class);

// The synthetic field used for the conversion should be propagated through intermediate nodes (like MV_EXPAND) but ultimately
// stripped from the final output, leaving only the aliased 'id' and 'foo'.
verifyNameAndType(convertedFa.name(), convertedFa.dataType(), "$$id$converted_to$keyword", KEYWORD);

eval.forEachDown(Project.class, p -> {
if (p.inputSet().contains(convertedFa)) {
assertTrue(p.outputSet().contains(convertedFa));
}
});

var output = plan.output();
assertThat(output, hasSize(2));

var fooAttr = output.getFirst();
var idAttr = output.getLast();
assertThat(fooAttr.dataType(), equalTo(KEYWORD));
assertThat(idAttr.dataType(), equalTo(KEYWORD));
assertThat(idAttr.name(), equalTo("id"));
}

public void testExplicitRetainOriginalFieldWithCast() {
// Use the existing union index fixture (id has keyword/integer union types)
LinkedHashMap<String, Set<String>> typesToIndices = new LinkedHashMap<>();
typesToIndices.put("keyword", Set.of("test1"));
typesToIndices.put("integer", Set.of("test2"));
EsField idField = new InvalidMappedField("id", typesToIndices);
EsIndex index = new EsIndex(
"union_index*",
Map.of("id", idField),
Map.of("test1", IndexMode.STANDARD, "test2", IndexMode.STANDARD),
Map.of(),
Map.of(),
Set.of()
);
IndexResolution resolution = IndexResolution.valid(index);
Analyzer analyzer = analyzer(resolution);

String query = """
FROM union_index*
| KEEP id
| EVAL x = id::long
""";
LogicalPlan plan = analyze(query, analyzer);

Project topProject = as(plan, Project.class);
var projections = topProject.projections();
assertThat(projections, hasSize(2));
assertThat(projections.get(0).name(), equalTo("id"));
assertThat(projections.get(0).dataType(), equalTo(UNSUPPORTED));

ReferenceAttribute xRef = as(projections.get(1), ReferenceAttribute.class);
assertThat(xRef.name(), equalTo("x"));
assertThat(xRef.dataType(), equalTo(LONG));

Limit limit = as(topProject.child(), Limit.class);
Eval eval = as(limit.child(), Eval.class);
Alias xAlias = as(eval.fields().get(0), Alias.class);
assertThat(xAlias.name(), equalTo("x"));
FieldAttribute syntheticFieldAttr = as(xAlias.child(), FieldAttribute.class);
assertThat(syntheticFieldAttr.name(), equalTo("$$id$converted_to$long"));
assertThat(xRef, is(xAlias.toAttribute()));

Project innerProject = as(eval.child(), Project.class);
EsRelation relation = as(innerProject.child(), EsRelation.class);
assertEquals("union_index*", relation.indexPattern());
var relationOutput = relation.output();
assertThat(relationOutput, hasSize(2));
assertThat(relationOutput.get(0).name(), equalTo("id"));
assertThat(relationOutput.get(0).dataType(), equalTo(UNSUPPORTED));
var syntheticField = relationOutput.get(1);
assertThat(syntheticField.name(), equalTo("$$id$converted_to$long"));
assertThat(syntheticField.dataType(), equalTo(LONG));
assertThat(syntheticFieldAttr.id(), equalTo(syntheticField.id()));
}

public void testImplicitCastingForDateAndDateNanosFields() {
IndexResolution indexWithUnionTypedFields = indexWithDateDateNanosUnionType();
Analyzer analyzer = AnalyzerTestUtils.analyzer(indexWithUnionTypedFields);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.elasticsearch.xpack.esql.analysis.EnrichResolution;
import org.elasticsearch.xpack.esql.analysis.MutableAnalyzerContext;
import org.elasticsearch.xpack.esql.core.type.EsField;
import org.elasticsearch.xpack.esql.core.type.InvalidMappedField;
import org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry;
import org.elasticsearch.xpack.esql.index.EsIndex;
import org.elasticsearch.xpack.esql.index.EsIndexGenerator;
Expand All @@ -26,6 +27,7 @@
import org.junit.BeforeClass;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -64,6 +66,7 @@ public abstract class AbstractLogicalPlanOptimizerTests extends ESTestCase {
protected static Map<String, EsField> metricMapping;
protected static Analyzer metricsAnalyzer;
protected static Analyzer multiIndexAnalyzer;
protected static Analyzer unionIndexAnalyzer;
protected static Analyzer sampleDataIndexAnalyzer;
protected static Analyzer subqueryAnalyzer;
protected static Map<String, EsField> mappingBaseConversion;
Expand Down Expand Up @@ -217,6 +220,32 @@ public static void init() {
TEST_VERIFIER
);

// Create a union index with conflicting types (keyword vs integer) for field 'id'
LinkedHashMap<String, Set<String>> typesToIndices = new LinkedHashMap<>();
typesToIndices.put("keyword", Set.of("test1"));
typesToIndices.put("integer", Set.of("test2"));
EsField idField = new InvalidMappedField("id", typesToIndices);
EsField fooField = new EsField("foo", KEYWORD, emptyMap(), true, EsField.TimeSeriesFieldType.NONE);
var unionIndex = new EsIndex(
"union_index*",
Map.of("id", idField, "foo", fooField),
Map.of("test1", IndexMode.STANDARD, "test2", IndexMode.STANDARD),
Map.of(),
Map.of(),
Set.of()
);
unionIndexAnalyzer = new Analyzer(
testAnalyzerContext(
EsqlTestUtils.TEST_CFG,
new EsqlFunctionRegistry(),
indexResolutions(unionIndex),
defaultLookupResolution(),
enrichResolution,
emptyInferenceResolution()
),
TEST_VERIFIER
);

var sampleDataMapping = loadMapping("mapping-sample_data.json");
var sampleDataIndex = new EsIndex(
"sample_data",
Expand Down Expand Up @@ -313,6 +342,10 @@ protected LogicalPlan planMultiIndex(String query) {
return logicalOptimizer.optimize(multiIndexAnalyzer.analyze(parser.parseQuery(query)));
}

protected LogicalPlan planUnionIndex(String query) {
return logicalOptimizer.optimize(unionIndexAnalyzer.analyze(parser.parseQuery(query)));
}

protected LogicalPlan planSample(String query) {
var analyzed = sampleDataIndexAnalyzer.analyze(parser.parseQuery(query));
return logicalOptimizer.optimize(analyzed);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@
import static org.elasticsearch.xpack.esql.core.type.DataType.GEO_POINT;
import static org.elasticsearch.xpack.esql.core.type.DataType.INTEGER;
import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD;
import static org.elasticsearch.xpack.esql.core.type.DataType.LONG;
import static org.elasticsearch.xpack.esql.core.type.DataType.TEXT;
import static org.elasticsearch.xpack.esql.core.type.DataType.UNSUPPORTED;
import static org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison.BinaryComparisonOperation.EQ;
import static org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison.BinaryComparisonOperation.GT;
import static org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison.BinaryComparisonOperation.GTE;
Expand Down Expand Up @@ -9855,6 +9857,97 @@ public void testFullTextFunctionOnEvalNull() {
assertEquals("test", relation.indexPattern());
}

/**
* Project[[foo{r}#12, $$id$converted_to$keyword{f$}#13 AS id#9]]
* \_Limit[1000[INTEGER],true,false]
* \_MvExpand[foo{f}#11,foo{r}#12]
* \_Project[[!id, foo{f}#11, $$id$converted_to$keyword{f$}#13]]
* \_Limit[1000[INTEGER],false,false]
* \_EsRelation[union_index*][foo{f}#11, !id, $$id$converted_to$keyword{f$}#13]
*/
public void testUnionTypesResolvePastProjections() {
LogicalPlan plan = planUnionIndex("""
FROM union_index*
| KEEP id, foo
| MV_EXPAND foo
| EVAL id = id::keyword
""");

Project topProject = as(plan, Project.class);
var topOutput = topProject.output();
assertThat(topOutput, hasSize(2));

var idAttr = topOutput.get(1);
assertThat(idAttr.name(), equalTo("id"));

// The id attribute should be a ReferenceAttribute that references the converted field
ReferenceAttribute idRef = as(idAttr, ReferenceAttribute.class);
assertThat(idRef.dataType(), equalTo(KEYWORD));

Limit limit1 = asLimit(topProject.child(), 1000, true);
MvExpand mvExpand = as(limit1.child(), MvExpand.class);

Project innerProject = as(mvExpand.child(), Project.class);
var innerOutput = innerProject.output();
assertThat(innerOutput, hasSize(3));
assertThat(Expressions.names(innerOutput), containsInAnyOrder("id", "foo", "$$id$converted_to$keyword"));

Limit limit2 = asLimit(innerProject.child(), 1000, false);
EsRelation relation = as(limit2.child(), EsRelation.class);
assertEquals("union_index*", relation.indexPattern());

var relationOutput = relation.output();
assertThat(relationOutput, hasSize(3));

assertThat(relationOutput.get(0).name(), equalTo("foo"));
assertThat(relationOutput.get(0).dataType(), equalTo(KEYWORD));

assertThat(relationOutput.get(1).name(), equalTo("id"));
assertThat(relationOutput.get(1).dataType(), equalTo(UNSUPPORTED));

assertThat(relationOutput.get(2).name(), equalTo("$$id$converted_to$keyword"));
assertThat(relationOutput.get(2).dataType(), equalTo(KEYWORD));
}

/**
* Project[[!id, $$id$converted_to$long{f$}#9 AS x#6]]
* \_Limit[1000[INTEGER],false,false]
* \_EsRelation[union_index*][foo{f}#7, !id, $$id$converted_to$long{f$}#9]
*/
public void testExplicitRetainOriginalFieldWithCast() {
LogicalPlan plan = planUnionIndex("""
FROM union_index*
| KEEP id
| EVAL x = id::long
""");

Project topProject = as(plan, Project.class);
var projections = topProject.projections();
assertThat(projections, hasSize(2));
assertThat(projections.get(0).name(), equalTo("id"));
assertThat(projections.get(0).dataType(), equalTo(UNSUPPORTED));

Alias xAlias = as(projections.get(1), Alias.class);
assertThat(xAlias.name(), equalTo("x"));
assertThat(xAlias.dataType(), equalTo(LONG));
FieldAttribute syntheticFieldAttr = as(xAlias.child(), FieldAttribute.class);
assertThat(syntheticFieldAttr.name(), equalTo("$$id$converted_to$long"));
ReferenceAttribute xRef = as(topProject.output().get(1), ReferenceAttribute.class);
assertThat(xRef, is(xAlias.toAttribute()));

Limit limit = asLimit(topProject.child(), 1000, false);
EsRelation relation = as(limit.child(), EsRelation.class);
assertEquals("union_index*", relation.indexPattern());
var relationOutput = relation.output();
assertThat(relationOutput, hasSize(3));
assertThat(relationOutput.get(1).name(), equalTo("id"));
assertThat(relationOutput.get(1).dataType(), equalTo(UNSUPPORTED));
var syntheticField = relationOutput.get(2);
assertThat(syntheticField.name(), equalTo("$$id$converted_to$long"));
assertThat(syntheticField.dataType(), equalTo(LONG));
assertThat(syntheticFieldAttr.id(), equalTo(syntheticField.id()));
}

/*
* Renaming or shadowing the @timestamp field prior to running stats with TS command is not allowed.
*/
Expand Down