Skip to content

ES|QL: Fix KQL/QSTR with unmapped fields in NULLIFY mode#143399

Merged
luigidellaquila merged 13 commits intoelastic:mainfrom
luigidellaquila:esql/fix_kql_nullify
Mar 9, 2026
Merged

ES|QL: Fix KQL/QSTR with unmapped fields in NULLIFY mode#143399
luigidellaquila merged 13 commits intoelastic:mainfrom
luigidellaquila:esql/fix_kql_nullify

Conversation

@luigidellaquila
Copy link
Copy Markdown
Member

@luigidellaquila luigidellaquila commented Mar 2, 2026

Fixing full text functions (KQL, QSTR) usage in queries with unmapped_fields=NULLIFY.

When using SET unmapped_fields="nullify", KQL and QSTR functions would fail validation due toEval nodes added right after EsRelation.

Here we propose a different approach: instead of adding an EVAL, we add unmapped fields directly to EsRelation's output with DataType.NULL.
ReplaceFieldWithConstantOrNull (local planner) rule will replace them with null literals anyway.

This should be fine, this is the same thing we do with non-existing fields locally (with index patterns).
And it's conceptually the same thing we do with LOAD strategy.

For non-FROM queries (eg. ROW) we still fall back to adding an EVAL.

I also evaluated two other solutions, but they don't seem to work:

  • make the fulltext functions validation more fine-grained, eg. allowing EVALs with all NULL values before KQL. It's fragile, users would be able to add an EVAL non_existing = null manually, and it would be hard to distinguish.
  • add the EVAL right before the unsupported fields are used (rather than right after FROM): it doesn't work in all cases, eg. we'd have to consider KEEP/DROP and other commands that mask the non-existing fields. It's fragile and complicated.

Fixes: #142968
Fixes: #142959

@elasticsearchmachine elasticsearchmachine added v9.4.0 Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) labels Mar 2, 2026
@elasticsearchmachine
Copy link
Copy Markdown
Collaborator

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

@elasticsearchmachine
Copy link
Copy Markdown
Collaborator

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

@alex-spies
Copy link
Copy Markdown
Contributor

alex-spies commented Mar 3, 2026

Without having actually reviewed, I'm not sure about the approach, as it works around full-text functions' limitations. I think that conceptually, NULL is a bottom type and each function that can work on a field or column should also work when passed the null literal.

I explored the rule "every function should take NULL" in another PR, and there are only few exceptions where functions don't behave this way, full-text functions being the most prominent ones.

Update: please disregard, this is not about type compatibility, but about outright rejecting EVALs before KQL and QSTR.

@alex-spies
Copy link
Copy Markdown
Contributor

Had a look at the approach. Still a little unsure. It's a pretty neat idea, though, and I think it's growing on me.

Injecting an EVAL after the EsRelation is a pretty standard thing we do, it just messes with KQL/QSTR validation because those don't want user-made EVALs before being called. Updating this validation may have less potential side effects, resp. may be a conceptually more isolated change compared to this PR, which drastically changes the effect of nullify on query plans.

Creating NULL-typed field attributes, like this PR proposes, means that wherever we process a field attribute, we may get bugs because we don't expect that attribute to correspond to a missing field. For instance, this approach requires testing to prevent wrong filter and topn pushdowns to Lucene, which isn't a problem we had to worry about before. For this reason, this PR should wait until #143460 is merged to avoid this problem.

That said, load uses PotentiallyUnmappedKeywordEsFields and does place them into the EsRelation, so this PR is an interesting opportunity to make nullify and load behave more similarly. But I think that we shouldn't just re-use EsField, but introduce an analogous MissingEsField (or so) so that we can more easily pattern match on. Golden tests should also clearly show that this isn't a standard field attribute #f but a missing field; for PotentiallyUnmappedKeywordEsField, this will be implemented in another PR (see here).

@luigidellaquila , can we leave this open until the following are merged?

@luigidellaquila
Copy link
Copy Markdown
Member Author

Thanks for the feedback @alex-spies

Creating NULL-typed field attributes, like this PR proposes, means that wherever we process a field attribute, we may get bugs because we don't expect that attribute to correspond to a missing field. For instance, this approach requires testing to prevent wrong filter and topn pushdowns to Lucene, which isn't a problem we had to worry about before

Actually, this is exactly what we do today with index patterns, a node/cluster could receive attributes that don't exist in the local indices, and the local planner just ignores them and emits a null. That's the main reason that makes me consider this approach (probably) safe

But I think that we shouldn't just re-use EsField, but introduce an analogous MissingEsField (or so) so that we can more easily pattern match on

I thought about this and I don't have a very strong opinion. It could be useful for testing, but in practice this is just a null value, so I'm not sure.

can we leave this open until the following are merged?

Yes, absolutely! More tests = catching more potential corner cases

@alex-spies
Copy link
Copy Markdown
Contributor

alex-spies commented Mar 3, 2026

Actually, this is exactly what we do today with index patterns, a node/cluster could receive attributes that don't exist in the local indices, and the local planner just ignores them and emits a null.

What's important to check is if FROM idx | WHERE missing == "foobar" still leads to an optimization to a LocalRelation. With this approach, I don't think it does. That might be a reason not to go with it unless we also ensure our optimizations correctly pick up the NULL field attribute.

