diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteArrayFunctionIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteArrayFunctionIT.java index 12b572f44a4..0f5b2bb5649 100644 --- a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteArrayFunctionIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteArrayFunctionIT.java @@ -551,6 +551,35 @@ public void testMvindexRangeSingleElement() throws IOException { verifyDataRows(actual, rows(List.of(3))); } + @Test + public void testMvindexWithNonZeroIndexPushdown() throws IOException { + // Regression test for #5660: mvindex with non-zero literal index fails during pushdown + // because PLUS(1,1) gets widened to BIGINT but ITEM expects INTEGER. + JSONObject actual = + executeQuery( + String.format( + "source=%s | eval arr = array('a', 'b', 'c'), result = mvindex(arr, 2)" + + " | head 1 | fields result", + TEST_INDEX_BANK)); + + verifySchema(actual, schema("result", "string")); + verifyDataRows(actual, rows("c")); + } + + @Test + public void testMvindexWithStatsAggregationPushdown() throws IOException { + // Regression test for #5660: mvindex with non-zero index in aggregation context + // triggers pushdown script compilation where BIGINT/INTEGER mismatch occurred. + JSONObject actual = + executeQuery( + String.format( + "source=%s | eval arr = array('x', 'y', 'z'), e = mvindex(arr, 1)" + + " | stats count() by e", + TEST_INDEX_BANK)); + + verifySchema(actual, schema("count()", "bigint"), schema("e", "string")); + } + @Test public void testMvfindWithMatch() throws IOException { JSONObject actual = diff --git a/opensearch/src/main/java/org/opensearch/sql/opensearch/storage/serde/RexStandardizer.java b/opensearch/src/main/java/org/opensearch/sql/opensearch/storage/serde/RexStandardizer.java index 78afd4aaf01..9e8692c9448 100644 --- a/opensearch/src/main/java/org/opensearch/sql/opensearch/storage/serde/RexStandardizer.java +++ b/opensearch/src/main/java/org/opensearch/sql/opensearch/storage/serde/RexStandardizer.java @@ -83,7 +83,19 @@ public RexNode visitCall(final RexCall call, ScriptParameterHelper helper) { // Do normalization before standardization Pair> normalized = RexNormalize.normalize(call.op, call.operands); List standardizedOperands = visitList(normalized.right, helper, update); - return helper.rexBuilder.makeCall(call.getType(), normalized.left, standardizedOperands); + RexNode result = + helper.rexBuilder.makeCall(call.getType(), normalized.left, standardizedOperands); + + if (allowNumericTypeWiden + && SqlTypeUtil.isExactNumeric(call.getType()) + && !call.getType().getSqlTypeName().equals(SqlTypeName.BIGINT)) { + RelDataType targetType = + OpenSearchTypeFactory.TYPE_FACTORY.createTypeWithNullability( + call.getType(), call.getType().isNullable()); + result = helper.rexBuilder.makeCast(targetType, result); + } + + return result; } finally { helper.stack.pop(); } diff --git a/opensearch/src/test/java/org/opensearch/sql/opensearch/storage/serde/RelJsonSerializerTest.java b/opensearch/src/test/java/org/opensearch/sql/opensearch/storage/serde/RelJsonSerializerTest.java index efaa9ce3dda..463cbd1f426 100644 --- a/opensearch/src/test/java/org/opensearch/sql/opensearch/storage/serde/RelJsonSerializerTest.java +++ b/opensearch/src/test/java/org/opensearch/sql/opensearch/storage/serde/RelJsonSerializerTest.java @@ -350,6 +350,67 @@ void testSerializeAndDeserializeSearch() { assertEquals(List.of(20, "Number", "Number", 10), helper.digests); } + @Test + void testArithmeticInItemIndexPreservesIntegerType() { + // Simulates the mvindex(entity, 1) scenario where PLUS(1, 1) is used as an array index. + // The PLUS operands get widened to BIGINT during standardization (for doc-value compat), + // but the result must remain INTEGER since ITEM/arrayItemOptional expects int. + RelDataType intType = TYPE_FACTORY.createSqlType(SqlTypeName.INTEGER); + RexNode plusCall = + rexBuilder.makeCall( + SqlStdOperatorTable.PLUS, + rexBuilder.makeLiteral(1, TYPE_FACTORY.createSqlType(SqlTypeName.INTEGER)), + rexBuilder.makeLiteral(1, TYPE_FACTORY.createSqlType(SqlTypeName.INTEGER))); + + final ScriptParameterHelper helper = + new ScriptParameterHelper(rowType.getFieldList(), fieldTypes, rexBuilder); + String code = serializer.serialize(plusCall, helper); + RexNode deserialized = serializer.deserialize(code); + + // After round-trip, the result should be wrapped in CAST(INTEGER) to preserve the type + assertEquals(SqlTypeName.INTEGER, deserialized.getType().getSqlTypeName()); + } + + @Test + void testArithmeticBigintNotWrappedInCast() { + // When the original arithmetic type is already BIGINT, no cast should be added. + RexNode plusCall = + rexBuilder.makeCall( + SqlStdOperatorTable.PLUS, + rexBuilder.makeLiteral(1L, TYPE_FACTORY.createSqlType(SqlTypeName.BIGINT)), + rexBuilder.makeLiteral(1L, TYPE_FACTORY.createSqlType(SqlTypeName.BIGINT))); + + final ScriptParameterHelper helper = + new ScriptParameterHelper(rowType.getFieldList(), fieldTypes, rexBuilder); + String code = serializer.serialize(plusCall, helper); + RexNode deserialized = serializer.deserialize(code); + + // BIGINT arithmetic should stay BIGINT without extra CAST + assertEquals(SqlTypeName.BIGINT, deserialized.getType().getSqlTypeName()); + } + + @Test + void testArithmeticWithFieldPreservesIntegerType() { + // Simulates arithmetic with a field reference: Number + 1 + // The field gets widened to BIGINT, but the original PLUS type is INTEGER, + // so the result should be cast back to INTEGER. + RexNode plusCall = + rexBuilder.makeCall( + SqlStdOperatorTable.PLUS, + rexBuilder.makeInputRef(rowType.getFieldList().get(1).getType(), 1), + rexBuilder.makeLiteral(1, TYPE_FACTORY.createSqlType(SqlTypeName.INTEGER))); + + Map fieldTypesWithNumber = + Map.of("Referer", ExprCoreType.STRING, "Number", ExprCoreType.INTEGER); + final ScriptParameterHelper helper = + new ScriptParameterHelper(rowType.getFieldList(), fieldTypesWithNumber, rexBuilder); + String code = serializer.serialize(plusCall, helper); + RexNode deserialized = serializer.deserialize(code); + + // Result should be INTEGER after deserialization + assertEquals(SqlTypeName.INTEGER, deserialized.getType().getSqlTypeName()); + } + @Test void deserialize_rejects_disallowed_class() throws Exception { java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();