Skip to content

Commit 8d68419

Browse files
authored
Type a nested field access as the field's type, not the whole row (#5764)
1 parent be129ec commit 8d68419

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@
291291
import java.util.stream.Stream;
292292
import javax.annotation.Nullable;
293293
import org.apache.calcite.rel.type.RelDataType;
294+
import org.apache.calcite.rel.type.RelDataTypeField;
294295
import org.apache.calcite.rex.RexBuilder;
295296
import org.apache.calcite.rex.RexCall;
296297
import org.apache.calcite.rex.RexLambda;
@@ -1365,6 +1366,37 @@ void populate() {
13651366
OperandTypes.family(SqlTypeFamily.ARRAY, SqlTypeFamily.INTEGER)
13661367
.or(OperandTypes.family(SqlTypeFamily.MAP, SqlTypeFamily.ANY)),
13671368
false));
1369+
// A `nested` mapping is exposed as ARRAY<ROW<...>>, so `events.name` becomes
1370+
// ITEM(<array-of-rows>, 'name'). By default, Calcite types this as the whole ROW because it
1371+
// never looks at the field name — so a later `events.count > 4` fails with
1372+
// "Unsupported conversion for Relational Data type: ROW".
1373+
// Fix: look 'name' up in the ROW and type the result as that field. Must be registered before
1374+
// the (IGNORE, CHARACTER) catch-all in the next registration below — that fallback would
1375+
// otherwise match ITEM(<array-of-rows>, 'name') first and re-apply the stock whole-ROW
1376+
// typing.
1377+
register(
1378+
INTERNAL_ITEM,
1379+
(FunctionImp2)
1380+
(builder, array, key) -> {
1381+
RelDataType arrayType = array.getType();
1382+
RelDataType component = arrayType.getComponentType();
1383+
if (component != null
1384+
&& component.isStruct()
1385+
&& key instanceof RexLiteral literal
1386+
&& SqlTypeFamily.CHARACTER.contains(literal.getType())) {
1387+
String fieldName = literal.getValueAs(String.class);
1388+
RelDataTypeField field = component.getField(fieldName, true, false);
1389+
if (field != null) {
1390+
// Nullable: an empty array yields NULL, independent of field nullability.
1391+
RelDataType fieldType =
1392+
builder.getTypeFactory().createTypeWithNullability(field.getType(), true);
1393+
return builder.makeCall(
1394+
fieldType, SqlStdOperatorTable.ITEM, List.of(array, key));
1395+
}
1396+
}
1397+
return builder.makeCall(SqlStdOperatorTable.ITEM, array, key);
1398+
},
1399+
PPLTypeChecker.family(SqlTypeFamily.ARRAY, SqlTypeFamily.CHARACTER));
13681400
registerOperator(
13691401
INTERNAL_ITEM,
13701402
SqlStdOperatorTable.ITEM,
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.sql.expression.function;
7+
8+
import static org.junit.jupiter.api.Assertions.assertEquals;
9+
import static org.junit.jupiter.api.Assertions.assertFalse;
10+
import static org.junit.jupiter.api.Assertions.assertTrue;
11+
import static org.opensearch.sql.calcite.utils.OpenSearchTypeFactory.TYPE_FACTORY;
12+
import static org.opensearch.sql.expression.function.BuiltinFunctionName.INTERNAL_ITEM;
13+
14+
import java.math.BigDecimal;
15+
import org.apache.calcite.rel.type.RelDataType;
16+
import org.apache.calcite.rex.RexBuilder;
17+
import org.apache.calcite.rex.RexNode;
18+
import org.apache.calcite.sql.type.SqlTypeName;
19+
import org.junit.jupiter.api.Test;
20+
21+
/**
22+
* Return-type behavior of {@code ITEM} (the internal {@code item} builtin behind {@code
23+
* events.name}, {@code arr[0]}, {@code map['k']}) — specifically the nested-field case where a
24+
* {@code nested} mapping is exposed as {@code ARRAY<ROW<...>>}. Stock Calcite types {@code
25+
* ITEM(ARRAY<ROW>, 'field')} as the whole {@code ROW}; the override resolves it to the named
26+
* field's type instead. Array-index and map-key access must be left untouched.
27+
*/
28+
public class PPLFuncImpTableNestedItemTest {
29+
30+
private final RexBuilder builder = new RexBuilder(TYPE_FACTORY);
31+
32+
/** ARRAY&lt;ROW&lt;name:VARCHAR, count:INTEGER&gt;&gt; — how a nested mapping is exposed. */
33+
private RelDataType structArray() {
34+
RelDataType row =
35+
TYPE_FACTORY
36+
.builder()
37+
.add("name", TYPE_FACTORY.createSqlType(SqlTypeName.VARCHAR))
38+
.add("count", TYPE_FACTORY.createSqlType(SqlTypeName.INTEGER))
39+
.build();
40+
return TYPE_FACTORY.createArrayType(row, -1);
41+
}
42+
43+
private RexNode item(RelDataType arrayType, RexNode key) {
44+
RexNode arrayRef = builder.makeInputRef(arrayType, 0);
45+
return PPLFuncImpTable.INSTANCE.resolve(builder, INTERNAL_ITEM, arrayRef, key);
46+
}
47+
48+
@Test
49+
public void keywordLeafIsTypedAsTheFieldNotTheRow() {
50+
RexNode result = item(structArray(), builder.makeLiteral("name"));
51+
assertEquals(SqlTypeName.VARCHAR, result.getType().getSqlTypeName());
52+
assertFalse(result.getType().isStruct(), "must be the leaf field, not the whole ROW");
53+
assertTrue(result.getType().isNullable(), "an empty array yields NULL");
54+
}
55+
56+
@Test
57+
public void numericLeafIsTypedAsTheField() {
58+
RexNode result = item(structArray(), builder.makeLiteral("count"));
59+
assertEquals(SqlTypeName.INTEGER, result.getType().getSqlTypeName());
60+
assertFalse(result.getType().isStruct());
61+
}
62+
63+
@Test
64+
public void unknownFieldFallsBackToStockRowTyping() {
65+
// No such field in the ROW: the override bails and defers to stock Calcite ITEM, which types
66+
// the
67+
// call as the array's component (the whole ROW) — behavior we deliberately do not change.
68+
RexNode result = item(structArray(), builder.makeLiteral("missing"));
69+
assertTrue(result.getType().isStruct(), "unknown field falls back to the whole ROW");
70+
}
71+
72+
@Test
73+
public void arrayIndexAccessIsUnaffected() {
74+
// ITEM(ARRAY<INTEGER>, 1) is (ARRAY, INTEGER) — not (ARRAY, CHARACTER) — so it never enters the
75+
// override and keeps returning the element type.
76+
RelDataType intArray =
77+
TYPE_FACTORY.createArrayType(TYPE_FACTORY.createSqlType(SqlTypeName.INTEGER), -1);
78+
RexNode idx =
79+
builder.makeExactLiteral(BigDecimal.ONE, TYPE_FACTORY.createSqlType(SqlTypeName.INTEGER));
80+
RexNode result = item(intArray, idx);
81+
assertEquals(SqlTypeName.INTEGER, result.getType().getSqlTypeName());
82+
}
83+
84+
@Test
85+
public void mapKeyAccessIsUnaffected() {
86+
// ITEM(MAP<VARCHAR,INTEGER>, 'k') is (MAP, *) — not (ARRAY, *) — so the override's ARRAY guard
87+
// skips it and it keeps returning the map value type.
88+
RelDataType mapType =
89+
TYPE_FACTORY.createMapType(
90+
TYPE_FACTORY.createSqlType(SqlTypeName.VARCHAR),
91+
TYPE_FACTORY.createSqlType(SqlTypeName.INTEGER));
92+
RexNode mapRef = builder.makeInputRef(mapType, 0);
93+
RexNode result =
94+
PPLFuncImpTable.INSTANCE.resolve(builder, INTERNAL_ITEM, mapRef, builder.makeLiteral("k"));
95+
assertEquals(SqlTypeName.INTEGER, result.getType().getSqlTypeName());
96+
}
97+
98+
@Test
99+
public void stringKeyOnNonStructArrayFallsThrough() {
100+
// (ARRAY, CHARACTER) matches the override's signature, but the component is not a ROW — the
101+
// isStruct guard fails, so it defers to stock ITEM (no field lookup, type is the element type).
102+
RelDataType stringArray =
103+
TYPE_FACTORY.createArrayType(TYPE_FACTORY.createSqlType(SqlTypeName.VARCHAR), -1);
104+
RexNode result = item(stringArray, builder.makeLiteral("x"));
105+
assertEquals(SqlTypeName.VARCHAR, result.getType().getSqlTypeName());
106+
assertFalse(result.getType().isStruct());
107+
}
108+
}

0 commit comments

Comments
 (0)