From 8515f961f806f4ccd3984c009623e1ed9835522f Mon Sep 17 00:00:00 2001 From: Chayanin Noramuttha Date: Tue, 13 Oct 2026 21:21:37 -0700 Subject: [PATCH] Fix 500 from coalesce, mvappend, array and percentile_approx types Four functions built a result whose Java class did not match the class Calcite casts it to for the declared type. coalesce built an Integer for SMALLINT and TINYINT and a Float for Calcite FLOAT. mvappend and array built Float elements for Calcite FLOAT. percentile_approx, which median also uses, returned a Double for SMALLINT, TINYINT and REAL and a Float for FLOAT. The cast threw, so the query returned 500 with a ClassCastException. Build each value with the class Calcite expects. Calcite's FLOAT is double precision and REAL is single precision, so FLOAT now shares the DOUBLE arm and REAL keeps the float. Signed-off-by: Chayanin Noramuttha --- .../udf/udaf/PercentileApproxFunction.java | 11 +- .../CollectionUDF/ArrayFunctionImpl.java | 9 +- .../function/CollectionUDF/MVAppendCore.java | 5 +- .../CollectionUDF/MVAppendFunctionImpl.java | 5 +- .../condition/EnhancedCoalesceFunction.java | 11 +- .../CollectionUDF/ArrayFunctionImplTest.java | 5 +- .../rest-api-spec/test/issues/5873.yml | 280 ++++++++++++++++++ 7 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 integ-test/src/yamlRestTest/resources/rest-api-spec/test/issues/5873.yml diff --git a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/PercentileApproxFunction.java b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/PercentileApproxFunction.java index 613291dcce1..edd3b749b54 100644 --- a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/PercentileApproxFunction.java +++ b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/PercentileApproxFunction.java @@ -58,7 +58,16 @@ public Object result(PencentileApproAccumulator acc) { case BIGINT: long longRet = (long) retValue; return longRet; - case FLOAT: + case SMALLINT: + short shortRet = (short) retValue; + return shortRet; + case TINYINT: + byte byteRet = (byte) retValue; + return byteRet; + // Calcite's FLOAT is double precision, and REAL is single precision + case DOUBLE, FLOAT: + return retValue; + case REAL: float floatRet = (float) retValue; return floatRet; default: diff --git a/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayFunctionImpl.java b/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayFunctionImpl.java index 318f32a41be..86960333c7b 100644 --- a/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayFunctionImpl.java +++ b/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayFunctionImpl.java @@ -119,18 +119,13 @@ public static Object internalCast(Object... args) { }) .collect(Collectors.toList()); break; - case DOUBLE: + // Calcite's FLOAT is double precision, and REAL is single precision + case DOUBLE, FLOAT: result = originalList.stream() .map(i -> i == null ? null : (Object) ((Number) i).doubleValue()) .collect(Collectors.toList()); break; - case FLOAT: - result = - originalList.stream() - .map(i -> i == null ? null : (Object) ((Number) i).floatValue()) - .collect(Collectors.toList()); - break; case VARCHAR, CHAR: result = originalList.stream() diff --git a/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/MVAppendCore.java b/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/MVAppendCore.java index c37189cb20b..a98d8c900c3 100644 --- a/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/MVAppendCore.java +++ b/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/MVAppendCore.java @@ -67,8 +67,9 @@ private static Object coerce(Object value, SqlTypeName elementType) { case SMALLINT -> num.shortValue(); case INTEGER -> num.intValue(); case BIGINT -> num.longValue(); - case FLOAT, REAL -> num.floatValue(); - case DOUBLE -> num.doubleValue(); + // Calcite's FLOAT is double precision, and REAL is single precision + case DOUBLE, FLOAT -> num.doubleValue(); + case REAL -> num.floatValue(); case DECIMAL -> num instanceof BigDecimal ? num : BigDecimal.valueOf(num.doubleValue()); default -> value; }; diff --git a/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/MVAppendFunctionImpl.java b/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/MVAppendFunctionImpl.java index dc3402e464d..505518db8d1 100644 --- a/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/MVAppendFunctionImpl.java +++ b/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/MVAppendFunctionImpl.java @@ -159,8 +159,9 @@ private static Class boxedJavaClass(SqlTypeName sqlType) { case SMALLINT -> Short.class; case INTEGER -> Integer.class; case BIGINT -> Long.class; - case FLOAT, REAL -> Float.class; - case DOUBLE -> Double.class; + // Calcite's FLOAT is double precision, and REAL is single precision + case DOUBLE, FLOAT -> Double.class; + case REAL -> Float.class; case DECIMAL -> BigDecimal.class; case CHAR, VARCHAR -> String.class; default -> Object.class; diff --git a/core/src/main/java/org/opensearch/sql/expression/function/udf/condition/EnhancedCoalesceFunction.java b/core/src/main/java/org/opensearch/sql/expression/function/udf/condition/EnhancedCoalesceFunction.java index c6ff1a64478..cebbf20331e 100644 --- a/core/src/main/java/org/opensearch/sql/expression/function/udf/condition/EnhancedCoalesceFunction.java +++ b/core/src/main/java/org/opensearch/sql/expression/function/udf/condition/EnhancedCoalesceFunction.java @@ -63,11 +63,12 @@ private static ExprValue coerceToType(ExprValue value, String typeName) { return switch (typeName) { case "INTEGER" -> tryConvert(() -> ExprValueUtils.integerValue(value.integerValue()), value); case "BIGINT" -> tryConvert(() -> ExprValueUtils.longValue(value.longValue()), value); - case "SMALLINT", "TINYINT" -> - tryConvert(() -> ExprValueUtils.integerValue(value.integerValue()), value); - case "DOUBLE" -> tryConvert(() -> ExprValueUtils.doubleValue(value.doubleValue()), value); - case "FLOAT", "REAL" -> - tryConvert(() -> ExprValueUtils.floatValue(value.floatValue()), value); + case "SMALLINT" -> tryConvert(() -> ExprValueUtils.shortValue(value.shortValue()), value); + case "TINYINT" -> tryConvert(() -> ExprValueUtils.byteValue(value.byteValue()), value); + // Calcite's FLOAT is double precision, and REAL is single precision + case "DOUBLE", "FLOAT" -> + tryConvert(() -> ExprValueUtils.doubleValue(value.doubleValue()), value); + case "REAL" -> tryConvert(() -> ExprValueUtils.floatValue(value.floatValue()), value); case "BOOLEAN" -> tryConvert(() -> ExprValueUtils.booleanValue(value.booleanValue()), value); case "VARCHAR", "CHAR" -> tryConvert(() -> ExprValueUtils.stringValue(String.valueOf(value.value())), value); diff --git a/core/src/test/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayFunctionImplTest.java b/core/src/test/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayFunctionImplTest.java index 600a802615a..47aadb06299 100644 --- a/core/src/test/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayFunctionImplTest.java +++ b/core/src/test/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayFunctionImplTest.java @@ -281,9 +281,10 @@ public void testArrayWithFloatTypePreservesNulls() { assertNotNull(result); List list = (List) result; assertEquals(3, list.size()); - assertEquals(1.5f, list.get(0)); + // Calcite FLOAT is double precision, so float elements widen to double + assertEquals(1.5, list.get(0)); assertNull(list.get(1), "Null should be preserved during FLOAT type conversion"); - assertEquals(2.7f, list.get(2)); + assertEquals((double) 2.7f, list.get(2)); } @Test diff --git a/integ-test/src/yamlRestTest/resources/rest-api-spec/test/issues/5873.yml b/integ-test/src/yamlRestTest/resources/rest-api-spec/test/issues/5873.yml new file mode 100644 index 00000000000..0d55c7fb613 --- /dev/null +++ b/integ-test/src/yamlRestTest/resources/rest-api-spec/test/issues/5873.yml @@ -0,0 +1,280 @@ +# Issue: https://github.com/opensearch-project/sql/issues/5873 +# coalesce built its SMALLINT and TINYINT result as an Integer and its Calcite FLOAT result as a +# Float, mvappend and array built Calcite FLOAT elements as Float, and percentile_approx returned +# a Double for SMALLINT, TINYINT and Calcite REAL and a Float for Calcite FLOAT. The code Calcite +# generates, or Avatica's array accessor, then cast them to the declared class, so the query +# returned 500 with a ClassCastException. Expected coalesce values match ifnull on the same field. + +setup: + - do: + query.settings: + body: + transient: + plugins.calcite.enabled : true + plugins.calcite.pushdown.enabled : true + - do: + indices.create: + index: numeric_udf_result_5873 + body: + settings: + number_of_shards: 1 + number_of_replicas: 0 + mappings: + properties: + host: + type: keyword + s: + type: short + b: + type: byte + f: + type: float + - do: + bulk: + index: numeric_udf_result_5873 + refresh: true + body: + - '{"index": {}}' + - '{"host": "a", "s": 5, "b": 5, "f": 1.5}' + - '{"index": {}}' + - '{"host": "b"}' + +--- +teardown: + - do: + indices.delete: + index: numeric_udf_result_5873 + - do: + query.settings: + body: + transient: + plugins.calcite.enabled : null + plugins.calcite.pushdown.enabled : null + +--- +"coalesce on a short field returns smallint": + - skip: + features: + - headers + # Before the fix: status 500, Integer cannot be cast to Short + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | sort host | eval x = coalesce(s) | fields host, x + - match: {"schema": [{"name": "host", "type": "string"}, {"name": "x", "type": "smallint"}]} + - match: {"datarows": [["a", 5], ["b", null]]} + +--- +"coalesce on a byte field returns tinyint": + - skip: + features: + - headers + # Before the fix: status 500, Integer cannot be cast to Byte + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | sort host | eval x = coalesce(b) | fields host, x + - match: {"schema": [{"name": "host", "type": "string"}, {"name": "x", "type": "tinyint"}]} + - match: {"datarows": [["a", 5], ["b", null]]} + +--- +"coalesce on a byte and a short field returns smallint": + - skip: + features: + - headers + # Before the fix: status 500, Integer cannot be cast to Short + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | sort host | eval x = coalesce(b, s) | fields host, x + - match: {"schema": [{"name": "host", "type": "string"}, {"name": "x", "type": "smallint"}]} + - match: {"datarows": [["a", 5], ["b", null]]} + +--- +"coalesce on a short field in a pushed filter matches the row": + - skip: + features: + - headers + # Before the fix: status 500, the filter script failed on the shard + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | where coalesce(s) = 5 | fields host + - match: {"datarows": [["a"]]} + +--- +"coalesce on a float field and a float literal returns float": + - skip: + features: + - headers + # Before the fix: status 500, Float cannot be cast to Double + # A float field is Calcite REAL, so the float literal is what makes the result Calcite FLOAT + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | sort host | eval x = coalesce(f, 2.5F) | fields host, x + - match: {"schema": [{"name": "host", "type": "string"}, {"name": "x", "type": "float"}]} + - match: {"datarows": [["a", 1.5], ["b", 2.5]]} + +--- +"coalesce on a float field returns float": + - skip: + features: + - headers + # Control, a float field alone stays Calcite REAL and passed before the fix + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | sort host | eval x = coalesce(f) | fields host, x + - match: {"schema": [{"name": "host", "type": "string"}, {"name": "x", "type": "float"}]} + - match: {"datarows": [["a", 1.5], ["b", null]]} + +--- +"mvappend on a float field and a float literal returns both values": + - skip: + features: + - headers + # Before the fix: status 500, Float cannot be cast to Double + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | sort host | eval x = mvappend(f, 2.5F) | fields host, x + - match: {"schema": [{"name": "host", "type": "string"}, {"name": "x", "type": "array"}]} + - match: {"datarows": [["a", [1.5, 2.5]], ["b", [2.5]]]} + +--- +"mvappend on an array of a float field and a float literal returns both values": + - skip: + features: + - headers + # Before the fix: status 500, Float cannot be cast to Double + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | sort host | eval x = mvappend(array(f), 2.5F) | fields host, x + - match: {"schema": [{"name": "host", "type": "string"}, {"name": "x", "type": "array"}]} + - match: {"datarows": [["a", [1.5, 2.5]], ["b", [2.5]]]} + +--- +"array on a float field and a float literal returns both values": + - skip: + features: + - headers + # Before the fix: status 500, Float cannot be cast to Double + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | sort host | eval x = array(f, 2.5F) | fields host, x + - match: {"schema": [{"name": "host", "type": "string"}, {"name": "x", "type": "array"}]} + - match: {"datarows": [["a", [1.5, 2.5]], ["b", [null, 2.5]]]} + +--- +"percentile_approx on a short field in memory returns smallint": + - skip: + features: + - headers + # Before the fix: status 500, Double cannot be cast to Short + # max(lower(host)) cannot be pushed down, so the percentile runs in memory + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | stats percentile_approx(s, 50) as p, max(lower(host)) as m + - match: {"schema": [{"name": "p", "type": "smallint"}, {"name": "m", "type": "string"}]} + - match: {"datarows": [[5, "b"]]} + +--- +"percentile_approx on a float field in memory returns float": + - skip: + features: + - headers + # Before the fix: status 500, Double cannot be cast to Float + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | stats percentile_approx(f, 50) as p, max(lower(host)) as m + - match: {"schema": [{"name": "p", "type": "float"}, {"name": "m", "type": "string"}]} + - match: {"datarows": [[1.5, "b"]]} + +--- +"percentile_approx on a byte field in memory returns tinyint": + - skip: + features: + - headers + # Before the fix: status 500, Double cannot be cast to Byte + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | stats percentile_approx(b, 50) as p, max(lower(host)) as m + - match: {"schema": [{"name": "p", "type": "tinyint"}, {"name": "m", "type": "string"}]} + - match: {"datarows": [[5, "b"]]} + +--- +"percentile_approx on a float expression with a float literal in memory returns float": + - skip: + features: + - headers + # Before the fix: status 500, Float cannot be cast to Double + # The float literal makes g a Calcite FLOAT + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | eval g = f * 2.0F | stats percentile_approx(g, 50) as p, max(lower(host)) as m + - match: {"schema": [{"name": "p", "type": "float"}, {"name": "m", "type": "string"}]} + - match: {"datarows": [[3.0, "b"]]} + +--- +"median on a short field in memory returns smallint": + - skip: + features: + - headers + # Before the fix: status 500, Double cannot be cast to Short + # median runs through percentile_approx + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | stats median(s) as p, max(lower(host)) as m + - match: {"schema": [{"name": "p", "type": "smallint"}, {"name": "m", "type": "string"}]} + - match: {"datarows": [[5, "b"]]} + +--- +"coalesce on a short field and an int literal returns int": + - skip: + features: + - headers + # Control, the int literal widens the result to int and passed before the fix + - do: + headers: + Content-Type: 'application/json' + ppl: + body: + query: source=numeric_udf_result_5873 | sort host | eval x = coalesce(s, 0) | fields host, x + - match: {"schema": [{"name": "host", "type": "string"}, {"name": "x", "type": "int"}]} + - match: {"datarows": [["a", 5], ["b", 0]]}