Skip to content

Commit d17aa3b

Browse files
committed
Decouple PPL logical plans from UDT temporal types
Logical RelNode/RexNode trees now use standard Calcite DATE/TIME(9)/ TIMESTAMP(9) types for date columns. A new TemporalUdtRewriteShuttle runs at the prepare-statement boundary (OpenSearchCalcitePreparingStmt .implement and OpenSearchRelRunners.run) and rewrites the standard temporal types back to UDTs (ExprDateType/ExprTimeType/ExprTimeStampType) just before physical execution, so Linq4j keeps receiving the VARCHAR-backed representation. This commit folds in the prior "Decouple Calcite PPL planning from ExprType" change as well: Calcite-side PPL planning (RelNode/RexNode code, coercion, type checking, and UDF implementations) operates on RelDataType throughout instead of bouncing through ExprType. Type system: - OpenSearchTypeFactory.convertExprTypeToRelDataType returns standard TIMESTAMP(9)/DATE/TIME(9) for the corresponding ExprCoreType. IP and BINARY remain UDT. - New helper isStandardTemporalType. - PPLOperandTypes constants renamed DATE_UDT/TIME_UDT/TIMESTAMP_UDT to DATE_T/TIME_T/TIMESTAMP_T. - UserDefinedFunctionUtils NULLABLE_*_UDT renamed to NULLABLE_*_T and repointed to standard temporal RelDataTypes (IP and BINARY constants unchanged). - UDTs are recognised via instanceof rather than getExprType(); cloneWith preserves UDT identity through createTypeWithNullability. Cast emission and pushdown: - CalciteRexNodeVisitor.visitCast emits standard temporal types for AST DATE/TIME/TIMESTAMP cast targets. - ExtendedRexBuilder.makeCast routes IP separately; standard temporal targets dispatch to PPLBuiltinOperators.DATE/TIME/TIMESTAMP with the standard target type so the call's RelDataType stays standard. - PredicateAnalyzer.isTimestamp/isDate accept both standard SqlTypeName and UDT, and read literal values via getValueAs(String.class) so TimestampString/DateString round-trip without ClassCastException. Coercion + type-checker: - RelDataType-typed common-type resolver with a CoercionTag widening DAG. - CoercionUtils' DATE+TIME -> TIMESTAMP resolver emits standard TIMESTAMP(9). - PPLTypeChecker gains a temporalKind helper used in typesMatch and isComparable so standard and UDT temporal pairs match across forms. - getRelDataTypes(family) returns standard temporal types for DATETIME/TIMESTAMP/DATE/TIME families (BINARY family stays UDT). - UDF return-type inference (AddSubDate/Weekday/LastDay/TimestampDiff /Format/TimestampAdd/Extract/Span/WidthBucket) recognise both standard and UDT temporal operand types. Shuttle implementation: - Atomic rebuild path for Project/Filter/Calc/Aggregate/Values nodes (Calcite's default copy-then-validate path doesn't work because each half of the rewrite would briefly violate row-type consistency). - Filter rebuild preserves variablesSet so correlated subqueries keep their CorrelationId binding. - TemporalSchemaRewritable marker interface lets OpenSearch table scans rewrite their schema in place rather than wrap in a LogicalProject(CAST), so Calcite Linq4j codegen reads String values matching what OpenSearchExprValueFactory delivers at runtime. Other: - DatetimeUdtNormalizeRule (api/datetime extension) recognises both UDT and standard temporal RexCalls and forces precision to MAX so unified-API consumers see TIMESTAMP(9). - Calcite IT golden files updated to reflect logical plans now showing TIMESTAMP(9) instead of EXPR_TIMESTAMP VARCHAR (physical plans still show UDT post-shuttle). Tests: - New unit: TemporalUdtRewriteShuttleTest, CalcitePPLLogicalPlanStandardTemporalTest. - Updated CoercionUtilsTest, OpenSearchTypeFactoryTest. All module unit tests pass. Full Calcite IT suite (including ExplainIT) green. Signed-off-by: Peng Huo <penghuo@gmail.com>
1 parent 89d1040 commit d17aa3b

51 files changed

Lines changed: 1815 additions & 627 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/src/main/java/org/opensearch/sql/api/spec/datetime/DatetimeExtension.java

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
import org.apache.calcite.rel.type.RelDataType;
1515
import org.apache.calcite.sql.type.SqlTypeName;
1616
import org.opensearch.sql.api.spec.LanguageSpec.LanguageExtension;
17-
import org.opensearch.sql.calcite.type.AbstractExprRelDataType;
18-
import org.opensearch.sql.calcite.utils.OpenSearchTypeFactory.ExprUDT;
17+
import org.opensearch.sql.calcite.type.ExprDateType;
18+
import org.opensearch.sql.calcite.type.ExprTimeStampType;
19+
import org.opensearch.sql.calcite.type.ExprTimeType;
1920

