diff --git a/docs/changelog/118870.yaml b/docs/changelog/118870.yaml new file mode 100644 index 0000000000000..ce3692d5454ae --- /dev/null +++ b/docs/changelog/118870.yaml @@ -0,0 +1,6 @@ +pr: 118870 +summary: Rewrite TO_UPPER/TO_LOWER comparisons +area: ES|QL +type: enhancement +issues: + - 118304 diff --git a/x-pack/plugin/esql-core/src/test/java/org/elasticsearch/xpack/esql/core/util/TestUtils.java b/x-pack/plugin/esql-core/src/test/java/org/elasticsearch/xpack/esql/core/util/TestUtils.java index b37ca0431ec2d..40321fddebdfe 100644 --- a/x-pack/plugin/esql-core/src/test/java/org/elasticsearch/xpack/esql/core/util/TestUtils.java +++ b/x-pack/plugin/esql-core/src/test/java/org/elasticsearch/xpack/esql/core/util/TestUtils.java @@ -15,6 +15,8 @@ import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.core.type.EsField; +import java.util.regex.Pattern; + import static java.util.Collections.emptyMap; import static org.elasticsearch.test.ESTestCase.randomAlphaOfLength; import static org.elasticsearch.test.ESTestCase.randomBoolean; @@ -26,6 +28,8 @@ public final class TestUtils { private TestUtils() {} + private static final Pattern WS_PATTERN = Pattern.compile("\\s"); + public static Literal of(Object value) { return of(Source.EMPTY, value); } @@ -59,4 +63,9 @@ public static FieldAttribute getFieldAttribute(String name) { public static FieldAttribute getFieldAttribute(String name, DataType dataType) { return new FieldAttribute(EMPTY, name, new EsField(name + "f", dataType, emptyMap(), true)); } + + /** Similar to {@link String#strip()}, but removes the WS throughout the entire string. */ + public static String stripThrough(String input) { + return WS_PATTERN.matcher(input).replaceAll(StringUtils.EMPTY); + } } diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec index e103168d2e589..5b0cccc1ed430 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec @@ -1231,6 +1231,189 @@ a:keyword | upper:keyword | lower:keyword π/2 + a + B + Λ ºC | Π/2 + A + B + Λ ºC | π/2 + a + b + λ ºc ; +equalsToUpperPushedDown[skip:-8.12.99, reason:case insensitive operators implemented in v 8.13] +from employees +| where to_upper(first_name) == "GEORGI" +| keep emp_no, first_name +; + +emp_no:integer | first_name:keyword +10001 | Georgi +; + +equalsToUpperNestedPushedDown[skip:-8.12.99, reason:case insensitive operators implemented in v 8.13] +from employees +| where to_upper(to_upper(to_lower(first_name))) == "GEORGI" +| keep emp_no, first_name +; + +emp_no:integer | first_name:keyword +10001 | Georgi +; + +negatedEqualsToUpperPushedDown[skip:-8.12.99, reason:case insensitive operators implemented in v 8.13] +from employees +| sort emp_no +| where not(to_upper(first_name) == "GEORGI") +| keep emp_no, first_name +| limit 1 +; + +emp_no:integer | first_name:keyword +10002 | Bezalel +; + +notEqualsToUpperPushedDown[skip:-8.12.99, reason:case insensitive operators implemented in v 8.13] +from employees +| sort emp_no +| where to_upper(first_name) != "GEORGI" +| keep emp_no, first_name +| limit 1 +; + +emp_no:integer | first_name:keyword +10002 | Bezalel +; + +negatedNotEqualsToUpperPushedDown[skip:-8.12.99, reason:case insensitive operators implemented in v 8.13] +from employees +| sort emp_no +| where not(to_upper(first_name) != "GEORGI") +| keep emp_no, first_name +| limit 1 +; + +emp_no:integer | first_name:keyword +10001 | Georgi +; + +equalsToUpperFolded +from employees +| where to_upper(first_name) == "Georgi" +| keep emp_no, first_name +; + +emp_no:integer | first_name:keyword +; + +negatedEqualsToUpperFolded +from employees +| where not(to_upper(first_name) == "Georgi") +| stats c = count() +; + +c:long +90 +; + +equalsToUpperNullFolded +from employees +| where to_upper(null) == "Georgi" +| keep emp_no, first_name +; + +emp_no:integer | first_name:keyword +; + +equalsNullToUpperFolded +from employees +| where to_upper(first_name) == null::keyword +| keep emp_no, first_name +; + +emp_no:integer | first_name:keyword +; + +notEqualsToUpperNullFolded +from employees +| where to_upper(null) != "Georgi" +| keep emp_no, first_name +; + +emp_no:integer | first_name:keyword +; + +notEqualsNullToUpperFolded +from employees +| where to_upper(first_name) != null::keyword +| keep emp_no, first_name +; + +emp_no:integer | first_name:keyword +; + +notEqualsToUpperFolded +from employees +| where to_upper(first_name) != "Georgi" +| stats c = count() +; + +c:long +90 +; + +negatedNotEqualsToUpperFolded +from employees +| where not(to_upper(first_name) != "Georgi") +| stats c = count() +; + +c:long +0 +; + +equalsToLowerPushedDown[skip:-8.12.99, reason:case insensitive operators implemented in v 8.13] +from employees +| where to_lower(first_name) == "georgi" +| keep emp_no, first_name +; + +emp_no:integer | first_name:keyword +10001 | Georgi +; + +notEqualsToLowerPushedDown[skip:-8.12.99, reason:case insensitive operators implemented in v 8.13] +from employees +| sort emp_no +| where to_lower(first_name) != "georgi" +| keep emp_no, first_name +| limit 1 +; + +emp_no:integer | first_name:keyword +10002 | Bezalel +; + +equalsToLowerFolded +from employees +| where to_lower(first_name) == "Georgi" +| keep emp_no, first_name +; + +emp_no:integer | first_name:keyword +; + +notEqualsToLowerFolded +from employees +| where to_lower(first_name) != "Georgi" +| stats c = count() +; + +c:long +90 +; + +equalsToLowerWithUnico(rn|d)s +from employees +| where to_lower(concat(first_name, "🦄🦄")) != "georgi🦄🦄" +| stats c = count() +; + +// 10 null first names +c:long +89 +; + reverse required_capability: fn_reverse from employees | sort emp_no | eval name_reversed = REVERSE(first_name) | keep emp_no, first_name, name_reversed | limit 1; diff --git a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLowerEvaluator.java b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ChangeCaseEvaluator.java similarity index 76% rename from x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLowerEvaluator.java rename to x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ChangeCaseEvaluator.java index 61e7c5c3042df..02d1b1c86ea32 100644 --- a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLowerEvaluator.java +++ b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ChangeCaseEvaluator.java @@ -20,25 +20,28 @@ import org.elasticsearch.xpack.esql.core.tree.Source; /** - * {@link EvalOperator.ExpressionEvaluator} implementation for {@link ToLower}. + * {@link EvalOperator.ExpressionEvaluator} implementation for {@link ChangeCase}. * This class is generated. Do not edit it. */ -public final class ToLowerEvaluator implements EvalOperator.ExpressionEvaluator { +public final class ChangeCaseEvaluator implements EvalOperator.ExpressionEvaluator { private final Source source; private final EvalOperator.ExpressionEvaluator val; private final Locale locale; + private final ChangeCase.Case caseType; + private final DriverContext driverContext; private Warnings warnings; - public ToLowerEvaluator(Source source, EvalOperator.ExpressionEvaluator val, Locale locale, - DriverContext driverContext) { + public ChangeCaseEvaluator(Source source, EvalOperator.ExpressionEvaluator val, Locale locale, + ChangeCase.Case caseType, DriverContext driverContext) { this.source = source; this.val = val; this.locale = locale; + this.caseType = caseType; this.driverContext = driverContext; } @@ -68,7 +71,7 @@ public BytesRefBlock eval(int positionCount, BytesRefBlock valBlock) { result.appendNull(); continue position; } - result.appendBytesRef(ToLower.process(valBlock.getBytesRef(valBlock.getFirstValueIndex(p), valScratch), this.locale)); + result.appendBytesRef(ChangeCase.process(valBlock.getBytesRef(valBlock.getFirstValueIndex(p), valScratch), this.locale, this.caseType)); } return result.build(); } @@ -78,7 +81,7 @@ public BytesRefVector eval(int positionCount, BytesRefVector valVector) { try(BytesRefVector.Builder result = driverContext.blockFactory().newBytesRefVectorBuilder(positionCount)) { BytesRef valScratch = new BytesRef(); position: for (int p = 0; p < positionCount; p++) { - result.appendBytesRef(ToLower.process(valVector.getBytesRef(p, valScratch), this.locale)); + result.appendBytesRef(ChangeCase.process(valVector.getBytesRef(p, valScratch), this.locale, this.caseType)); } return result.build(); } @@ -86,7 +89,7 @@ public BytesRefVector eval(int positionCount, BytesRefVector valVector) { @Override public String toString() { - return "ToLowerEvaluator[" + "val=" + val + ", locale=" + locale + "]"; + return "ChangeCaseEvaluator[" + "val=" + val + ", locale=" + locale + ", caseType=" + caseType + "]"; } @Override @@ -113,20 +116,24 @@ static class Factory implements EvalOperator.ExpressionEvaluator.Factory { private final Locale locale; - public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory val, Locale locale) { + private final ChangeCase.Case caseType; + + public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory val, Locale locale, + ChangeCase.Case caseType) { this.source = source; this.val = val; this.locale = locale; + this.caseType = caseType; } @Override - public ToLowerEvaluator get(DriverContext context) { - return new ToLowerEvaluator(source, val.get(context), locale, context); + public ChangeCaseEvaluator get(DriverContext context) { + return new ChangeCaseEvaluator(source, val.get(context), locale, caseType, context); } @Override public String toString() { - return "ToLowerEvaluator[" + "val=" + val + ", locale=" + locale + "]"; + return "ChangeCaseEvaluator[" + "val=" + val + ", locale=" + locale + ", caseType=" + caseType + "]"; } } } diff --git a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpperEvaluator.java b/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpperEvaluator.java deleted file mode 100644 index 82412dde53941..0000000000000 --- a/x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpperEvaluator.java +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -// or more contributor license agreements. Licensed under the Elastic License -// 2.0; you may not use this file except in compliance with the Elastic License -// 2.0. -package org.elasticsearch.xpack.esql.expression.function.scalar.string; - -import java.lang.IllegalArgumentException; -import java.lang.Override; -import java.lang.String; -import java.util.Locale; -import org.apache.lucene.util.BytesRef; -import org.elasticsearch.compute.data.Block; -import org.elasticsearch.compute.data.BytesRefBlock; -import org.elasticsearch.compute.data.BytesRefVector; -import org.elasticsearch.compute.data.Page; -import org.elasticsearch.compute.operator.DriverContext; -import org.elasticsearch.compute.operator.EvalOperator; -import org.elasticsearch.compute.operator.Warnings; -import org.elasticsearch.core.Releasables; -import org.elasticsearch.xpack.esql.core.tree.Source; - -/** - * {@link EvalOperator.ExpressionEvaluator} implementation for {@link ToUpper}. - * This class is generated. Do not edit it. - */ -public final class ToUpperEvaluator implements EvalOperator.ExpressionEvaluator { - private final Source source; - - private final EvalOperator.ExpressionEvaluator val; - - private final Locale locale; - - private final DriverContext driverContext; - - private Warnings warnings; - - public ToUpperEvaluator(Source source, EvalOperator.ExpressionEvaluator val, Locale locale, - DriverContext driverContext) { - this.source = source; - this.val = val; - this.locale = locale; - this.driverContext = driverContext; - } - - @Override - public Block eval(Page page) { - try (BytesRefBlock valBlock = (BytesRefBlock) val.eval(page)) { - BytesRefVector valVector = valBlock.asVector(); - if (valVector == null) { - return eval(page.getPositionCount(), valBlock); - } - return eval(page.getPositionCount(), valVector).asBlock(); - } - } - - public BytesRefBlock eval(int positionCount, BytesRefBlock valBlock) { - try(BytesRefBlock.Builder result = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) { - BytesRef valScratch = new BytesRef(); - position: for (int p = 0; p < positionCount; p++) { - if (valBlock.isNull(p)) { - result.appendNull(); - continue position; - } - if (valBlock.getValueCount(p) != 1) { - if (valBlock.getValueCount(p) > 1) { - warnings().registerException(new IllegalArgumentException("single-value function encountered multi-value")); - } - result.appendNull(); - continue position; - } - result.appendBytesRef(ToUpper.process(valBlock.getBytesRef(valBlock.getFirstValueIndex(p), valScratch), this.locale)); - } - return result.build(); - } - } - - public BytesRefVector eval(int positionCount, BytesRefVector valVector) { - try(BytesRefVector.Builder result = driverContext.blockFactory().newBytesRefVectorBuilder(positionCount)) { - BytesRef valScratch = new BytesRef(); - position: for (int p = 0; p < positionCount; p++) { - result.appendBytesRef(ToUpper.process(valVector.getBytesRef(p, valScratch), this.locale)); - } - return result.build(); - } - } - - @Override - public String toString() { - return "ToUpperEvaluator[" + "val=" + val + ", locale=" + locale + "]"; - } - - @Override - public void close() { - Releasables.closeExpectNoException(val); - } - - private Warnings warnings() { - if (warnings == null) { - this.warnings = Warnings.createWarnings( - driverContext.warningsMode(), - source.source().getLineNumber(), - source.source().getColumnNumber(), - source.text() - ); - } - return warnings; - } - - static class Factory implements EvalOperator.ExpressionEvaluator.Factory { - private final Source source; - - private final EvalOperator.ExpressionEvaluator.Factory val; - - private final Locale locale; - - public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory val, Locale locale) { - this.source = source; - this.val = val; - this.locale = locale; - } - - @Override - public ToUpperEvaluator get(DriverContext context) { - return new ToUpperEvaluator(source, val.get(context), locale, context); - } - - @Override - public String toString() { - return "ToUpperEvaluator[" + "val=" + val + ", locale=" + locale + "]"; - } - } -} diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ChangeCase.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ChangeCase.java new file mode 100644 index 0000000000000..fe9a2d5beb3cf --- /dev/null +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ChangeCase.java @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.expression.function.scalar.string; + +import org.apache.lucene.util.BytesRef; +import org.elasticsearch.common.lucene.BytesRefs; +import org.elasticsearch.compute.ann.Evaluator; +import org.elasticsearch.compute.ann.Fixed; +import org.elasticsearch.compute.operator.EvalOperator; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlConfigurationFunction; +import org.elasticsearch.xpack.esql.session.Configuration; + +import java.util.List; +import java.util.Locale; + +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString; + +public abstract class ChangeCase extends EsqlConfigurationFunction { + + public enum Case { + UPPER { + @Override + String process(String value, Locale locale) { + return value.toUpperCase(locale); + } + + @Override + public boolean matchesCase(String value) { + return value.codePoints().allMatch(cp -> Character.getType(cp) != Character.LOWERCASE_LETTER); + } + }, + LOWER { + @Override + String process(String value, Locale locale) { + return value.toLowerCase(locale); + } + + @Override + public boolean matchesCase(String value) { + return value.codePoints().allMatch(cp -> Character.getType(cp) != Character.UPPERCASE_LETTER); + } + }; + + abstract String process(String value, Locale locale); + + public abstract boolean matchesCase(String value); + } + + private final Expression field; + private final Case caseType; + + protected ChangeCase(Source source, Expression field, Configuration configuration, Case caseType) { + super(source, List.of(field), configuration); + this.field = field; + this.caseType = caseType; + } + + @Override + public DataType dataType() { + return DataType.KEYWORD; + } + + @Override + protected TypeResolution resolveType() { + if (childrenResolved() == false) { + return new TypeResolution("Unresolved children"); + } + + return isString(field, sourceText(), DEFAULT); + } + + @Override + public boolean foldable() { + return field.foldable(); + } + + public Expression field() { + return field; + } + + public Case caseType() { + return caseType; + } + + public abstract Expression replaceChild(Expression child); + + @Override + public Expression replaceChildren(List newChildren) { + assert newChildren.size() == 1; + return replaceChild(newChildren.get(0)); + } + + @Evaluator + static BytesRef process(BytesRef val, @Fixed Locale locale, @Fixed Case caseType) { + return BytesRefs.toBytesRef(caseType.process(val.utf8ToString(), locale)); + } + + @Override + public EvalOperator.ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) { + var fieldEvaluator = toEvaluator.apply(field); + return new ChangeCaseEvaluator.Factory(source(), fieldEvaluator, configuration().locale(), caseType); + } +} diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLower.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLower.java index 5f2bbcde52166..084afb1b69996 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLower.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLower.java @@ -7,37 +7,23 @@ package org.elasticsearch.xpack.esql.expression.function.scalar.string; -import org.apache.lucene.util.BytesRef; import org.elasticsearch.common.io.stream.NamedWriteableRegistry; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; -import org.elasticsearch.common.lucene.BytesRefs; -import org.elasticsearch.compute.ann.Evaluator; -import org.elasticsearch.compute.ann.Fixed; -import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator; import org.elasticsearch.xpack.esql.core.expression.Expression; import org.elasticsearch.xpack.esql.core.tree.NodeInfo; import org.elasticsearch.xpack.esql.core.tree.Source; -import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.expression.function.Example; import org.elasticsearch.xpack.esql.expression.function.FunctionInfo; import org.elasticsearch.xpack.esql.expression.function.Param; -import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlConfigurationFunction; import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput; import org.elasticsearch.xpack.esql.session.Configuration; import java.io.IOException; -import java.util.List; -import java.util.Locale; -import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT; -import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString; - -public class ToLower extends EsqlConfigurationFunction { +public class ToLower extends ChangeCase { public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "ToLower", ToLower::new); - private final Expression field; - @FunctionInfo( returnType = { "keyword" }, description = "Returns a new string representing the input string converted to lower case.", @@ -52,8 +38,7 @@ public ToLower( ) Expression field, Configuration configuration ) { - super(source, List.of(field), configuration); - this.field = field; + super(source, field, configuration, Case.LOWER); } private ToLower(StreamInput in) throws IOException { @@ -70,52 +55,12 @@ public String getWriteableName() { return ENTRY.name; } - @Override - public DataType dataType() { - return DataType.KEYWORD; - } - - @Override - protected TypeResolution resolveType() { - if (childrenResolved() == false) { - return new TypeResolution("Unresolved children"); - } - - return isString(field, sourceText(), DEFAULT); - } - - @Override - public boolean foldable() { - return field.foldable(); - } - - @Evaluator - static BytesRef process(BytesRef val, @Fixed Locale locale) { - return BytesRefs.toBytesRef(val.utf8ToString().toLowerCase(locale)); - } - - @Override - public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) { - var fieldEvaluator = toEvaluator.apply(field); - return new ToLowerEvaluator.Factory(source(), fieldEvaluator, configuration().locale()); - } - - public Expression field() { - return field; - } - public ToLower replaceChild(Expression child) { return new ToLower(source(), child, configuration()); } - @Override - public Expression replaceChildren(List newChildren) { - assert newChildren.size() == 1; - return replaceChild(newChildren.get(0)); - } - @Override protected NodeInfo info() { - return NodeInfo.create(this, ToLower::new, field, configuration()); + return NodeInfo.create(this, ToLower::new, field(), configuration()); } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpper.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpper.java index 7fdd5e39f96f3..4509404754f36 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpper.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpper.java @@ -7,37 +7,23 @@ package org.elasticsearch.xpack.esql.expression.function.scalar.string; -import org.apache.lucene.util.BytesRef; import org.elasticsearch.common.io.stream.NamedWriteableRegistry; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; -import org.elasticsearch.common.lucene.BytesRefs; -import org.elasticsearch.compute.ann.Evaluator; -import org.elasticsearch.compute.ann.Fixed; -import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator; import org.elasticsearch.xpack.esql.core.expression.Expression; import org.elasticsearch.xpack.esql.core.tree.NodeInfo; import org.elasticsearch.xpack.esql.core.tree.Source; -import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.expression.function.Example; import org.elasticsearch.xpack.esql.expression.function.FunctionInfo; import org.elasticsearch.xpack.esql.expression.function.Param; -import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlConfigurationFunction; import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput; import org.elasticsearch.xpack.esql.session.Configuration; import java.io.IOException; -import java.util.List; -import java.util.Locale; -import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT; -import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString; - -public class ToUpper extends EsqlConfigurationFunction { +public class ToUpper extends ChangeCase { public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "ToUpper", ToUpper::new); - private final Expression field; - @FunctionInfo( returnType = { "keyword" }, description = "Returns a new string representing the input string converted to upper case.", @@ -52,8 +38,7 @@ public ToUpper( ) Expression field, Configuration configuration ) { - super(source, List.of(field), configuration); - this.field = field; + super(source, field, configuration, Case.UPPER); } private ToUpper(StreamInput in) throws IOException { @@ -62,7 +47,7 @@ private ToUpper(StreamInput in) throws IOException { @Override public void writeTo(StreamOutput out) throws IOException { - out.writeNamedWriteable(field); + out.writeNamedWriteable(field()); } @Override @@ -70,52 +55,12 @@ public String getWriteableName() { return ENTRY.name; } - @Override - public DataType dataType() { - return DataType.KEYWORD; - } - - @Override - protected TypeResolution resolveType() { - if (childrenResolved() == false) { - return new TypeResolution("Unresolved children"); - } - - return isString(field, sourceText(), DEFAULT); - } - - @Override - public boolean foldable() { - return field.foldable(); - } - - @Evaluator - static BytesRef process(BytesRef val, @Fixed Locale locale) { - return BytesRefs.toBytesRef(val.utf8ToString().toUpperCase(locale)); - } - - @Override - public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) { - var fieldEvaluator = toEvaluator.apply(field); - return new ToUpperEvaluator.Factory(source(), fieldEvaluator, configuration().locale()); - } - - public Expression field() { - return field; - } - public ToUpper replaceChild(Expression child) { return new ToUpper(source(), child, configuration()); } - @Override - public Expression replaceChildren(List newChildren) { - assert newChildren.size() == 1; - return replaceChild(newChildren.get(0)); - } - @Override protected NodeInfo info() { - return NodeInfo.create(this, ToUpper::new, field, configuration()); + return NodeInfo.create(this, ToUpper::new, field(), configuration()); } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizer.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizer.java index a5f97cf961378..36150083daec0 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizer.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizer.java @@ -49,6 +49,7 @@ import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceRegexMatch; import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceRowAsLocalRelation; import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceStatsFilteredAggWithEval; +import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceStringCasingWithInsensitiveEquals; import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceTrivialTypeConversions; import org.elasticsearch.xpack.esql.optimizer.rules.logical.SetAsOptimized; import org.elasticsearch.xpack.esql.optimizer.rules.logical.SimplifyComparisonsArithmetics; @@ -175,6 +176,7 @@ protected static Batch operators() { new CombineDisjunctions(), // TODO: bifunction can now (since we now have just one data types set) be pushed into the rule new SimplifyComparisonsArithmetics(DataType::areCompatible), + new ReplaceStringCasingWithInsensitiveEquals(), new ReplaceStatsFilteredAggWithEval(), new ExtractAggregateCommonFilter(), // prune/elimination diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/ReplaceStringCasingWithInsensitiveEquals.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/ReplaceStringCasingWithInsensitiveEquals.java new file mode 100644 index 0000000000000..0fea7cf8ddc1f --- /dev/null +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/ReplaceStringCasingWithInsensitiveEquals.java @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.optimizer.rules.logical; + +import org.elasticsearch.common.lucene.BytesRefs; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.expression.Literal; +import org.elasticsearch.xpack.esql.core.expression.function.scalar.ScalarFunction; +import org.elasticsearch.xpack.esql.core.expression.predicate.logical.Not; +import org.elasticsearch.xpack.esql.core.expression.predicate.nulls.IsNotNull; +import org.elasticsearch.xpack.esql.core.expression.predicate.operator.comparison.BinaryComparison; +import org.elasticsearch.xpack.esql.expression.function.scalar.string.ChangeCase; +import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.Equals; +import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.InsensitiveEquals; +import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.NotEquals; + +public class ReplaceStringCasingWithInsensitiveEquals extends OptimizerRules.OptimizerExpressionRule { + + public ReplaceStringCasingWithInsensitiveEquals() { + super(OptimizerRules.TransformDirection.DOWN); + } + + @Override + protected Expression rule(ScalarFunction sf) { + Expression e = sf; + if (sf instanceof BinaryComparison bc) { + e = rewriteBinaryComparison(sf, bc, false); + } else if (sf instanceof Not not && not.field() instanceof BinaryComparison bc) { + e = rewriteBinaryComparison(sf, bc, true); + } + return e; + } + + private static Expression rewriteBinaryComparison(ScalarFunction sf, BinaryComparison bc, boolean negated) { + Expression e = sf; + if (bc.left() instanceof ChangeCase changeCase && bc.right().foldable()) { + if (bc instanceof Equals) { + e = replaceChangeCase(bc, changeCase, negated); + } else if (bc instanceof NotEquals) { // not actually used currently, `!=` is built as `NOT(==)` already + e = replaceChangeCase(bc, changeCase, negated == false); + } + } + return e; + } + + private static Expression replaceChangeCase(BinaryComparison bc, ChangeCase changeCase, boolean negated) { + var foldedRight = BytesRefs.toString(bc.right().fold()); + var field = unwrapCase(changeCase.field()); + var e = changeCase.caseType().matchesCase(foldedRight) + ? new InsensitiveEquals(bc.source(), field, bc.right()) + : Literal.of(bc, Boolean.FALSE); + if (negated) { + e = e instanceof Literal ? new IsNotNull(e.source(), field) : new Not(e.source(), e); + } + return e; + } + + private static Expression unwrapCase(Expression e) { + for (; e instanceof ChangeCase cc; e = cc.field()) { + } + return e; + } +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLowerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLowerTests.java index 69dbe023bde66..026d190c06e3f 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLowerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToLowerTests.java @@ -83,7 +83,7 @@ protected Expression buildWithConfiguration(Source source, List args private static TestCaseSupplier supplier(String name, DataType type, Supplier valueSupplier) { return new TestCaseSupplier(name, List.of(type), () -> { List values = new ArrayList<>(); - String expectedToString = "ToLowerEvaluator[val=Attribute[channel=0], locale=en_US]"; + String expectedToString = "ChangeCaseEvaluator[val=Attribute[channel=0], locale=en_US, caseType=LOWER]"; String value = valueSupplier.get(); values.add(new TestCaseSupplier.TypedData(new BytesRef(value), type, "0")); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpperTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpperTests.java index 33d6f929503b3..027ac54d15875 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpperTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ToUpperTests.java @@ -83,7 +83,7 @@ protected Expression buildWithConfiguration(Source source, List args private static TestCaseSupplier supplier(String name, DataType type, Supplier valueSupplier) { return new TestCaseSupplier(name, List.of(type), () -> { List values = new ArrayList<>(); - String expectedToString = "ToUpperEvaluator[val=Attribute[channel=0], locale=en_US]"; + String expectedToString = "ChangeCaseEvaluator[val=Attribute[channel=0], locale=en_US, caseType=UPPER]"; String value = valueSupplier.get(); values.add(new TestCaseSupplier.TypedData(new BytesRef(value), type, "0")); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java index 6ee035aa8bd84..7e65cb045b26e 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java @@ -39,6 +39,7 @@ import org.elasticsearch.xpack.esql.core.expression.ReferenceAttribute; import org.elasticsearch.xpack.esql.core.expression.UnresolvedAttribute; import org.elasticsearch.xpack.esql.core.expression.predicate.logical.And; +import org.elasticsearch.xpack.esql.core.expression.predicate.logical.Not; import org.elasticsearch.xpack.esql.core.expression.predicate.logical.Or; import org.elasticsearch.xpack.esql.core.expression.predicate.nulls.IsNotNull; import org.elasticsearch.xpack.esql.core.expression.predicate.operator.comparison.BinaryComparison; @@ -85,6 +86,7 @@ import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison; import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.GreaterThan; import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.In; +import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.InsensitiveEquals; import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThan; import org.elasticsearch.xpack.esql.index.EsIndex; import org.elasticsearch.xpack.esql.index.IndexResolution; @@ -126,6 +128,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.function.BiFunction; import java.util.function.Function; @@ -5735,6 +5738,78 @@ public void testSimplifyComparisonArithmeticSkippedOnFloats() { } } + public void testReplaceStringCasingWithInsensitiveEqualsUpperFalse() { + var plan = optimizedPlan("FROM test | WHERE TO_UPPER(first_name) == \"VALÜe\""); + var local = as(plan, LocalRelation.class); + assertThat(local.supplier(), equalTo(LocalSupplier.EMPTY)); + } + + public void testReplaceStringCasingWithInsensitiveEqualsUpperTrue() { + var plan = optimizedPlan("FROM test | WHERE TO_UPPER(first_name) != \"VALÜe\""); + var limit = as(plan, Limit.class); + var filter = as(limit.child(), Filter.class); + var isNotNull = as(filter.condition(), IsNotNull.class); + assertThat(Expressions.name(isNotNull.field()), is("first_name")); + as(filter.child(), EsRelation.class); + } + + public void testReplaceStringCasingWithInsensitiveEqualsLowerFalse() { + var plan = optimizedPlan("FROM test | WHERE TO_LOWER(first_name) == \"VALÜe\""); + var local = as(plan, LocalRelation.class); + assertThat(local.supplier(), equalTo(LocalSupplier.EMPTY)); + } + + public void testReplaceStringCasingWithInsensitiveEqualsLowerTrue() { + var plan = optimizedPlan("FROM test | WHERE TO_LOWER(first_name) != \"VALÜe\""); + var limit = as(plan, Limit.class); + var filter = as(limit.child(), Filter.class); + assertThat(filter.condition(), instanceOf(IsNotNull.class)); + as(filter.child(), EsRelation.class); + } + + public void testReplaceStringCasingWithInsensitiveEqualsEquals() { + for (var fn : List.of("TO_LOWER", "TO_UPPER")) { + var value = fn.equals("TO_LOWER") ? fn.toLowerCase(Locale.ROOT) : fn.toUpperCase(Locale.ROOT); + value += "🐔✈🔥🎉"; // these should not cause folding, they're not in the upper/lower char class + var plan = optimizedPlan("FROM test | WHERE " + fn + "(first_name) == \"" + value + "\""); + var limit = as(plan, Limit.class); + var filter = as(limit.child(), Filter.class); + var insensitive = as(filter.condition(), InsensitiveEquals.class); + as(insensitive.left(), FieldAttribute.class); + var bRef = as(insensitive.right().fold(), BytesRef.class); + assertThat(bRef.utf8ToString(), is(value)); + as(filter.child(), EsRelation.class); + } + } + + public void testReplaceStringCasingWithInsensitiveEqualsNotEquals() { + for (var fn : List.of("TO_LOWER", "TO_UPPER")) { + var value = fn.equals("TO_LOWER") ? fn.toLowerCase(Locale.ROOT) : fn.toUpperCase(Locale.ROOT); + value += "🐔✈🔥🎉"; // these should not cause folding, they're not in the upper/lower char class + var plan = optimizedPlan("FROM test | WHERE " + fn + "(first_name) != \"" + value + "\""); + var limit = as(plan, Limit.class); + var filter = as(limit.child(), Filter.class); + var not = as(filter.condition(), Not.class); + var insensitive = as(not.field(), InsensitiveEquals.class); + as(insensitive.left(), FieldAttribute.class); + var bRef = as(insensitive.right().fold(), BytesRef.class); + assertThat(bRef.utf8ToString(), is(value)); + as(filter.child(), EsRelation.class); + } + } + + public void testReplaceStringCasingWithInsensitiveEqualsUnwrap() { + var plan = optimizedPlan("FROM test | WHERE TO_UPPER(TO_LOWER(TO_UPPER(first_name))) == \"VALÜ\""); + var limit = as(plan, Limit.class); + var filter = as(limit.child(), Filter.class); + var insensitive = as(filter.condition(), InsensitiveEquals.class); + var field = as(insensitive.left(), FieldAttribute.class); + assertThat(field.fieldName(), is("first_name")); + var bRef = as(insensitive.right().fold(), BytesRef.class); + assertThat(bRef.utf8ToString(), is("VALÜ")); + as(filter.child(), EsRelation.class); + } + @Override protected List filteredWarnings() { return withDefaultLimitWarning(super.filteredWarnings()); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java index 5ab45c8c5f383..ac56d13f870f7 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java @@ -174,6 +174,7 @@ import static org.elasticsearch.xpack.esql.core.type.DataType.CARTESIAN_SHAPE; import static org.elasticsearch.xpack.esql.core.type.DataType.GEO_POINT; import static org.elasticsearch.xpack.esql.core.type.DataType.GEO_SHAPE; +import static org.elasticsearch.xpack.esql.core.util.TestUtils.stripThrough; import static org.elasticsearch.xpack.esql.parser.ExpressionBuilder.MAX_EXPRESSION_DEPTH; import static org.elasticsearch.xpack.esql.parser.LogicalPlanBuilder.MAX_QUERY_DEPTH; import static org.hamcrest.Matchers.closeTo; @@ -1803,7 +1804,7 @@ public void testPushDownEqualsIgnoreCase() { assertThat(source.estimatedRowSize(), equalTo(allFieldRowSize + Integer.BYTES)); QueryBuilder query = source.query(); - assertNotNull(query); + assertNotNull(query); // TODO: verify query } /** @@ -1838,7 +1839,257 @@ public void testNoPushDownEvalEqualsIgnoreCase() { var source = source(extract.child()); QueryBuilder query = source.query(); - assertNull(query); + assertNull(query); // TODO: verify query + } + + public void testPushDownEqualsToUpper() { + doTestPushDownChangeCase(""" + from test + | where to_upper(first_name) == "FOO" + """, """ + { + "esql_single_value" : { + "field" : "first_name", + "next" : { + "term" : { + "first_name" : { + "value" : "FOO", + "case_insensitive" : true + } + } + }, + "source" : "to_upper(first_name) == \\"FOO\\"@2:9" + } + }"""); + } + + /* + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[_meta_field{f}#9, emp_no{f}#3, first_name{f}#4, gender{f}#5, hire_date{f}#10, job{f}#11, job.raw{f}#12, + * languages{f}#6, last_name{f}#7, long_noidx{f}#13, salary{f}#8],false] + * \_ProjectExec[[_meta_field{f}#9, emp_no{f}#3, first_name{f}#4, gender{f}#5, hire_date{f}#10, job{f}#11, job.raw{f}#12, + * languages{f}#6, last_name{f}#7, long_noidx{f}#13, salary{f}#8]] + * \_FieldExtractExec[_meta_field{f}#9, emp_no{f}#3, first_name{f}#4, gen..]<[]> + * \_EsQueryExec[test], indexMode[standard], query[{...}}][_doc{f}#25], limit[1000], sort[] estimatedRowSize[332] + */ + private void doTestPushDownChangeCase(String esql, String expected) { + var plan = physicalPlan(esql); + var optimized = optimizedPlan(plan); + var topLimit = as(optimized, LimitExec.class); + var exchange = asRemoteExchange(topLimit.child()); + var project = as(exchange.child(), ProjectExec.class); + var extractRest = as(project.child(), FieldExtractExec.class); + var source = source(extractRest.child()); + assertThat(source.estimatedRowSize(), equalTo(allFieldRowSize + Integer.BYTES)); + + QueryBuilder query = source.query(); + assertThat(stripThrough(query.toString()), is(stripThrough(expected))); + } + + public void testPushDownEqualsToLower() { + doTestPushDownChangeCase(""" + from test + | where to_lower(first_name) == "foo" + """, """ + { + "esql_single_value" : { + "field" : "first_name", + "next" : { + "term" : { + "first_name" : { + "value" : "foo", + "case_insensitive" : true + } + } + }, + "source" : "to_lower(first_name) == \\"foo\\"@2:9" + } + }"""); + } + + public void testPushDownNotEqualsToUpper() { + doTestPushDownChangeCase(""" + from test + | where to_upper(first_name) != "FOO" + """, """ + { + "esql_single_value" : { + "field" : "first_name", + "next" : { + "bool" : { + "must_not" : [ + { + "term" : { + "first_name" : { + "value" : "FOO", + "case_insensitive" : true + } + } + } + ], + "boost" : 1.0 + } + }, + "source" : "to_upper(first_name) != \\"FOO\\"@2:9" + } + }"""); + } + + public void testPushDownNotEqualsToLower() { + doTestPushDownChangeCase(""" + from test + | where to_lower(first_name) != "foo" + """, """ + { + "esql_single_value" : { + "field" : "first_name", + "next" : { + "bool" : { + "must_not" : [ + { + "term" : { + "first_name" : { + "value" : "foo", + "case_insensitive" : true + } + } + } + ], + "boost" : 1.0 + } + }, + "source" : "to_lower(first_name) != \\"foo\\"@2:9" + } + }"""); + } + + public void testPushDownChangeCaseMultiplePredicates() { + doTestPushDownChangeCase(""" + from test + | where to_lower(first_name) != "foo" or to_upper(first_name) == "FOO" or emp_no > 10 + """, """ + { + "bool" : { + "should" : [ + { + "esql_single_value" : { + "field" : "first_name", + "next" : { + "bool" : { + "must_not" : [ + { + "term" : { + "first_name" : { + "value" : "foo", + "case_insensitive" : true + } + } + } + ], + "boost" : 1.0 + } + }, + "source" : "to_lower(first_name) != \\"foo\\"@2:9" + } + }, + { + "esql_single_value" : { + "field" : "first_name", + "next" : { + "term" : { + "first_name" : { + "value" : "FOO", + "case_insensitive" : true + } + } + }, + "source" : "to_upper(first_name) == \\"FOO\\"@2:42" + } + }, + { + "esql_single_value" : { + "field" : "emp_no", + "next" : { + "range" : { + "emp_no" : { + "gt" : 10, + "boost" : 1.0 + } + } + }, + "source" : "emp_no > 10@2:75" + } + } + ], + "boost" : 1.0 + } + } + """); + } + + // same tree as with doTestPushDownChangeCase(), but with a topping EvalExec (for `x`) + public void testPushDownChangeCaseThroughEval() { + var esql = """ + from test + | eval x = first_name + | where to_lower(x) == "foo" + """; + var plan = physicalPlan(esql); + var optimized = optimizedPlan(plan); + var eval = as(optimized, EvalExec.class); + var topLimit = as(eval.child(), LimitExec.class); + var exchange = asRemoteExchange(topLimit.child()); + var project = as(exchange.child(), ProjectExec.class); + var extractRest = as(project.child(), FieldExtractExec.class); + var source = source(extractRest.child()); + + var expected = """ + { + "esql_single_value" : { + "field" : "first_name", + "next" : { + "term" : { + "first_name" : { + "value" : "foo", + "case_insensitive" : true + } + } + }, + "source" : "to_lower(x) == \\"foo\\"@3:9" + } + }"""; + QueryBuilder query = source.query(); + assertThat(stripThrough(query.toString()), is(stripThrough(expected))); + } + + /* + * LimitExec[1000[INTEGER]] + * \_ExchangeExec[[_meta_field{f}#9, emp_no{f}#3, first_name{f}#4, gender{f}#5, hire_date{f}#10, job{f}#11, job.raw{f}#12, + * languages{f}#6, last_name{f}#7, long_noidx{f}#13, salary{f}#8],false] + * \_ProjectExec[[_meta_field{f}#9, emp_no{f}#3, first_name{f}#4, gender{f}#5, hire_date{f}#10, job{f}#11, job.raw{f}#12, + * languages{f}#6, last_name{f}#7, long_noidx{f}#13, salary{f}#8]] + * \_FieldExtractExec[_meta_field{f}#9, emp_no{f}#3, gender{f}#5, hire_da..]<[]> + * \_LimitExec[1000[INTEGER]] + * \_FilterExec[NOT(INSENSITIVEEQUALS(CONCAT(first_name{f}#4,[66 6f 6f][KEYWORD]),[66 6f 6f][KEYWORD]))] + * \_FieldExtractExec[first_name{f}#4]<[]> + * \_EsQueryExec[test], indexMode[standard], query[][_doc{f}#25], limit[], sort[] estimatedRowSize[332] + */ + public void testNoPushDownChangeCase() { + var plan = physicalPlan(""" + from test + | where to_lower(concat(first_name, "foo")) != "foo" + """); + + var optimized = optimizedPlan(plan); + var topLimit = as(optimized, LimitExec.class); + var exchange = asRemoteExchange(topLimit.child()); + var project = as(exchange.child(), ProjectExec.class); + var fieldExtract = as(project.child(), FieldExtractExec.class); + var limit = as(fieldExtract.child(), LimitExec.class); + var filter = as(limit.child(), FilterExec.class); + var fieldExtract2 = as(filter.child(), FieldExtractExec.class); + var source = source(fieldExtract2.child()); + assertThat(source.query(), nullValue()); } public void testPushDownNotRLike() {