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 107df5eea4..bafbeb09c4 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 @@ -7,7 +7,10 @@ import static org.apache.calcite.sql.type.SqlTypeUtil.createArrayType; +import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; +import org.apache.calcite.adapter.enumerable.EnumUtils; import org.apache.calcite.adapter.enumerable.NotNullImplementor; import org.apache.calcite.adapter.enumerable.NullPolicy; import org.apache.calcite.adapter.enumerable.RexToLixTranslator; @@ -78,25 +81,74 @@ private static RelDataType updateMostGeneralType( if (current == null) { return candidate; } - - if (!current.equals(candidate)) { - return typeFactory.createSqlType(SqlTypeName.ANY); - } else { + if (current.equals(candidate)) { return current; } + // Widen via Calcite's {@code leastRestrictive} — the same routine + // {@code SqlLibraryOperators.ARRAY} uses for its return-type inference. For genuinely + // incompatible operand types (INT + VARCHAR, …) it returns null; fall back to {@code ANY} + // there to preserve the in-process Calcite engine's {@code Object[]} runtime semantics + // that pre-existing tests rely on. Promote DECIMAL → DOUBLE on the way through: the row + // codec on the analytics-engine route maps DECIMAL cells to {@code FloatingPoint(DOUBLE)} + // anyway, and an explicit DECIMAL element type triggers Calcite's element coercion to + // BigDecimal, which downstream Avatica array accessors and the JSON formatter render + // inconsistently across paths. + RelDataType least = typeFactory.leastRestrictive(java.util.List.of(current, candidate)); + if (least == null) { + return typeFactory.createSqlType(SqlTypeName.ANY); + } + if (least.getSqlTypeName() == SqlTypeName.DECIMAL) { + return typeFactory.createTypeWithNullability( + typeFactory.createSqlType(SqlTypeName.DOUBLE), true); + } + return least; } public static class MVAppendImplementor implements NotNullImplementor { @Override public Expression implement( RexToLixTranslator translator, RexCall call, List translatedOperands) { + // Pre-cast each scalar operand to the call's element Java class so the result list is + // homogeneously typed. Avatica's {@code AbstractCursor.ArrayAccessor} dispatches the + // per-element accessor by the declared SQL type — e.g. {@code DoubleAccessor.getDouble} + // does {@code (Double) value} — and would throw a runtime ClassCastException on an + // {@code Integer} cell when the call's element type widens to DOUBLE. Array operands + // pass through; their element-type alignment is the planner's responsibility. + RelDataType elementType = call.getType().getComponentType(); + Class elementClass = + elementType == null ? Object.class : boxedJavaClass(elementType.getSqlTypeName()); + List coerced = new ArrayList<>(translatedOperands.size()); + for (int i = 0; i < translatedOperands.size(); i++) { + Expression op = translatedOperands.get(i); + RelDataType opType = call.getOperands().get(i).getType(); + if (opType.getComponentType() != null || elementClass == Object.class) { + coerced.add(op); + } else { + coerced.add(EnumUtils.convert(op, elementClass)); + } + } return Expressions.call( Types.lookupMethod(MVAppendFunctionImpl.class, "mvappend", Object[].class), - Expressions.newArrayInit(Object.class, translatedOperands)); + Expressions.newArrayInit(Object.class, coerced)); } } public static Object mvappend(Object... args) { return MVAppendCore.collectElements(args); } + + private static Class boxedJavaClass(SqlTypeName sqlType) { + return switch (sqlType) { + case BOOLEAN -> Boolean.class; + case TINYINT -> Byte.class; + case SMALLINT -> Short.class; + case INTEGER -> Integer.class; + case BIGINT -> Long.class; + case FLOAT, REAL -> Float.class; + case DOUBLE -> Double.class; + case DECIMAL -> BigDecimal.class; + case CHAR, VARCHAR -> String.class; + default -> Object.class; + }; + } }