2021
/** Datetime language extension that normalizes datetime UDT types to standard Calcite types. */
2122
public class DatetimeExtension implements LanguageExtension {
@@ -30,20 +31,21 @@ public List<RelShuttle> postAnalysisRules() {
3031
@Getter
3132
@RequiredArgsConstructor
3233
enum UdtMapping {
33-
DATE(ExprUDT.EXPR_DATE, SqlTypeName.DATE),
34-
TIME(ExprUDT.EXPR_TIME, SqlTypeName.TIME),
35-
TIMESTAMP(ExprUDT.EXPR_TIMESTAMP, SqlTypeName.TIMESTAMP);
34+
DATE(ExprDateType.class, SqlTypeName.DATE),
35+
TIME(ExprTimeType.class, SqlTypeName.TIME),
36+
TIMESTAMP(ExprTimeStampType.class, SqlTypeName.TIMESTAMP);
3637

37-
private final ExprUDT udtType;
38+
private final Class<?> udtClass;
3839
private final SqlTypeName stdType;
3940

4041
/** Matches a UDT RelDataType to its mapping, or empty if not a datetime UDT. */
4142
static Optional<UdtMapping> fromUdtType(RelDataType type) {
42-
if (!(type instanceof AbstractExprRelDataType<?> e)) {
43-
return Optional.empty();
44-
}
45-
ExprUDT udt = e.getUdt();
46-
return Arrays.stream(values()).filter(u -> u.udtType == udt).findFirst();
43+
return Arrays.stream(values()).filter(u -> u.udtClass.isInstance(type)).findFirst();
44+
}
45+
46+
/** Returns true if the given SqlTypeName is a standard datetime type. */
47+
static boolean isDatetimeType(SqlTypeName typeName) {
48+
return Arrays.stream(values()).anyMatch(u -> u.stdType == typeName);
4749
}
4850
}
4951
}

api/src/main/java/org/opensearch/sql/api/spec/datetime/DatetimeUdtNormalizeRule.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,21 +36,29 @@ public RelNode visit(RelNode other) {
3636
@Override
3737
public RexNode visitCall(RexCall call) {
3838
call = (RexCall) super.visitCall(call);
39-
Optional<UdtMapping> mapping = UdtMapping.fromUdtType(call.getType());
40-
if (mapping.isEmpty()) {
39+
RelDataType callType = call.getType();
40+
Optional<UdtMapping> mapping = UdtMapping.fromUdtType(callType);
41+
SqlTypeName stdTypeName;
42+
if (mapping.isPresent()) {
43+
// Normalize UDT return type to standard Calcite DATE/TIME/TIMESTAMP
44+
stdTypeName = mapping.get().getStdType();
45+
} else if (UdtMapping.isDatetimeType(callType.getSqlTypeName())) {
46+
// Normalize standard temporal types to canonical max precision
47+
stdTypeName = callType.getSqlTypeName();
48+
} else {
4149
return call;
4250
}
4351

44-
// Normalize UDT return type to standard Calcite DATE/TIME/TIMESTAMP
45-
UdtMapping m = mapping.get();
46-
SqlTypeName stdTypeName = m.getStdType();
4752
RelDataType baseType =
4853
stdTypeName.allowsPrec()
4954
? typeFactory.createSqlType(
5055
stdTypeName, typeFactory.getTypeSystem().getMaxPrecision(stdTypeName))
5156
: typeFactory.createSqlType(stdTypeName);
5257
RelDataType stdType =
53-
typeFactory.createTypeWithNullability(baseType, call.getType().isNullable());
58+
typeFactory.createTypeWithNullability(baseType, callType.isNullable());
59+
if (stdType.equals(callType)) {
60+
return call;
61+
}
5462
return call.clone(stdType, call.getOperands());
5563
}
5664
});

api/src/test/java/org/opensearch/sql/api/UnifiedQueryPlannerTest.java

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -147,20 +147,6 @@ public void invalidTableIsRethrownAsSemanticCheckException() {
147147
.assertCauseType(CalciteException.class);
148148
}
149149

150-
@Test
151-
public void assertionErrorIsWrappedAsSemanticCheckException() {
152-
// Remove when the underlying Calcite assertion is fixed.
153-
givenInvalidQuery(
154-
"""
155-
source = catalog.employees
156-
| eval ts = timestamp('2024-01-01')
157-
| stats max(ts)
158-
""")
159-
.assertErrorType(SemanticCheckException.class)
160-
.assertErrorMessageEquals("Failed to plan query: invalid plan structure")
161-
.assertCauseType(AssertionError.class);
162-
}
163-
164150
/**
165151
* Without the {@code PATTERN_*} defaults in {@link UnifiedQueryContext}, a bare {@code patterns
166152
* <field>} (no explicit {@code method=}/{@code mode=}) dies at parse time with {@code

core/src/main/java/org/opensearch/sql/calcite/CalciteRexNodeVisitor.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
import org.opensearch.sql.calcite.plan.rel.LogicalSystemLimit;
8383
import org.opensearch.sql.calcite.plan.rel.LogicalSystemLimit.SystemLimitType;
8484
import org.opensearch.sql.calcite.utils.OpenSearchTypeFactory;
85+
import org.opensearch.sql.calcite.utils.OpenSearchTypeFactory.ExprUDT;
8586
import org.opensearch.sql.calcite.utils.PlanUtils;
8687
import org.opensearch.sql.calcite.utils.SubsearchUtils;
8788
import org.opensearch.sql.common.utils.StringUtils;
@@ -819,10 +820,30 @@ private RelNode resolveSubqueryPlan(
819820
@Override
820821
public RexNode visitCast(Cast node, CalcitePlanContext context) {
821822
RexNode expr = analyze(node.getExpression(), context);
822-
RelDataType type =
823-
OpenSearchTypeFactory.convertExprTypeToRelDataType(node.getDataType().getCoreType());
824823
RelDataType nullableType =
825-
context.rexBuilder.getTypeFactory().createTypeWithNullability(type, true);
824+
switch (node.getDataType()) {
825+
case TYPE_ERROR ->
826+
throw new IllegalArgumentException("Unsupported AST DataType: " + node.getDataType());
827+
case NULL -> TYPE_FACTORY.createSqlType(SqlTypeName.NULL, true);
828+
case INTEGER -> TYPE_FACTORY.createSqlType(SqlTypeName.INTEGER, true);
829+
case LONG -> TYPE_FACTORY.createSqlType(SqlTypeName.BIGINT, true);
830+
case SHORT -> TYPE_FACTORY.createSqlType(SqlTypeName.SMALLINT, true);
831+
case FLOAT -> TYPE_FACTORY.createSqlType(SqlTypeName.REAL, true);
832+
case DOUBLE, DECIMAL -> TYPE_FACTORY.createSqlType(SqlTypeName.DOUBLE, true);
833+
case STRING -> TYPE_FACTORY.createSqlType(SqlTypeName.VARCHAR, true);
834+
case BOOLEAN -> TYPE_FACTORY.createSqlType(SqlTypeName.BOOLEAN, true);
835+
case DATE -> TYPE_FACTORY.createSqlType(SqlTypeName.DATE, true);
836+
case TIME ->
837+
TYPE_FACTORY.createTypeWithNullability(
838+
TYPE_FACTORY.createSqlType(SqlTypeName.TIME, 9), true);
839+
case TIMESTAMP ->
840+
TYPE_FACTORY.createTypeWithNullability(
841+
TYPE_FACTORY.createSqlType(SqlTypeName.TIMESTAMP, 9), true);
842+
case INTERVAL ->
843+
throw new IllegalArgumentException(
844+
"INTERVAL must carry a unit; cannot map to RelDataType directly.");
845+
case IP -> TYPE_FACTORY.createUDT(ExprUDT.EXPR_IP, true);
846+
};
826847
// call makeCast() instead of cast() because the saft parameter is true could avoid exception.
827848
return context.rexBuilder.makeCast(nullableType, expr, true, true);
828849
}

core/src/main/java/org/opensearch/sql/calcite/ExtendedRexBuilder.java

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,8 @@
2020
import org.apache.calcite.sql.type.SqlTypeName;
2121
import org.apache.calcite.sql.type.SqlTypeUtil;
2222
import org.opensearch.sql.ast.expression.SpanUnit;
23-
import org.opensearch.sql.calcite.type.AbstractExprRelDataType;
24-
import org.opensearch.sql.calcite.utils.OpenSearchTypeFactory;
25-
import org.opensearch.sql.data.type.ExprCoreType;
23+
import org.opensearch.sql.calcite.type.ExprIPType;
2624
import org.opensearch.sql.exception.ExpressionEvaluationException;
27-
import org.opensearch.sql.exception.SemanticCheckException;
2825
import org.opensearch.sql.expression.function.PPLBuiltinOperators;
2926

3027
public class ExtendedRexBuilder extends RexBuilder {
@@ -146,35 +143,39 @@ public RexNode makeCast(
146143
// SqlStdOperatorTable.NOT_EQUALS,
147144
// ImmutableList.of(exp, makeZeroLiteral(sourceType)));
148145
}
149-
} else if (OpenSearchTypeFactory.isUserDefinedType(type)) {
146+
} else if (type instanceof ExprIPType) {
150147
if (RexLiteral.isNullLiteral(exp)) {
151148
return super.makeCast(pos, type, exp, matchNullability, safe, format);
152149
}
153-
var udt = ((AbstractExprRelDataType<?>) type).getUdt();
154-
var argExprType = OpenSearchTypeFactory.convertRelDataTypeToExprType(sourceType);
155-
return switch (udt) {
156-
case EXPR_DATE -> makeCall(type, PPLBuiltinOperators.DATE, List.of(exp));
157-
case EXPR_TIME -> makeCall(type, PPLBuiltinOperators.TIME, List.of(exp));
158-
case EXPR_TIMESTAMP -> makeCall(type, PPLBuiltinOperators.TIMESTAMP, List.of(exp));
159-
case EXPR_IP -> {
160-
if (argExprType == ExprCoreType.IP) {
161-
yield exp;
162-
} else if (argExprType == ExprCoreType.STRING) {
163-
yield makeCall(type, PPLBuiltinOperators.IP, List.of(exp));
164-
}
165-
// Throwing error inside implementation will be suppressed by Calcite, thus
166-
// throwing 500 error. Therefore, we throw error here to ensure the error
167-
// information is displayed properly.
168-
throw new ExpressionEvaluationException(
169-
String.format(
170-
Locale.ROOT,
171-
"Cannot convert %s to IP, only STRING and IP types are supported",
172-
argExprType));
173-
}
174-
default ->
175-
throw new SemanticCheckException(
176-
String.format(Locale.ROOT, "Cannot cast from %s to %s", argExprType, udt.name()));
177-
};
150+
if (sourceType instanceof ExprIPType) {
151+
return exp;
152+
} else if (SqlTypeUtil.isCharacter(sourceType)) {
153+
return makeCall(type, PPLBuiltinOperators.IP, List.of(exp));
154+
}
155+
// Throwing error inside implementation will be suppressed by Calcite, thus
156+
// throwing 500 error. Therefore, we throw error here to ensure the error
157+
// information is displayed properly.
158+
throw new ExpressionEvaluationException(
159+
String.format(
160+
Locale.ROOT,
161+
"Cannot convert %s to IP, only STRING and IP types are supported",
162+
sourceType));
163+
} else if (sqlType == SqlTypeName.DATE
164+
|| sqlType == SqlTypeName.TIME
165+
|| sqlType == SqlTypeName.TIMESTAMP) {
166+
// Standard SQL temporal target. Lower to PPL UDF so the runtime String → temporal parsing
167+
// matches v2 semantics. The shuttle in OpenSearchCalcitePreparingStmt rewrites these to UDT
168+
// at execution time.
169+
if (RexLiteral.isNullLiteral(exp)) {
170+
return super.makeCast(pos, type, exp, matchNullability, safe, format);
171+
}
172+
if (sqlType == SqlTypeName.DATE) {
173+
return makeCall(type, PPLBuiltinOperators.DATE, List.of(exp));
174+
} else if (sqlType == SqlTypeName.TIME) {
175+
return makeCall(type, PPLBuiltinOperators.TIME, List.of(exp));
176+
} else {
177+
return makeCall(type, PPLBuiltinOperators.TIMESTAMP, List.of(exp));
178+
}
178179
}
179180
// Use a custom operator when casting floating point or decimal number to a character type.
180181
// This patch is necessary because in Calcite, 0.0F is cast to 0E0, decimal 0.x to x
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.sql.calcite.plan.rel;
7+
8+
import org.apache.calcite.rel.RelNode;
9+
import org.apache.calcite.rel.type.RelDataType;
10+
11+
/**
12+
* Marker interface for OpenSearch RelNodes whose row type can be rewritten in place.
13+
*
14+
* <p>The {@link TemporalUdtRewriteShuttle} uses this interface to convert a scan's schema from
15+
* standard Calcite temporal types ({@code TIMESTAMP}, {@code DATE}, {@code TIME}) to the equivalent
16+
* UDT-backed types ({@code ExprTimeStampType}/{@code ExprDateType}/{@code ExprTimeType}, all
17+
* VARCHAR-backed at runtime) just before physical execution.
18+
*
19+
* <p>Wrapping a TableScan in a {@code LogicalProject(CAST...)} is not sufficient: Calcite's Linq4j
20+
* codegen reads the TableScan's row type to generate field accessors, and any standard temporal
21+
* type triggers {@code (Long) row[i]} casts at runtime, which fail when the runtime actually
22+
* delivers VARCHAR values via {@code OpenSearchExprValueFactory}. Implementations of this interface
23+
* must therefore return a NEW RelNode whose row type itself reflects the rewritten UDT schema (not
24+
* a wrapper that hides it from Calcite).
25+
*/
26+
public interface TemporalSchemaRewritable {
27+
/**
28+
* Return a copy of this RelNode whose row type is replaced by {@code rowType}. The structural
29+
* shape of the returned node should otherwise mirror {@code this}.
30+
*/
31+
RelNode withRowType(RelDataType rowType);
32+
}

0 commit comments

Comments
 (0)