Skip to content

ESQL: Fix injected attributes's IDs in UnionAll branches#141262

Merged
bpintea merged 6 commits intoelastic:mainfrom
bpintea:fix/unmapped_unique_id_on_branches
Jan 29, 2026
Merged

ESQL: Fix injected attributes's IDs in UnionAll branches#141262
bpintea merged 6 commits intoelastic:mainfrom
bpintea:fix/unmapped_unique_id_on_branches

Conversation

@bpintea
Copy link
Contributor

@bpintea bpintea commented Jan 26, 2026

This fixes the generation of name IDs for the attributes corresponding to the unmapped fields and are pushed to different branches in UntionAll.

So far, one set of IDs was generated and reused for all subplans. This is now updated to individual set per subplan. Along the change, the handling of Fork in ResolveUnmapped has been somewhat simplified.

Also, more unit tests have been completed (where the plans are simple enough) and the plan comments updated to replace the EsqlProject with the now merged Project.

A minor collateral proposed change: the CSV spec-based tests skipped due to missing capabilities are now logged.

This fixes the generation of name IDs for the attributes corresponding
to the unmapped fields and are pushed to different branches in UntionAll.

So far, one set of IDs was generated and reused for all subplans. This
is now updated to own set per subplan.

A minor collateral proposed change: the CSV spec-based tests skipped due
to missing capabilities are now logged.
@bpintea bpintea added >bug auto-backport Automatically create backport pull requests when merged :Analytics/ES|QL AKA ESQL v9.3.1 v9.4.0 labels Jan 26, 2026
@elasticsearchmachine elasticsearchmachine added the Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) label Jan 26, 2026
@elasticsearchmachine
Copy link
Collaborator

Pinging @elastic/es-analytical-engine (Team:Analytics)

@elasticsearchmachine
Copy link
Collaborator

Hi @bpintea, I've created a changelog YAML for you.

}

