diff --git a/docs/changelog/122601.yaml b/docs/changelog/122601.yaml new file mode 100644 index 0000000000000..11f44a806917d --- /dev/null +++ b/docs/changelog/122601.yaml @@ -0,0 +1,6 @@ +pr: 122601 +summary: Implicit numeric casting for CASE/GREATEST/LEAST +area: ES|QL +type: bug +issues: + - 121890 diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/conditional.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/conditional.csv-spec index 9177fcbcd2afb..8c186484b7361 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/conditional.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/conditional.csv-spec @@ -281,3 +281,111 @@ languages:integer| emp_no:integer|eval:keyword null |10020 |languages is null null |10021 |languages is null ; + +caseWithMixedNumericValue +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| WHERE emp_no >= 10005 AND emp_no <= 10010 +| EVAL g = case(gender == "F", 1.0, gender == "M", 2, 3.0) +| KEEP emp_no, gender, g +| SORT emp_no +; + +emp_no:integer | gender:keyword | g:double +10005 | M | 2.0 +10006 | F | 1.0 +10007 | F | 1.0 +10008 | M | 2.0 +10009 | F | 1.0 +10010 | null | 3.0 +; + +caseWithMixedNumericValueWithNull +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| WHERE emp_no >= 10005 AND emp_no <= 10010 +| EVAL g = case(gender == "F", 1.0, gender == "M", 2, null) +| KEEP emp_no, gender, g +| SORT emp_no +; + +emp_no:integer | gender:keyword | g:double +10005 | M | 2.0 +10006 | F | 1.0 +10007 | F | 1.0 +10008 | M | 2.0 +10009 | F | 1.0 +10010 | null | null +; + +caseWithMixedNumericField +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| WHERE emp_no >= 10005 AND emp_no <= 10010 +| EVAL g = case(gender == "F", height, gender == "M", salary, languages) +| KEEP emp_no, gender, g +| SORT emp_no +; + +emp_no:integer | gender:keyword | g:double +10005 | M | 63528.0 +10006 | F | 1.56 +10007 | F | 1.7 +10008 | M | 43906.0 +10009 | F | 1.85 +10010 | null | 4.0 +; + +caseWithMixedNumericFieldWithNull +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| WHERE emp_no >= 10005 AND emp_no <= 10010 +| EVAL g = case(gender == "F", height, gender == "M", salary, null) +| KEEP emp_no, gender, g +| SORT emp_no +; + +emp_no:integer | gender:keyword | g:double +10005 | M | 63528.0 +10006 | F | 1.56 +10007 | F | 1.7 +10008 | M | 43906.0 +10009 | F | 1.85 +10010 | null | null +; + +caseWithMixedNumericFieldWithMV +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| WHERE emp_no >= 10005 AND emp_no <= 10010 +| EVAL g = case(gender == "F", salary_change, gender == "M", salary, languages) +| KEEP emp_no, gender, g +| SORT emp_no +; + +emp_no:integer | gender:keyword | g:double +10005 | M | 63528.0 +10006 | F | -3.9 +10007 | F | [-7.06, 0.57, 1.99] +10008 | M | 43906.0 +10009 | F | null +10010 | null | 4.0 +; + +caseWithMixedNumericFieldWithNullWithMV +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| WHERE emp_no >= 10005 AND emp_no <= 10010 +| EVAL g = case(gender == "F", salary_change, gender == "M", salary, null) +| KEEP emp_no, gender, g +| SORT emp_no +; + +emp_no:integer | gender:keyword | g:double +10005 | M | 63528.0 +10006 | F | -3.9 +10007 | F | [-7.06, 0.57, 1.99] +10008 | M | 43906.0 +10009 | F | null +10010 | null | null +; diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/math.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/math.csv-spec index b2b4f15860484..56486b8954abe 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/math.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/math.csv-spec @@ -1583,3 +1583,95 @@ emp_no: integer | x:date | y:date 10001 | 2024-11-03 | 2024-11-06 10002 | 2024-11-03 | 2024-11-06 ; + +greatestWithMixedNumericValues +required_capability: mixed_numeric_types_in_case_greatest_least +ROW g1=GREATEST(10.0, 5.0, 1, -100.1, 0, 1234, -10000), g2=GREATEST(10.0, 5, 1, -100.1, null); + +g1:double |g2:double +1234 |null +; + +leastWithMixedNumericValues +required_capability: mixed_numeric_types_in_case_greatest_least +ROW l1=LEAST(10.0, 5.0, 1, -100.1, 0, 1234, -10000), l2=LEAST(10.0, 5, 1, -100.1, null); + +l1:double |l2:double +-10000 |null +; + +greatestWithMixedNumericFields +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| EVAL g1 = GREATEST(height, salary, languages), g2 = GREATEST(height, salary, languages, null) +| KEEP emp_no, g1, g2 +| SORT emp_no +| LIMIT 3 +; + +emp_no:integer | g1:double | g2:double +10001 | 57305.0 | null +10002 | 56371.0 | null +10003 | 61805.0 | null +; + +leastWithMixedNumericFields +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| EVAL l1 = LEAST(height, salary, languages), l2 = LEAST(height, salary, languages, null) +| KEEP emp_no, l1, l2 +| SORT emp_no +| LIMIT 3 +; + +emp_no:integer | l1:double | l2:double +10001 | 2.0 | null +10002 | 2.08 | null +10003 | 1.83 | null +; + +greatestWithMixedNumericValuesWithMV +required_capability: mixed_numeric_types_in_case_greatest_least +ROW g1=GREATEST([10.0, 4], 1), g2=GREATEST([10.0, 4], 1, null); + +g1:double |g2:double +10 |null +; + +leastWithMixedNumericValuesWithMV +required_capability: mixed_numeric_types_in_case_greatest_least +ROW l1=LEAST([10.0, 4], 1), l2=LEAST([10.0, 4], 1, null); + +l1:double |l2:double +1 |null +; + +greatestWithMixedNumericFieldsWithMV +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| EVAL g1 = GREATEST(salary_change, salary, languages), g2 = GREATEST(salary_change, salary, languages, null) +| KEEP emp_no, g1, g2 +| SORT emp_no +| LIMIT 3 +; + +emp_no:integer | g1:double | g2:double +10001 | 57305.0 | null +10002 | 56371.0 | null +10003 | 61805.0 | null +; + +leastWithMixedNumericFieldsWithMV +required_capability: mixed_numeric_types_in_case_greatest_least +FROM employees +| EVAL l1 = LEAST(salary_change, salary, languages), l2 = LEAST(salary_change, salary, languages, null) +| KEEP emp_no, l1, l2 +| SORT emp_no +| LIMIT 3 +; + +emp_no:integer | l1:double | l2:double +10001 | 1.19 | null +10002 | -7.23 | null +10003 | 4.0 | null +; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java index 072b3e214d72c..980e5402ea560 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java @@ -840,7 +840,12 @@ public enum Cap { /** * Support for FORK command */ - FORK(Build.current().isSnapshot()); + FORK(Build.current().isSnapshot()), + + /** + * Allow mixed numeric types in conditional functions - case, greatest and least + */ + MIXED_NUMERIC_TYPES_IN_CASE_GREATEST_LEAST; private final boolean enabled; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java index 589826501423b..c8df71a0e1753 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java @@ -52,6 +52,9 @@ import org.elasticsearch.xpack.esql.expression.function.UnsupportedAttribute; import org.elasticsearch.xpack.esql.expression.function.grouping.GroupingFunction; import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlScalarFunction; +import org.elasticsearch.xpack.esql.expression.function.scalar.conditional.Case; +import org.elasticsearch.xpack.esql.expression.function.scalar.conditional.Greatest; +import org.elasticsearch.xpack.esql.expression.function.scalar.conditional.Least; import org.elasticsearch.xpack.esql.expression.function.scalar.convert.AbstractConvertFunction; import org.elasticsearch.xpack.esql.expression.function.scalar.convert.FoldablesConvertFunction; import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToDouble; @@ -1314,7 +1317,7 @@ private static Expression processIn(In in) { } private static boolean canCastMixedNumericTypes(org.elasticsearch.xpack.esql.core.expression.function.Function f) { - return f instanceof Coalesce; + return f instanceof Coalesce || f instanceof Case || f instanceof Greatest || f instanceof Least; } private static boolean canCastNumeric(DataType from, DataType to) { diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java index 2c76f76a67f77..b63a41faa9006 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java @@ -2330,13 +2330,37 @@ public void testRateRequiresCounterTypes() { ); } - public void testCoalesceWithMixedNumericTypes() { + public void testConditionalFunctionsWithMixedNumericTypes() { LogicalPlan plan = analyze(""" from test | eval x = coalesce(salary_change, null, 0), y = coalesce(languages, null, 0), z = coalesce(languages.long, null, 0) , w = coalesce(salary_change, null, 0::long) | keep x, y, z, w """, "mapping-default.json"); + validateConditionalFunctions(plan); + + plan = analyze(""" + from test + | eval x = case(languages == 1, salary_change, languages == 2, salary, languages == 3, salary_change.long, 0) + , y = case(languages == 1, salary_change.int, languages == 2, salary, 0) + , z = case(languages == 1, salary_change.long, languages == 2, salary, 0::long) + , w = case(languages == 1, salary_change, languages == 2, salary, languages == 3, salary_change.long, null) + | keep x, y, z, w + """, "mapping-default.json"); + validateConditionalFunctions(plan); + + plan = analyze(""" + from test + | eval x = greatest(salary_change, salary, salary_change.long) + , y = least(salary_change.int, salary) + , z = greatest(salary_change.long, salary, null) + , w = least(null, salary_change, salary_change.long, salary, null) + | keep x, y, z, w + """, "mapping-default.json"); + validateConditionalFunctions(plan); + } + + private void validateConditionalFunctions(LogicalPlan plan) { var limit = as(plan, Limit.class); var esqlProject = as(limit.child(), EsqlProject.class); List projections = esqlProject.projections(); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java index 71d36ce0ffcf8..76e63aa853e2b 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java @@ -1671,108 +1671,162 @@ public void testTermTargetsExistingField() throws Exception { assertEquals("1:38: Unknown column [first_name]", error("from test | keep emp_no | where term(first_name, \"Anna\")")); } - public void testCoalesceWithMixedNumericTypes() { - assertEquals( - "1:22: second argument of [coalesce(languages, height)] must be [integer], found value [height] type [double]", - error("from test | eval x = coalesce(languages, height)") - ); - assertEquals( - "1:22: second argument of [coalesce(languages.long, height)] must be [long], found value [height] type [double]", - error("from test | eval x = coalesce(languages.long, height)") - ); - assertEquals( - "1:22: second argument of [coalesce(salary, languages.long)] must be [integer], found value [languages.long] type [long]", - error("from test | eval x = coalesce(salary, languages.long)") - ); - assertEquals( - "1:22: second argument of [coalesce(languages.short, height)] must be [integer], found value [height] type [double]", - error("from test | eval x = coalesce(languages.short, height)") - ); - assertEquals( - "1:22: second argument of [coalesce(languages.byte, height)] must be [integer], found value [height] type [double]", - error("from test | eval x = coalesce(languages.byte, height)") - ); - assertEquals( - "1:22: second argument of [coalesce(languages, height.float)] must be [integer], found value [height.float] type [double]", - error("from test | eval x = coalesce(languages, height.float)") - ); - assertEquals( - "1:22: second argument of [coalesce(languages, height.scaled_float)] must be [integer], " - + "found value [height.scaled_float] type [double]", - error("from test | eval x = coalesce(languages, height.scaled_float)") - ); - assertEquals( - "1:22: second argument of [coalesce(languages, height.half_float)] must be [integer], " - + "found value [height.half_float] type [double]", - error("from test | eval x = coalesce(languages, height.half_float)") - ); + public void testConditionalFunctionsWithMixedNumericTypes() { + for (String functionName : List.of("coalesce", "greatest", "least")) { + assertEquals( + "1:22: second argument of [" + functionName + "(languages, height)] must be [integer], found value [height] type [double]", + error("from test | eval x = " + functionName + "(languages, height)") + ); + assertEquals( + "1:22: second argument of [" + + functionName + + "(languages.long, height)] must be [long], found value [height] type [double]", + error("from test | eval x = " + functionName + "(languages.long, height)") + ); + assertEquals( + "1:22: second argument of [" + + functionName + + "(salary, languages.long)] must be [integer], found value [languages.long] type [long]", + error("from test | eval x = " + functionName + "(salary, languages.long)") + ); + assertEquals( + "1:22: second argument of [" + + functionName + + "(languages.short, height)] must be [integer], found value [height] type [double]", + error("from test | eval x = " + functionName + "(languages.short, height)") + ); + assertEquals( + "1:22: second argument of [" + + functionName + + "(languages.byte, height)] must be [integer], found value [height] type [double]", + error("from test | eval x = " + functionName + "(languages.byte, height)") + ); + assertEquals( + "1:22: second argument of [" + + functionName + + "(languages, height.float)] must be [integer], found value [height.float] type [double]", + error("from test | eval x = " + functionName + "(languages, height.float)") + ); + assertEquals( + "1:22: second argument of [" + + functionName + + "(languages, height.scaled_float)] must be [integer], " + + "found value [height.scaled_float] type [double]", + error("from test | eval x = " + functionName + "(languages, height.scaled_float)") + ); + assertEquals( + "1:22: second argument of [" + + functionName + + "(languages, height.half_float)] must be [integer], " + + "found value [height.half_float] type [double]", + error("from test | eval x = " + functionName + "(languages, height.half_float)") + ); - assertEquals( - "1:22: third argument of [coalesce(null, languages, height)] must be [integer], found value [height] type [double]", - error("from test | eval x = coalesce(null, languages, height)") - ); - assertEquals( - "1:22: third argument of [coalesce(null, languages.long, height)] must be [long], found value [height] type [double]", - error("from test | eval x = coalesce(null, languages.long, height)") - ); - assertEquals( - "1:22: third argument of [coalesce(null, salary, languages.long)] must be [integer], " - + "found value [languages.long] type [long]", - error("from test | eval x = coalesce(null, salary, languages.long)") - ); - assertEquals( - "1:22: third argument of [coalesce(null, languages.short, height)] must be [integer], found value [height] type [double]", - error("from test | eval x = coalesce(null, languages.short, height)") - ); - assertEquals( - "1:22: third argument of [coalesce(null, languages.byte, height)] must be [integer], found value [height] type [double]", - error("from test | eval x = coalesce(null, languages.byte, height)") - ); - assertEquals( - "1:22: third argument of [coalesce(null, languages, height.float)] must be [integer], " - + "found value [height.float] type [double]", - error("from test | eval x = coalesce(null, languages, height.float)") - ); - assertEquals( - "1:22: third argument of [coalesce(null, languages, height.scaled_float)] must be [integer], " - + "found value [height.scaled_float] type [double]", - error("from test | eval x = coalesce(null, languages, height.scaled_float)") - ); - assertEquals( - "1:22: third argument of [coalesce(null, languages, height.half_float)] must be [integer], " - + "found value [height.half_float] type [double]", - error("from test | eval x = coalesce(null, languages, height.half_float)") - ); + assertEquals( + "1:22: third argument of [" + + functionName + + "(null, languages, height)] must be [integer], found value [height] type [double]", + error("from test | eval x = " + functionName + "(null, languages, height)") + ); + assertEquals( + "1:22: third argument of [" + + functionName + + "(null, languages.long, height)] must be [long], found value [height] type [double]", + error("from test | eval x = " + functionName + "(null, languages.long, height)") + ); + assertEquals( + "1:22: third argument of [" + + functionName + + "(null, salary, languages.long)] must be [integer], " + + "found value [languages.long] type [long]", + error("from test | eval x = " + functionName + "(null, salary, languages.long)") + ); + assertEquals( + "1:22: third argument of [" + + functionName + + "(null, languages.short, height)] must be [integer], found value [height] type [double]", + error("from test | eval x = " + functionName + "(null, languages.short, height)") + ); + assertEquals( + "1:22: third argument of [" + + functionName + + "(null, languages.byte, height)] must be [integer], found value [height] type [double]", + error("from test | eval x = " + functionName + "(null, languages.byte, height)") + ); + assertEquals( + "1:22: third argument of [" + + functionName + + "(null, languages, height.float)] must be [integer], " + + "found value [height.float] type [double]", + error("from test | eval x = " + functionName + "(null, languages, height.float)") + ); + assertEquals( + "1:22: third argument of [" + + functionName + + "(null, languages, height.scaled_float)] must be [integer], " + + "found value [height.scaled_float] type [double]", + error("from test | eval x = " + functionName + "(null, languages, height.scaled_float)") + ); + assertEquals( + "1:22: third argument of [" + + functionName + + "(null, languages, height.half_float)] must be [integer], " + + "found value [height.half_float] type [double]", + error("from test | eval x = " + functionName + "(null, languages, height.half_float)") + ); - // counter - assertEquals( - "1:23: second argument of [coalesce(network.bytes_in, 0)] must be [counter_long], found value [0] type [integer]", - error("FROM tests | eval x = coalesce(network.bytes_in, 0)", tsdb) - ); + // counter + assertEquals( + "1:23: second argument of [" + + functionName + + "(network.bytes_in, 0)] must be [counter_long], found value [0] type [integer]", + error("FROM tests | eval x = " + functionName + "(network.bytes_in, 0)", tsdb) + ); - assertEquals( - "1:23: second argument of [coalesce(network.bytes_in, to_long(0))] must be [counter_long], " - + "found value [to_long(0)] type [long]", - error("FROM tests | eval x = coalesce(network.bytes_in, to_long(0))", tsdb) - ); - assertEquals( - "1:23: second argument of [coalesce(network.bytes_in, 0.0)] must be [counter_long], found value [0.0] type [double]", - error("FROM tests | eval x = coalesce(network.bytes_in, 0.0)", tsdb) - ); + assertEquals( + "1:23: second argument of [" + + functionName + + "(network.bytes_in, to_long(0))] must be [counter_long], " + + "found value [to_long(0)] type [long]", + error("FROM tests | eval x = " + functionName + "(network.bytes_in, to_long(0))", tsdb) + ); + assertEquals( + "1:23: second argument of [" + + functionName + + "(network.bytes_in, 0.0)] must be [counter_long], found value [0.0] type [double]", + error("FROM tests | eval x = " + functionName + "(network.bytes_in, 0.0)", tsdb) + ); - assertEquals( - "1:23: third argument of [coalesce(null, network.bytes_in, 0)] must be [counter_long], found value [0] type [integer]", - error("FROM tests | eval x = coalesce(null, network.bytes_in, 0)", tsdb) - ); + assertEquals( + "1:23: third argument of [" + + functionName + + "(null, network.bytes_in, 0)] must be [counter_long], found value [0] type [integer]", + error("FROM tests | eval x = " + functionName + "(null, network.bytes_in, 0)", tsdb) + ); + + assertEquals( + "1:23: third argument of [" + + functionName + + "(null, network.bytes_in, to_long(0))] must be [counter_long], " + + "found value [to_long(0)] type [long]", + error("FROM tests | eval x = " + functionName + "(null, network.bytes_in, to_long(0))", tsdb) + ); + assertEquals( + "1:23: third argument of [" + + functionName + + "(null, network.bytes_in, 0.0)] must be [counter_long], found value [0.0] type [double]", + error("FROM tests | eval x = " + functionName + "(null, network.bytes_in, 0.0)", tsdb) + ); + } + // case, a subset tests of coalesce/greatest/least assertEquals( - "1:23: third argument of [coalesce(null, network.bytes_in, to_long(0))] must be [counter_long], " - + "found value [to_long(0)] type [long]", - error("FROM tests | eval x = coalesce(null, network.bytes_in, to_long(0))", tsdb) + "1:22: third argument of [case(languages == 1, salary, height)] must be [integer], found value [height] type [double]", + error("from test | eval x = case(languages == 1, salary, height)") ); assertEquals( - "1:23: third argument of [coalesce(null, network.bytes_in, 0.0)] must be [counter_long], found value [0.0] type [double]", - error("FROM tests | eval x = coalesce(null, network.bytes_in, 0.0)", tsdb) + "1:23: third argument of [case(name == \"a\", network.bytes_in, 0)] must be [counter_long], found value [0] type [integer]", + error("FROM tests | eval x = case(name == \"a\", network.bytes_in, 0)", tsdb) ); }