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,