private static List<FieldAttribute> fieldsToLoad(List<UnresolvedAttribute> unresolved, Set<String> exclude) {
private static List<FieldAttribute> fieldsToLoad(Set<UnresolvedAttribute> unresolved, List<String> exclude) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this a mistake? I would have expected the List to be the iteratee, and the Set to be the one we check contains on, but it seems to be the other way around.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a Set because the initial collection of UnresolvedAttributes is dedup'd -- this is what unresolvedLinkedSet() produces (// Some plans may reference the same UA multiple times (Aggregate groupings in aggregates, Eval): dedupe)
It's a List because that's what EsRelation#output (and then Expressions#names` produces.

What we want here is to exclude those attributes produces by the EsRelation itself into which we would then later inject/insist the extractors.

Not sure if it's worth instantiating new collection types to wrap the existing ones.

*/
private static Fork patchFork(Fork fork) {
List<LogicalPlan> newChildren = new ArrayList<>(fork.children().size());
Holder<Boolean> changed = new Holder<>(false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a comment explaining the different between changed and patched, since those names are too similar (or rename them so it's more obvious).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed changed to childrenChanged

unresolved.forEach(u -> aliasesMap.computeIfAbsent(u.name(), k -> nullAlias(u)));
return new ArrayList<>(aliasesMap.values());
private static List<Alias> nullAliases(Set<UnresolvedAttribute> unresolved) {
List<Alias> aliases = new ArrayList<>(unresolved.size());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use addAll (Or even just a basic map for that matter)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand how would addAll help. A map could, but I find the streams too heavy for a relatively simple iteration. But let me know if I misunderstood your suggestion.

@@ -63,7 +63,10 @@
import static org.hamcrest.Matchers.hasItems;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Newly merged golden tests? 😁

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. I'll extend in a subsequent PR, since this isn't going to be the only one.

* are tested in integration tests.
*/
assumeFalse(
assumeFalseLogging(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Since you're already touching this, perhaps change the logging from "X is not supported" to "capability" is not enabled?
  2. And if you do, you can just define a single nice helper function to check if a capability is enabled!

Copy link
Contributor Author

@bpintea bpintea Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Not sure, I personally find the existing "CSV tests cannot currently..." or "... in csv tests" messages better, tbh, since it's not about a capability not being enabled (it actually is enabled, and that's the "problem"), but the CSV testing infrastructure not being developed enough to support a new feature, right? But will let the other chime in as well.

if (descendantOutputsAttribute(project, attribute) == false) {
nullAliases.add(nullAlias(attribute));
}
private static Project patchForkProject(Project project, Holder<Boolean> changed) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Please add a comment explaining what this method does.
  2. Why do you need a holden here? Can't you just check in the parent if the reference has changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I've added a comment.
  2. Thanks, fixed (got like that through iterations).

if (projectOutput.equals(childOutput) == false) {
List<Attribute> delta = new ArrayList<>(childOutput);
delta.removeAll(projectOutput);
project = project.withProjections(mergeOutputAttributes(delta, projectOutput));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we please avoid this pattern of renaming the input parameter and then returning it outside the block? Just use an early exit above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pre-existing pattern. Some folks find it easier to read code with fewer returns. (Myself, I don't necessarily, but I don't mind this style either).

returning it outside the block

...reason being: if the control hasn't visited the block, the input is simply returned with no change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally find it unfathomable that it's harder to read code with more early exits than it is to read code with more changes if the input variable, but to each their own I guess 🥲.

}

// Some plans may reference the same UA multiple times (Aggregate groupings in aggregates, Eval): dedupe
private static Set<UnresolvedAttribute> unresolvedLinkedSet(List<UnresolvedAttribute> unresolved) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the return type here should be LinkedHashSet or at least SequencedSet, given the method name. If you opt for the latter, then consider also renaming this method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the return type here should be LinkedHashSet

Updated.

Copy link
Contributor

@astefan astefan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with two questions.

Comment on lines +170 to +172
var projectOutput = project.output();
var childOutput = project.child().output();
if (projectOutput.equals(childOutput) == false) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. for a reviewer would have been easier to assess if this equals could be tricky or not by seeing the actual type of the .output(). In IDE this type is List.
  2. is there a scenario where this equals is missed because the same elements exists in both lists but in different order?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I've updated the declarations.
  2. There could be, yes. But in this case the delta list in the branch will be empty. The project will still be recreated, but the resulting instance will be equal to the previous one and the operation will eventually either leave the plan unchanged or changed, but due to other modifications. In any case, there should be a guard against that empty delta list, to avoid creating a new necessary intense equal to the previous one -- thanks.

Since these changes aren't functionally impacting, I'd apply them to a follow-up PR (unless other changes will be required), if ok with you?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's ok. Thanks.

Comment on lines +2735 to +2736
Set<AbstractConvertFunction> converts = oldOutputToConvertFunctions.get(oldAttr.name());
if (converts != null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not the contains and .get approach? I see the code that reaches this part is fairly safe to assume that there won't be any null sets returned for a key, but we are not sure how this code will evolve in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't strictly related, but I thought I might need an update of this code and spotted the pattern.
There's nothing wrong from the functional PoV, but the code, as it was, checks if the key is in the map, then does it again, but fetching the corresponding value. The code as is in the proposed change only does the latter. If the result/value is null, the key isn't in there. (Well, I guess it could [have] be[en] a null value, to be exact, but that would have resulted in a NPE by now(?))

convert.replaceChildren(Collections.singletonList(oldAttr))
convert.replaceChildren(Collections.singletonList(oldAttr)),
null, // generate a new id
true // this'll be used to Project the synthetic attributes out when finishing analysis
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines -282 to +305
| KEEP emp_*
| KEEP emp_no, *
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now testing something else, but the original test was a valid case. Do we want to add the original one back?

* null[INTEGER] AS salary#35]]
* \_Subquery[]
* \_Filter[TOLONG(does_not_exist1{r}#20) > 1[INTEGER]]
* \_Eval[[null[NULL] AS does_not_exist1#20, null[NULL] AS does_not_exist2#51]]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment reflects that we now don't re-use name ids from subquery branches; it's asserted in the test above, but not here - shouldn't we also assert the name ids for does_not_exist2 in this case?

* \_Aggregate[[],[COUNT(*[KEYWORD],true[BOOLEAN],PT0S[TIME_DURATION]) AS c#4]]
* \_Eval[[null[NULL] AS does_not_exist#53]]
* \_EsRelation[employees][_meta_field{f}#24, emp_no{f}#18, first_name{f}#19, .
* \_Eval[[null[NULL] AS does_not_exist#54]]
Copy link
Contributor

@alex-spies alex-spies Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of those funny cases where we might wrongly break a plan by adding this EVAL does_not_exist = null here - the field might actually exist and the downstream STATS might actually be computing COUNT(does_not_exist) (even though the field does_not_exist is not in the output of this subquery).

Added to #138888

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding in #141340 (removeShadowing). This has been implemented for two out of three cases (the "load" case and adding the null-aliasing to an existing Eval, but was missing when adding a new Eval on top of a source).
Note however that this effort is mostly for producing a correct, but otherwise later still failing plan: if the null-aliasing is injected below a STATS that doesn't export the attribute (which is why the null-aliasing is done in the first place), the attribute will remain missing and the verification later will fail the query.
This is what the initially introduced testFailStatsThenKeep or testFailStatsThenEval test.

Copy link
Contributor

@alex-spies alex-spies left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks Bogdan. Only minor remarks, which can be addressed later. I tracked one of them already in the meta issue #138888.

Also, this gets rid of the refresh mechanism, right? Can we check off the corresponding to-do item on #138888?

Comment on lines -2073 to -2081
* \_Eval[[TOLONG(does_not_exist2{r}#74) AS $$does_not_exist2$converted_to$long#78]]
* \_Eval[[TOLONG(does_not_exist1{r}#26) AS $$does_not_exist1$converted_to$long#69]]
* \_Eval[[null[KEYWORD] AS _meta_field#42, null[INTEGER] AS emp_no#43, null[KEYWORD] AS first_name#44,
* null[TEXT] AS gender#45, null[DATETIME] AS hire_date#46, null[TEXT] AS job#47, null[KEYWORD] AS job.raw#48,
* null[INTEGER] AS languages#49, null[KEYWORD] AS last_name#50, null[LONG] AS long_noidx#51,
* null[INTEGER] AS salary#52]]
* \_Subquery[]
* \_Filter[TOLONG(does_not_exist1{r}#26) > 2[INTEGER]]
* \_Eval[[null[NULL] AS does_not_exist1#26, null[NULL] AS does_not_exist2#71]]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This subquery branch used to be inconsistent: does_not_exist2 is added with name id 71 after the esrelation, but it's used to define $$does_not_exist2$converted_to$long later while being referenced under id 74.

We're not currently asserting correct name ids. Maybe we can borrow the dependency checker for this? (It might also become a part of the analyzer/verifier pipeline as part of #137362, but we don't have this yet)

var unresolvedLinkedSet = unresolvedLinkedSet(unresolved);

var transformed = load ? load(plan, unresolved) : nullify(plan, unresolved);
var transformed = load ? load(plan, unresolvedLinkedSet) : nullify(plan, unresolvedLinkedSet);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the unresolveds being in a linked (thus order-preserving) set is important, should the signature of load and nullify require a LinkedHashSet rather than a Set? We shouldn't be implicitly relying on a linked set if the compiler can guarantee this for us.

Map<String, Alias> aliasesMap = new LinkedHashMap<>(unresolved.size());
unresolved.forEach(u -> aliasesMap.computeIfAbsent(u.name(), k -> nullAlias(u)));
return new ArrayList<>(aliasesMap.values());
private static List<Alias> nullAliases(Set<UnresolvedAttribute> unresolved) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: If the output list is supposed to be stable, I think we should explicitly require a LinkedHashSet.

@bpintea
Copy link
Contributor Author

bpintea commented Jan 29, 2026

Only minor remarks, which can be addressed later.

@alex-spies will address the points in the next PR (along with the ones Andrei raised). BTW, will follow on some of the points left on the closed PR too.
Just to avoid one more CI cycle.

Gal, Andrei, Alex, thanks for the reviews and pointers!

@bpintea bpintea merged commit 8e3113c into elastic:main Jan 29, 2026
36 checks passed
@bpintea bpintea deleted the fix/unmapped_unique_id_on_branches branch January 29, 2026 15:54
@elasticsearchmachine
Copy link
Collaborator

💔 Backport failed

Status Branch Result
9.3 Commit could not be cherrypicked due to conflicts

You can use sqren/backport to manually backport by running backport --upstream elastic/elasticsearch --pr 141262


private static void checkMissingFork(QueryPlan<?> plan, Failures failures) {
for (QueryPlan<?> child : plan.children()) {
// TODO: this checks the set-semantics, but not the ordering
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ioanatia do we want a simple iteration on a subplan's output to check that at same position there's an equality of name and type with that in fork's output? Can there be a set-equality, but not same order?

@alex-spies
Copy link
Contributor

💚 All backports created successfully

Status Branch Result
9.3

Questions ?

Please refer to the Backport tool documentation

alex-spies pushed a commit to alex-spies/elasticsearch that referenced this pull request Feb 2, 2026
)

This fixes the generation of name IDs for the attributes corresponding to the unmapped fields and are pushed to different branches in `UntionAll`.

So far, one set of IDs was generated and reused for all subplans. This is now updated to individual set per subplan. Along the change, the handling of `Fork` in `ResolveUnmapped` has been somewhat simplified.

Also, more unit tests have been completed (where the plans are simple enough) and the plan comments updated to replace the `EsqlProject` with the now merged `Project`.

A minor collateral proposed change: the CSV spec-based tests skipped due to missing capabilities are now logged.

(cherry picked from commit 8e3113c)
elasticsearchmachine pushed a commit that referenced this pull request Feb 2, 2026
…) (#141675)

* ESQL: Fix injected attributes's IDs in UnionAll branches (#141262)

This fixes the generation of name IDs for the attributes corresponding to the unmapped fields and are pushed to different branches in `UntionAll`.

So far, one set of IDs was generated and reused for all subplans. This is now updated to individual set per subplan. Along the change, the handling of `Fork` in `ResolveUnmapped` has been somewhat simplified.

Also, more unit tests have been completed (where the plans are simple enough) and the plan comments updated to replace the `EsqlProject` with the now merged `Project`.

A minor collateral proposed change: the CSV spec-based tests skipped due to missing capabilities are now logged.

(cherry picked from commit 8e3113c)

* Fix tests

9.3 does not have #139058, so the implicit limits at the top of subquery
  branches are still in place. Adjust the expectations accordingly.

* Checkstyle

---------

Co-authored-by: Bogdan Pintea <bogdan.pintea@elastic.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:Analytics/ES|QL AKA ESQL auto-backport Automatically create backport pull requests when merged backport pending >bug Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) v9.3.1 v9.4.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants