You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by vp...@apache.org on 2026/10/21 20:48:21 UTC

(ignite) 01/02: IGNITE-29157 SQL Calcite: Convert nested SQL values to external Java types in query results

This is an automated email from the ASF dual-hosted git repository.

vldpyatkov pushed a commit to branch ignite-29157
in repository https://gitbox.apache.org/repos/asf/ignite.git

commit c9fa1e2d27b8dbc925ea9c9a963ef215fef3b9f4
Author: Vladislav Pyatkov <vl...@gmail.com>
AuthorDate: Fri Oct 16 17:54:24 2026 +0300

    IGNITE-29157 SQL Calcite: Convert nested SQL values to external Java types in query results
---
 .../processors/query/calcite/util/TypeUtils.java   | 102 ++++++++-
 .../NestedResultTypesIntegrationTest.java          | 242 +++++++++++++++++++++
 .../ignite/testsuites/IntegrationTestSuite.java    |   2 +
 3 files changed, 337 insertions(+), 9 deletions(-)

diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/TypeUtils.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/TypeUtils.java
index b1f856966cb..2d9a313fdb9 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/TypeUtils.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/TypeUtils.java
@@ -33,8 +33,10 @@ import java.util.Arrays;
 import java.util.Calendar;
 import java.util.GregorianCalendar;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Set;
 import java.util.TimeZone;
 import java.util.UUID;
@@ -242,13 +244,44 @@ public class TypeUtils {
 
         for (int i = 0; i < sqlType.getFieldCount(); i++) {
             List<String> origin = origins == null ? null : origins.get(i);
-            b.add(fields.get(i).getName(), typeFactory.createType(
-                getResultClass(typeFactory, schema, fields.get(i).getType(), origin)));
+
+            b.add(fields.get(i).getName(), resultFieldType(typeFactory, schema, fields.get(i).getType(), origin));
         }
 
         return b.build();
     }
 
+    /** Preserves ROW, ARRAY and MAP types and resolves the external Java type of each leaf. */
+    private static RelDataType resultFieldType(
+        IgniteTypeFactory typeFactory,
+        RelOptSchema schema,
+        RelDataType type,
+        @Nullable List<String> origin
+    ) {
+        // Java types, including byte[], are values rather than SQL containers.
+        if (type instanceof RelDataTypeFactoryImpl.JavaType)
+            return typeFactory.createType(getResultClass(typeFactory, schema, type, origin));
+
+        RelDataType resultType;
+
+        if (type.isStruct())
+            resultType = getResultType(typeFactory, schema, type, null);
+        else if (type.getSqlTypeName() == SqlTypeName.ARRAY) {
+            RelDataType componentType = resultFieldType(typeFactory, schema, type.getComponentType(), null);
+
+            resultType = typeFactory.createArrayType(componentType, -1);
+        }
+        else if (type.getSqlTypeName() == SqlTypeName.MAP) {
+            resultType = typeFactory.createMapType(
+                resultFieldType(typeFactory, schema, type.getKeyType(), null),
+                resultFieldType(typeFactory, schema, type.getValueType(), null));
+        }
+        else
+            return typeFactory.createType(getResultClass(typeFactory, schema, type, origin));
+
+        return typeFactory.createTypeWithNullability(resultType, type.isNullable());
+    }
+
     /**
      * @param schema Schema.
      * @param type Logical column type.
@@ -299,7 +332,7 @@ public class TypeUtils {
     public static <Row> Function<Row, Row> resultTypeConverter(ExecutionContext<Row> ectx, RelDataType resultType) {
         assert resultType.isStruct();
 
-        if (hasConvertableFields(resultType)) {
+        if (needsResultConversion(resultType)) {
             RowHandler<Row> hnd = ectx.rowHandler();
             List<RelDataType> types = RelOptUtil.getFieldTypeList(resultType);
             RowHandler.RowFactory<Row> factory = hnd.factory(ectx.getTypeFactory(), types);
@@ -319,10 +352,52 @@ public class TypeUtils {
 
     /** */
     private static Function<Object, Object> fieldConverter(ExecutionContext<?> ectx, RelDataType fieldType) {
-        Type storageType = ectx.getTypeFactory().getJavaClass(fieldType);
+        if (!needsResultConversion(fieldType))
+            return Function.identity();
+
+        if (fieldType instanceof RelDataTypeFactoryImpl.JavaType) {
+            Type storageType = ectx.getTypeFactory().getJavaClass(fieldType);
 
-        if (isConvertableType(storageType))
             return v -> fromInternal(ectx, v, storageType);
+        }
+
+        if (fieldType.isStruct()) {
+            List<Function<Object, Object>> converters = transform(fieldType.getFieldList(),
+                f -> fieldConverter(ectx, f.getType()));
+            return v -> {
+                if (v == null)
+                    return null;
+
+                Object[] row = ((Object[])v).clone();
+
+                for (int i = 0; i < converters.size(); i++)
+                    row[i] = converters.get(i).apply(row[i]);
+
+                return row;
+            };
+        }
+
+        if (fieldType.getSqlTypeName() == SqlTypeName.ARRAY) {
+            Function<Object, Object> elementConverter = fieldConverter(ectx, fieldType.getComponentType());
+
+            return v -> v == null ? null : transform((List<?>)v, elementConverter::apply);
+        }
+
+        if (fieldType.getSqlTypeName() == SqlTypeName.MAP) {
+            Function<Object, Object> keyConverter = fieldConverter(ectx, fieldType.getKeyType());
+            Function<Object, Object> valConverter = fieldConverter(ectx, fieldType.getValueType());
+
+            return v -> {
+                if (v == null)
+                    return null;
+
+                Map<Object, Object> map = new LinkedHashMap<>();
+
+                ((Map<?, ?>)v).forEach((key, val) -> map.put(keyConverter.apply(key), valConverter.apply(val)));
+
+                return map;
+            };
+        }
 
         return Function.identity();
     }
@@ -338,10 +413,19 @@ public class TypeUtils {
                     && isConvertableType(((RelDataTypeFactoryImpl.JavaType)type).getJavaClass());
     }
 
-    /** */
-    private static boolean hasConvertableFields(RelDataType resultType) {
-        return RelOptUtil.getFieldTypeList(resultType).stream()
-            .anyMatch(TypeUtils::isConvertableType);
+    /** Checks declared types during converter construction, without inspecting result values. */
+    private static boolean needsResultConversion(RelDataType type) {
+        if (type instanceof RelDataTypeFactoryImpl.JavaType)
+            return isConvertableType(type);
+
+        if (type.isStruct())
+            return type.getFieldList().stream().anyMatch(f -> needsResultConversion(f.getType()));
+
+        if (type.getSqlTypeName() == SqlTypeName.ARRAY)
+            return needsResultConversion(type.getComponentType());
+
+        return type.getSqlTypeName() == SqlTypeName.MAP
+            && (needsResultConversion(type.getKeyType()) || needsResultConversion(type.getValueType()));
     }
 
     /** */
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/NestedResultTypesIntegrationTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/NestedResultTypesIntegrationTest.java
new file mode 100644
index 00000000000..70d8aaf8e40
--- /dev/null
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/NestedResultTypesIntegrationTest.java
@@ -0,0 +1,242 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.processors.query.calcite.integration;
+
+import java.sql.Date;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.time.Duration;
+import java.time.Period;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/** Checks external Java values in nested ROW, ARRAY and MAP results. */
+public class NestedResultTypesIntegrationTest extends AbstractBasicIntegrationTest {
+    /** SQL literals and their expected external values, independent of the execution representation. */
+    private static Stream<Arguments> values() {
+        return Stream.of(
+            Arguments.of("DATE '2026-01-01'", Date.valueOf("2026-01-01")),
+            Arguments.of("TIME '12:34:56'", Time.valueOf("12:34:56")),
+            Arguments.of("TIMESTAMP '2026-01-01 12:34:56'", Timestamp.valueOf("2026-01-01 12:34:56")),
+            Arguments.of("INTERVAL '1' DAY", Duration.ofDays(1)),
+            Arguments.of("INTERVAL '1-2' YEAR TO MONTH", Period.of(1, 2, 0)),
+            Arguments.of("X'0102'", new byte[] {1, 2})
+        );
+    }
+
+    /** Wrapping the same value in ARRAY must not expose its internal representation. */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("values")
+    public void testArrayElement(String expr, Object expected) {
+        String qry = "SELECT ARRAY[" + expr + ']';
+        List<?> arr = Assertions.assertInstanceOf(List.class, value(qry), qry);
+
+        assertEquals(1, arr.size());
+        assertExternalValue(expected, arr.get(0), qry);
+    }
+
+    /** Conversion must descend through multiple levels, as required by ARRAY-of-ROW paths. */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("values")
+    public void testArrayOfRows(String expr, Object expected) {
+        String qry = "SELECT ARRAY[ROW(" + expr + ", 42)]";
+        List<?> arr = Assertions.assertInstanceOf(List.class, value(qry), qry);
+
+        assertEquals(1, arr.size());
+
+        Object[] row = Assertions.assertInstanceOf(Object[].class, arr.get(0), qry);
+
+        assertEquals(2, row.length);
+        assertEquals(42, row[1]);
+        assertExternalValue(expected, row[0], qry);
+    }
+
+    /** NULL fields in an ARRAY-of-ROW result must remain NULL after conversion. */
+    @Test
+    public void testArrayOfRowsWithNullField() {
+        String qry = "SELECT ARRAY[ROW(CAST(NULL AS DATE), 42)]";
+        List<?> arr = Assertions.assertInstanceOf(List.class, value(qry), qry);
+
+        assertEquals(1, arr.size());
+
+        Object[] row = Assertions.assertInstanceOf(Object[].class, arr.get(0), qry);
+
+        assertEquals(2, row.length);
+        assertEquals(42, row[1]);
+        assertNull(row[0]);
+    }
+
+    /** The constructor's ROW value is a single result column containing two fields. */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("values")
+    public void testRowField(String expr, Object expected) {
+        String qry = "SELECT ROW(" + expr + ", 42)";
+        Object[] row = Assertions.assertInstanceOf(Object[].class, value(qry), qry);
+
+        assertEquals(2, row.length);
+        assertEquals(42, row[1]);
+        assertExternalValue(expected, row[0], qry);
+    }
+
+    /** Each field is converted according to its own type, preserving order and NULL values. */
+    @Test
+    public void testRowWithDifferentFieldTypes() {
+        String qry = "SELECT ROW(DATE '2026-01-01', 42, TIME '12:34:56', 'text', CAST(NULL AS DATE), "
+            + "TIMESTAMP '2026-01-01 12:34:56', INTERVAL '1' DAY, INTERVAL '1-2' YEAR TO MONTH, X'0102')";
+        Object[] row = Assertions.assertInstanceOf(Object[].class, value(qry), qry);
+
+        assertEquals(9, row.length);
+        assertExternalValue(Date.valueOf("2026-01-01"), row[0], qry);
+        assertExternalValue(42, row[1], qry);
+        assertExternalValue(Time.valueOf("12:34:56"), row[2], qry);
+        assertExternalValue("text", row[3], qry);
+        assertNull(row[4]);
+        assertExternalValue(Timestamp.valueOf("2026-01-01 12:34:56"), row[5], qry);
+        assertExternalValue(Duration.ofDays(1), row[6], qry);
+        assertExternalValue(Period.of(1, 2, 0), row[7], qry);
+        assertExternalValue(new byte[] {1, 2}, row[8], qry);
+    }
+
+    /** Fields of different types are also converted when the ROW is inside an ARRAY. */
+    @Test
+    public void testArrayOfRowsWithDifferentFieldTypes() {
+        String qry = "SELECT ARRAY[ROW(DATE '2026-01-01', 42, TIME '12:34:56', 'text', CAST(NULL AS DATE), "
+            + "TIMESTAMP '2026-01-01 12:34:56', INTERVAL '1' DAY, INTERVAL '1-2' YEAR TO MONTH, X'0102')]";
+        List<?> arr = Assertions.assertInstanceOf(List.class, value(qry), qry);
+
+        assertEquals(1, arr.size());
+
+        Object[] row = Assertions.assertInstanceOf(Object[].class, arr.get(0), qry);
+
+        assertEquals(9, row.length);
+        assertExternalValue(Date.valueOf("2026-01-01"), row[0], qry);
+        assertExternalValue(42, row[1], qry);
+        assertExternalValue(Time.valueOf("12:34:56"), row[2], qry);
+        assertExternalValue("text", row[3], qry);
+        assertNull(row[4]);
+        assertExternalValue(Timestamp.valueOf("2026-01-01 12:34:56"), row[5], qry);
+        assertExternalValue(Duration.ofDays(1), row[6], qry);
+        assertExternalValue(Period.of(1, 2, 0), row[7], qry);
+        assertExternalValue(new byte[] {1, 2}, row[8], qry);
+    }
+
+    /** MAP values must use the same external types as other result values. */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("values")
+    public void testMapValue(String expr, Object expected) {
+        String qry = "SELECT MAP['value', " + expr + ']';
+        Map<?, ?> map = Assertions.assertInstanceOf(Map.class, value(qry), qry);
+
+        assertEquals(1, map.size());
+        assertExternalValue(expected, map.get("value"), qry);
+    }
+
+    /** MAP keys must also be converted, including binary keys compared by content. */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("values")
+    public void testMapKey(String expr, Object expected) {
+        String qry = "SELECT MAP[" + expr + ", 42]";
+        Map<?, ?> map = Assertions.assertInstanceOf(Map.class, value(qry), qry);
+
+        assertEquals(1, map.size());
+
+        Map.Entry<?, ?> entry = map.entrySet().iterator().next();
+
+        assertExternalValue(expected, entry.getKey(), qry);
+        assertEquals(42, entry.getValue());
+    }
+
+    /** Converts keys and values together and preserves entries with NULL values. */
+    @Test
+    public void testMapWithDifferentKeyAndValueTypes() {
+        String qry = "SELECT MAP[DATE '2026-01-01', TIMESTAMP '2026-01-01 12:34:56', "
+            + "DATE '2026-01-02', CAST(NULL AS TIMESTAMP)]";
+        Map<?, ?> map = Assertions.assertInstanceOf(Map.class, value(qry), qry);
+
+        assertEquals(2, map.size());
+        assertExternalValue(Timestamp.valueOf("2026-01-01 12:34:56"), map.get(Date.valueOf("2026-01-01")), qry);
+        assertTrue(map.containsKey(Date.valueOf("2026-01-02")));
+        assertNull(map.get(Date.valueOf("2026-01-02")));
+    }
+
+    /** Conversion descends from a MAP value into a ROW and its ARRAY field. */
+    @Test
+    public void testMapWithRowValue() {
+        String qry = "SELECT MAP[DATE '2026-01-01', ROW(TIMESTAMP '2026-01-01 12:34:56', "
+            + "ARRAY[INTERVAL '1' DAY], CAST(NULL AS TIME))]";
+        Map<?, ?> map = Assertions.assertInstanceOf(Map.class, value(qry), qry);
+
+        assertEquals(1, map.size());
+
+        Object[] row = Assertions.assertInstanceOf(Object[].class, map.get(Date.valueOf("2026-01-01")), qry);
+
+        assertEquals(3, row.length);
+        assertExternalValue(Timestamp.valueOf("2026-01-01 12:34:56"), row[0], qry);
+
+        List<?> arr = Assertions.assertInstanceOf(List.class, row[1], qry);
+
+        assertEquals(1, arr.size());
+        assertExternalValue(Duration.ofDays(1), arr.get(0), qry);
+        assertNull(row[2]);
+    }
+
+    /** A MAP nested in a CYCLE-like ARRAY-of-ROW result also requires conversion. */
+    @Test
+    public void testArrayOfRowsWithMapField() {
+        String qry = "SELECT ARRAY[ROW(MAP['value', DATE '2026-01-01'], 42)]";
+        List<?> arr = Assertions.assertInstanceOf(List.class, value(qry), qry);
+
+        assertEquals(1, arr.size());
+
+        Object[] row = Assertions.assertInstanceOf(Object[].class, arr.get(0), qry);
+
+        assertEquals(2, row.length);
+        assertEquals(42, row[1]);
+
+        Map<?, ?> map = Assertions.assertInstanceOf(Map.class, row[0], qry);
+
+        assertEquals(1, map.size());
+        assertExternalValue(Date.valueOf("2026-01-01"), map.get("value"), qry);
+    }
+
+    /** Executes a one-row, one-column query without normalizing the returned value. */
+    private Object value(String qry) {
+        List<List<?>> rows = sql(qry);
+
+        assertEquals(1, rows.size());
+        assertEquals(1, rows.get(0).size());
+
+        return rows.get(0).get(0);
+    }
+
+    /** Checks both Java type and value, including binary content. */
+    private static void assertExternalValue(Object expected, Object actual, String qry) {
+        Assertions.assertInstanceOf(expected.getClass(), actual, qry + "; actual value: " + actual);
+
+        if (expected instanceof byte[])
+            Assertions.assertArrayEquals((byte[])expected, (byte[])actual, qry);
+        else
+            Assertions.assertEquals(expected, actual, qry);
+    }
+}
diff --git a/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java b/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
index 39f01f6db55..118b68988ba 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
@@ -59,6 +59,7 @@ import org.apache.ignite.internal.processors.query.calcite.integration.LocalQuer
 import org.apache.ignite.internal.processors.query.calcite.integration.MemoryQuotasIntegrationTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.MetadataIntegrationTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.MultiDcQueryMappingTest;
+import org.apache.ignite.internal.processors.query.calcite.integration.NestedResultTypesIntegrationTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.OperatorsExtensionIntegrationTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.PartitionPruneTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.PartitionsReservationIntegrationTest;
@@ -130,6 +131,7 @@ import org.junit.platform.suite.api.Suite;
     StdSqlOperatorsTest.class,
     TableDmlIntegrationTest.class,
     DataTypesTest.class,
+    NestedResultTypesIntegrationTest.class,
     IndexSpoolIntegrationTest.class,
     HashSpoolIntegrationTest.class,
     IndexScanlIntegrationTest.class,