A simpler approach might be to ensure the added NULL aliases in the EVAL (added by ResolveUnmapped) are synthetic, and skip the validation of KQL/QSTR that forbids EVALs if the EVAL only adds synthetic attributes.

@luigidellaquila
Copy link
Copy Markdown
Member Author

luigidellaquila commented Mar 3, 2026

What's important to check is if FROM idx | WHERE missing == "foobar" still leads to an optimization to a LocalRelation. With this approach, I don't think it does

I tend to put correctness on top of performance, but I think in this case we are lucky.
I did a quick test:

[2026-03-03T17:59:06,981][TRACE][o.e.x.e.o.L.changes      ] [runTask-0] Rule logical.FoldNull applied with change
Limit[1000[INTEGER],false,false]                                           = Limit[1000[INTEGER],false,false]
\_Filter[missing{f}#49 == foobar[KEYWORD]]                                 ! \_Filter[null[BOOLEAN]]
  \_EsRelation[idx][123foo{f}#44, @timestamp{f}#45, foo{f}#46, keyword{..] =   \_EsRelation[idx][123foo{f}#44, @timestamp{f}#45, foo{f}#46, keyword{..]
[2026-03-03T17:59:06,982][TRACE][o.e.x.e.o.L.changes      ] [runTask-0] Rule logical.PruneFilters applied with change
Limit[1000[INTEGER],false,false]                                           = Limit[1000[INTEGER],false,false]
\_Filter[null[BOOLEAN]]                                                    ! \_LocalRelation[[123foo{f}#44, @timestamp{f}#45, foo{f}#46, keyword{f}#48, text{f}#47, missing{f}#49],EMPTY]
  \_EsRelation[idx][123foo{f}#44, @timestamp{f}#45, foo{f}#46, keyword{..] ! 

It works because FoldNull checks isGuaranteedNull(), that also checks for NULL field type.

A simpler approach might be to ensure the added NULL aliases in the EVAL (added by ResolveUnmapped) are synthetic

I also tried this path, but synthetic attributes are not supposed to be returned as a result, so I got other failures.

@alex-spies
Copy link
Copy Markdown
Contributor

It works because FoldNull checks isGuaranteedNull(), that also checks for NULL field type.

Oh, that's a nice surprise. Thanks for checking!

In this case, this looks like a nice improvement over the original approach and I think we should go for it, provided that we are a little more paranoid about testing things that could break.

@alex-spies alex-spies requested review from bpintea and removed request for alex-spies March 4, 2026 10:31
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 6, 2026

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (2)
  • Team:Delivery
  • Team:Search - Inference

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 2559112b-a859-4f95-a373-970f610fd7fc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@bpintea bpintea left a comment

Choose a reason for hiding this comment

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

LGTM.
Left two nits, but not worth going through CI if these will be the only left things.
I think I kind of like this solution better. 👍

// For non-EsRelation sources (Row, LocalRelation): insert Eval nodes with null assignments
// This handles cases like: ROW x = 1 | EVAL y = unmapped_field
transformed = transformed.transformUp(
n -> n instanceof UnaryPlan unary && unary.child() instanceof LeafPlan leaf && (leaf instanceof EsRelation == false),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: no need for ( )

List<LogicalPlan> newChildren = new ArrayList<>(nAry.children().size());
boolean changed = false;
for (var child : nAry.children()) {
if (child instanceof LeafPlan source && (source instanceof EsRelation == false)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also here, ( ) are redundant.

@luigidellaquila
Copy link
Copy Markdown
Member Author

Thanks @bpintea!

@luigidellaquila luigidellaquila enabled auto-merge (squash) March 6, 2026 16:37
@luigidellaquila luigidellaquila disabled auto-merge March 6, 2026 17:17
@luigidellaquila
Copy link
Copy Markdown
Member Author

luigidellaquila commented Mar 6, 2026

Running the golden tests I noticed a strange side effect. A query like

SET unmapped_fields="nullify";
FROM idx 
| KEEP field, does_not_exist 
| WHERE field == "foo" OR does_not_exist::KEYWORD == "foo"

returns does_not_exist as a keyword type (and not as a null type).
If I add another condition casting to another type. eg does_not_exist::KEYWORD == "foo" OR does_not_exist::INTEGER == 3, I get null again.

I think it's an interaction with union types
I need to investigate it a bit before moving forward, but @bpintea @alex-spies if you have a clue please let me know.

Sorry, ignore all the above, I don't know what I was looking at.
It's all fine, it always returns a null type; it's just that it's Friday afternoon and I probably need a break.

@luigidellaquila luigidellaquila enabled auto-merge (squash) March 6, 2026 17:29
@luigidellaquila luigidellaquila merged commit d22c50a into elastic:main Mar 9, 2026
34 of 36 checks passed
luigidellaquila added a commit to luigidellaquila/elasticsearch that referenced this pull request Mar 13, 2026
luigidellaquila added a commit to luigidellaquila/elasticsearch that referenced this pull request Mar 13, 2026
luigidellaquila added a commit to luigidellaquila/elasticsearch that referenced this pull request Mar 13, 2026
ncordon pushed a commit to ncordon/elasticsearch that referenced this pull request Mar 16, 2026
michalborek pushed a commit to michalborek/elasticsearch that referenced this pull request Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:Analytics/ES|QL AKA ESQL >bug Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) v9.4.0

Projects

None yet

4 participants