Skip to content

Conversation

@bpintea
Copy link
Contributor

@bpintea bpintea commented May 23, 2025

This introduces an optimization to pushdown to Lucense those language constructs that aim at case-insensitive regular expression matching, used with LIKE and RLIKE operators, such as:

  • | WHERE TO_LOWER(field) LIKE "abc*"
  • | WHERE TO_UPPER(field) RLIKE "ABC.*"

These are now pushed as case-insensitive wildcard and regexp respectively queries down to Lucene.

Closes #127479

This introduces an optimization to pushdown to Lucense those language
constructs that aim case-insensitive regular expression matching, used
with LIKE and RLIKE operators, such as:
* `| WHERE TO_LOWER(field) LIKE "abc*"`
* `| WHERE TO_UPPER(field) RLIKE `ABC.*`
These are now pushed as case-insensitive `regexp` and `wildcard`
respectively queries down to Lucene.
@elasticsearchmachine
Copy link
Collaborator

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

@elasticsearchmachine elasticsearchmachine added the Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) label May 23, 2025
@elasticsearchmachine
Copy link
Collaborator

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

Comment on lines 85 to 93
if (caseInsensitive() && out.getTransportVersion().before(TransportVersions.ESQL_REGEX_MATCH_WITH_CASE_INSENSITIVITY)) {
// The plan has been optimized to run a case-insensitive match, which the remote peer cannot be notified of. Simply avoiding
// the serialization of the boolean would result in wrong results.
throw new EsqlIllegalArgumentException(
NAME + " with case insensitivity is not supported in peer node's version [{}]. Upgrade to version [{}] or newer.",
out.getTransportVersion(),
TransportVersions.ESQL_REGEX_MATCH_WITH_CASE_INSENSITIVITY
);
}
Copy link
Contributor Author

@bpintea bpintea May 23, 2025

Choose a reason for hiding this comment

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

Pointing out this fast-failure here // bwc issue.

We haven't been serialising the case-insensitivity of the regexp operators, so far. We now need to.

Not sure we have an alternative to failing the query in this case, without introducing some compensating mechanisms like rerouting queries to old nodes for planning. The planner isn't currently aware of the versions it should plan for.

Introducing new operators (RLIKE2?) would still fail on old nodes; but it would maybe allow the user to opt out of the optimisation, so that the query on mixed-versions won't fail?

Copy link
Member

Choose a reason for hiding this comment

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

Another case for us needing to know if we can enable the optimization based on the minimum node version, or something like it.

Copy link
Member

Choose a reason for hiding this comment

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

We can't make these decisions on the local node only?

Copy link
Contributor

Choose a reason for hiding this comment

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

In this case we could be safe, see my comment below, but in general I agree that having minimum node version (or node features, or whatever) at planning level would help a lot.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can't make these decisions on the local node only?

We probably should be able to pull that into a rule that applies locally-only, yes.. Thanks!
That would allows to apply this optimisation, tough the serialisation issue itself will remain.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Extracted the logic into local optimiser-only rule.
The exception triggering is left in place, though it's now no longer triggerable, as any new functionality to turn the RegexMatch caseInsensitive will follow after introducing the serialisation of the boolean.

@luigidellaquila luigidellaquila self-requested a review May 23, 2025 15:22
Copy link
Contributor

@luigidellaquila luigidellaquila left a comment

Choose a reason for hiding this comment

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

I had a quick look and left a first round of comments, the implementation looks good in general.

My real concern is on the breaking change: TO_LOWER and TO_UPPER are the most used functions in ES|QL, it means that introducing a breaking change here would impact most of our users.
Probably we have an escape hatch though, see my comment below.

FROM employees
| KEEP emp_no, first_name
| SORT emp_no
| WHERE TO_UPPER(first_name) LIKE "GEOR*"
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 please add a couple of negative tests, eg. where TO_UPPER tries to match a lowercase pattern

FROM employees
| KEEP emp_no, first_name
| SORT emp_no
| WHERE TO_UPPER(first_name) LIKE "geor*"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added more tests (the logical folding is tested as well)

10055 |Georgy
;

likeWithLower
Copy link
Contributor

Choose a reason for hiding this comment

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

If we decide to accept the breaking change, you'll need at least a new capability, otherwise these tests will fail on mixed clusters.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No longer needed.

import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvSum;
import org.elasticsearch.xpack.esql.expression.function.scalar.nulls.Coalesce;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.Concat;
import org.elasticsearch.xpack.esql.expression.function.scalar.string.RLike;
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 we'll also need more unit tests, ie. a few more cases in WildcardLikeTests and RLikeTests

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, added those now too.

source().writeTo(out);
out.writeNamedWriteable(field());
out.writeString(pattern().asJavaRegex());
if (caseInsensitive() && out.getTransportVersion().before(TransportVersions.ESQL_REGEX_MATCH_WITH_CASE_INSENSITIVITY)) {
Copy link
Contributor

@luigidellaquila luigidellaquila May 23, 2025

Choose a reason for hiding this comment

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

Can't we just re-add the to_upper/lower() function on the fly if we are on an old transport version?

We'll have to be careful with layouts and NameIDs, but probably it's not impossible.

Copy link
Contributor

Choose a reason for hiding this comment

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

This would also give us a bwc advantage if we decide to add case insensitive operators to the grammar.

Copy link
Member

Choose a reason for hiding this comment

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

Clever idea: serialize foo rlike "aAa" to --> to_lower(foo) rlike to_lower "aAa".
This is best done in the planner but since we don't have the versioning in place, we can do this locally for rlike and like.

Another suggestion that might be cleaner would be to perform the optimization on the data nodes only, not on the coordinator to avoid the difference in serialization.

Copy link
Member

@costin costin left a comment

Choose a reason for hiding this comment

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

Thanks for picking this one up! LGTM to me once the serialization/compatibility stuff gets sorted out.


@Override
public Automaton createAutomaton() {
public Automaton createAutomaton(boolean ignoreCase) {
Copy link
Member

Choose a reason for hiding this comment

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

Expose ignoreCase as a property in StringPattern since it affects both the Automaton and javaRegex. The former can contain the mode but the latter doesn't so we need a way to bubble it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The pattern is independent of how it's used for matching, casing-wise. The java regex version has it's own mechanism to flag case insensitivity and not sure it'd be trivial, or "safe", or even needed to modify it based on a method parameter.
But even if we updated the StringPattern interface, we'd have to recreate the object if the RegexMatch requires case-insenstive matching, since the StringPattern object is created at parsing time (when it's not known if the matching will be case insensitive or not).
Furthermore, the matchesAll() and exactMatch() methods of AbstractStringPattern also calling automaton() are invariant to casing.
So not sure if we'd need any more changes, but if there's a better solution here, happy to apply it.

Copy link
Member

Choose a reason for hiding this comment

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

Same comment as above - make the parameter a class property.

public static String luceneWildcardToRegExp(String wildcard) {
StringBuilder regex = new StringBuilder();

for (int i = 0, wcLen = wildcard.length(); i < wcLen; i++) {
Copy link
Member

Choose a reason for hiding this comment

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

👍

source().writeTo(out);
out.writeNamedWriteable(field());
out.writeString(pattern().asJavaRegex());
if (caseInsensitive() && out.getTransportVersion().before(TransportVersions.ESQL_REGEX_MATCH_WITH_CASE_INSENSITIVITY)) {
Copy link
Member

Choose a reason for hiding this comment

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

Clever idea: serialize foo rlike "aAa" to --> to_lower(foo) rlike to_lower "aAa".
This is best done in the planner but since we don't have the versioning in place, we can do this locally for rlike and like.

Another suggestion that might be cleaner would be to perform the optimization on the data nodes only, not on the coordinator to avoid the difference in serialization.

@costin costin self-requested a review May 23, 2025 21:12
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Dropped the now useless proxy-class.

List<Object[]> cases = (List<Object[]>) RLikeTests.parameters(str -> {
for (String syntax : new String[] { "\\", "*" }) {
final Function<String, String> escapeString = str -> {
for (String syntax : new String[] { "\\", "*", "?" }) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

? needs escaping too for the wildcard matching.

// System.out.println("Physical\n" + physical);
if (assertSerialization) {
assertSerialization(physical);
assertSerialization(physical, config);
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 how this didn't trigger earlier. The instances of certain functions (like TO_UPPER/_LOWER), contain references to the config, which contains the instant it was created at. So comparing these function instances before & after serialization compares the configs too, which needs to be equal.

Copy link
Contributor

@luigidellaquila luigidellaquila left a comment

Choose a reason for hiding this comment

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

LGTM, thanks!

return newBatches;

// add rule that should only apply locally
newRules.add(new ReplaceStringCasingWithInsensitiveRegexMatch());
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 +79 to +83
throw new EsqlIllegalArgumentException(
name() + " with case insensitivity is not supported in peer node's version [{}]. Upgrade to version [{}] or newer.",
out.getTransportVersion(),
TransportVersions.ESQL_REGEX_MATCH_WITH_CASE_INSENSITIVITY
);
Copy link
Contributor

Choose a reason for hiding this comment

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

This should never happen, but I like the idea of throwing an exception here, as a defense from possible future mistakes.

@bpintea
Copy link
Contributor Author

bpintea commented May 30, 2025

Thanks folks!

@bpintea bpintea merged commit 0a80916 into elastic:main May 30, 2025
18 checks passed
@bpintea bpintea deleted the enh/case_insensitivity_regex branch May 30, 2025 08:55
joshua-adams-1 pushed a commit to joshua-adams-1/elasticsearch that referenced this pull request Jun 3, 2025
)

This introduces an optimization to pushdown to Lucense those language constructs that aim at case-insensitive regular expression matching, used with `LIKE` and `RLIKE` operators, such as:
* `| WHERE TO_LOWER(field) LIKE "abc*"`
* `| WHERE TO_UPPER(field) RLIKE "ABC.*"` 
 
These are now pushed as case-insensitive `wildcard` and `regexp` respectively queries down to Lucene.

Closes elastic#127479
elasticsearchmachine pushed a commit that referenced this pull request Jun 5, 2025
) (#128750) (#128753)  (#128919)

* ESQL: Pushdown constructs doing case-insensitive regexes (#128393)

This introduces an optimization to pushdown to Lucense those language constructs that aim at case-insensitive regular expression matching, used with `LIKE` and `RLIKE` operators, such as:
* `| WHERE TO_LOWER(field) LIKE "abc*"`
* `| WHERE TO_UPPER(field) RLIKE "ABC.*"`

These are now pushed as case-insensitive `wildcard` and `regexp` respectively queries down to Lucene.

Closes #127479

(cherry picked from commit 0a80916)

* ESQL: Fix conversion of a Lucene wildcard pattern to a regexp (#128750)

This adds the reserved optional characters to the list that is escaped
during conversion. These characters are all enabled by the `RegExp.ALL`
flag in our use.

Closes #128676, closes #128677.

(cherry picked from commit 5eb54bf)

* ESQL: Fix case-insensitive test generation with Unicodes (#128753)

This excludes from testing the strings containing Unicode chars that
change length when changing case.

Closes #128705 Closes #128706 Closes #128710 Closes #128711 Closes
Closes #128717 Closes #128789 Closes #128790 Closes #128791 Closes

(cherry picked from commit 092d4ba)

* [CI] Auto commit changes from spotless

* Java21 adaptations and automerge fixes

* [CI] Auto commit changes from spotless

* 8.x's Lucene/RegExp doesn't support case-insensitive matching

* [CI] Auto commit changes from spotless

* One more Lucene 9 fix

---------

Co-authored-by: elasticsearchmachine <[email protected]>
Samiul-TheSoccerFan pushed a commit to Samiul-TheSoccerFan/elasticsearch that referenced this pull request Jun 5, 2025
)

This introduces an optimization to pushdown to Lucense those language constructs that aim at case-insensitive regular expression matching, used with `LIKE` and `RLIKE` operators, such as:
* `| WHERE TO_LOWER(field) LIKE "abc*"`
* `| WHERE TO_UPPER(field) RLIKE "ABC.*"` 
 
These are now pushed as case-insensitive `wildcard` and `regexp` respectively queries down to Lucene.

Closes elastic#127479
dnhatn added a commit to dnhatn/elasticsearch that referenced this pull request Jun 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:Analytics/ES|QL AKA ESQL >enhancement Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) v8.19.0 v9.1.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ESQL: pushdown to_upper/to_lower in like/rlike

5 participants