Skip to content

Decouple PPL logical plans from UDT temporal types#5547

Draft
penghuo wants to merge 1 commit into
opensearch-project:mainfrom
penghuo:feat/expr_lazy_udt_v1
Draft

Decouple PPL logical plans from UDT temporal types#5547
penghuo wants to merge 1 commit into
opensearch-project:mainfrom
penghuo:feat/expr_lazy_udt_v1

Conversation

@penghuo

@penghuo penghuo commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR makes two related but distinct architectural changes to the Calcite-side PPL planner. Together they let logical RelNode/RexNode trees produced during analysis use ordinary Calcite types end-to-end. UDTs (and ExprType) only re-enter the picture immediately before physical execution, where they're needed to keep Linq4j codegen seeing the existing VARCHAR-backed runtime representation.


1. Decouple ExprType from the logical plan

Calcite-side PPL planning code (RelNode/RexNode construction, coercion, type checking, UDF return-type inference, UDF implementors) now operates on RelDataType directly instead of converting to/from ExprType at every step.

  • Coercion (CoercionUtils): new RelDataType-typed common-type resolver with an internal widening DAG keyed by a small CoercionTag enum (matches v2 widening semantics — STRING → TIMESTAMP direct edge, UNDEFINED → any-concrete unit cost, etc).
  • Type checking (PPLTypeChecker): signatures expressed as List<List<RelDataType>>; new renderTypeName for error messages; new temporalKind helper that canonicalizes DATE/TIME/TIMESTAMP across UDT and standard SQL temporal forms in a single place.
  • Operand metadata (PPLOperandTypes, UDFOperandMetadata): signature constants exposed as RelDataType (e.g. INTEGER_T, STRING_T, IP_UDT).
  • UDF implementors (AddSubDate, Extract, Format, LastDay, PeriodName, TimestampAdd, TimestampDiff, Weekday, Span, WidthBucket, IPFunction, CidrMatchFunction, CompareIpFunction, geo-IP, CurrentFunction): branch on RelDataType (UDT class instances or SqlTypeName) and pass RelDataType through their lowering, instead of bouncing through ExprType.
  • CalciteRexNodeVisitor.visitCast: maps AST DataType directly to RelDataType, removing the DataType.getCoreType() round-trip.
  • ExtendedRexBuilder: type discrimination via instanceof UDT classes and SqlTypeName, no ExprType conversion.

UDT identity is preserved at planning time via instanceof checks (not getExprType()); subclasses survive createTypeWithNullability / createTypeWithCharsetAndCollation via a cloneWith hook on ExprSqlType/ExprJavaType.

2. Remove DATE/TIME/TIMESTAMP UDTs from the logical plan

After the ExprType decoupling lands, the only thing pinning UDT temporal types to the logical layer was convertExprTypeToRelDataType. This change pushes those UDTs out of the logical plan entirely:

  • Type factory: OpenSearchTypeFactory.convertExprTypeToRelDataType returns standard DATE/TIME(9)/TIMESTAMP(9) for the corresponding ExprCoreType. New isStandardTemporalType helper. IP and BINARY remain UDT.
  • Cast emission (CalciteRexNodeVisitor.visitCast): emits standard temporal types for AST DATE/TIME/TIMESTAMP cast targets.
  • Cast lowering (ExtendedRexBuilder.makeCast): standard temporal targets dispatch to PPLBuiltinOperators.DATE/TIME/TIMESTAMP UDFs but keep the call's RelDataType standard. IP routed separately.
  • Pushdown (PredicateAnalyzer): isTimestamp/isDate accept both standard SqlTypeName and UDT, and read literal values via getValueAs(String.class) so TimestampString/DateString round-trip without ClassCastException.
  • Constant rename: NULLABLE_*_UDTNULLABLE_*_T, DATE_UDT/TIME_UDT/TIMESTAMP_UDTDATE_T/TIME_T/TIMESTAMP_T.
  • DatetimeExtension / DatetimeUdtNormalizeRule removed (introduced in Normalize datetime types for unified query API #5408). Their sole purpose was to rewrite UDT temporal return types to standard Calcite types as a unified-API post-analysis pass; the analysis pipeline no longer produces temporal UDTs in the first place, so the rule is a no-op and the extension is removed along with its registrations in UnifiedPplSpec/UnifiedSqlSpec.
TemporalUdtRewriteShuttle (the conversion boundary)

A new RelShuttle runs once at the prepare-statement boundary (OpenSearchCalcitePreparingStmt.implement and OpenSearchRelRunners.run) and rewrites every standard temporal type in the tree back to its UDT counterpart. Implementation notes:

  • Atomic rebuild for stateful nodes. Calcite's default RelShuttleImpl.visitChild rebuilds parents via parent.copy(traits, newInputs) after visiting each child, which makes both rewrite-then-visit and visit-then-rewrite orderings briefly violate row-type consistency. The shuttle intercepts Project/Filter/Calc/Aggregate/Values and rebuilds them in one shot with rewritten inputs and rewritten RexNodes together.
  • Filter rebuild preserves variablesSet. Without this, correlated subqueries lose their CorrelationId binding.
  • RexShuttle extensions: visitSubQuery recurses into the inner plan (Calcite's default doesn't); visitLambda/visitLambdaRef rebuild the lambda body and parameter refs together so transform(arr, x -> ...) over temporal-element arrays stays consistent.
  • rewriteType recurses into ARRAY/MULTISET/MAP/struct, so collection-typed temporal columns (e.g. TAKE(@timestamp, 2)) get rewritten.
  • TemporalSchemaRewritable marker interface. AbstractCalciteIndexScan implements it and rewrites its schema in place via buildScan(...), rather than wrapping in a LogicalProject(CAST(...)). This is what keeps Linq4j codegen reading String values consistent with what OpenSearchExprValueFactory delivers at runtime.
Other adjustments
  • Calcite IT golden files (explain_appendpipe_command.json × 2 profiles): logical plans now show TIMESTAMP(9) instead of EXPR_TIMESTAMP VARCHAR; physical plans still show UDT post-shuttle.

Related Issues

Check List

  • New functionality includes testing.
  • New functionality has been documented.
  • New functionality has javadoc added.
  • New functionality has a user manual doc added.
  • New PPL command checklist all confirmed.
  • API changes companion pull request created.
  • Commits are signed per the DCO using `--signoff` or `-s`.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@penghuo penghuo added the PPL Piped processing language label Jun 12, 2026
@penghuo penghuo self-assigned this Jun 12, 2026
@penghuo penghuo force-pushed the feat/expr_lazy_udt_v1 branch from d17aa3b to 3244bed Compare June 12, 2026 21:50
@penghuo penghuo force-pushed the feat/expr_lazy_udt_v1 branch from 3244bed to 666bec2 Compare July 13, 2026 21:22
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 683bc8a)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Possible Issue

In rebuildValues, when a RexLiteral is rewritten to a RexCall (CAST), the code falls back to wrapTableScanIfNeeded(values) which wraps the Values node in a LogicalProject. However, this fallback is triggered for every tuple that contains a non-literal rewritten node, potentially creating redundant Project nodes or failing to preserve the original Values semantics when only some tuples need casting. The condition if (rewrittenLit instanceof RexLiteral) inside the tuple loop means that if any single literal in any tuple becomes a RexCall, the entire Values node is replaced with a Project-wrapped version, discarding the partially-built newTuples. This can lead to incorrect plans or assertion failures when Calcite expects a Values node with specific tuple structure.

if (values instanceof LogicalValues) {
  // Also rewrite each RexLiteral in every tuple so column types agree with the new row type.
  // Otherwise LogicalValues.create asserts on a std-TIMESTAMP literal under a UDT column.
  ImmutableList.Builder<ImmutableList<RexLiteral>> newTuples = ImmutableList.builder();
  for (ImmutableList<RexLiteral> tuple : values.getTuples()) {
    ImmutableList.Builder<RexLiteral> newTuple = ImmutableList.builder();
    for (RexLiteral lit : tuple) {
      RexNode rewrittenLit = lit.accept(rexShuttle);
      if (rewrittenLit instanceof RexLiteral rewrittenRexLit) {
        newTuple.add(rewrittenRexLit);
      } else {
        // TemporalRexShuttle.visitLiteral wraps in a CAST for UDT-target types, producing a
        // RexCall (not a RexLiteral). LogicalValues can only hold RexLiterals, so we can't
        // use LogicalValues here — fall back to LogicalProject(CAST...) over the values.
        return wrapTableScanIfNeeded(values);
      }
    }
    newTuples.add(newTuple.build());
  }
  return LogicalValues.create(values.getCluster(), rewritten, newTuples.build());
}
return wrapTableScanIfNeeded(values);
Possible Issue

The distance method uses CoercionTag.UNDEFINED to represent NULL literals and assigns them unit widening cost to any concrete type. However, the code does not validate that the target type is truly concrete before returning acc + 1. If to is CoercionTag.UNKNOWN, the method returns acc + 1 instead of IMPOSSIBLE_WIDENING, allowing NULL to widen to UNKNOWN. This contradicts the comment stating UNDEFINED widens only to concrete types and may cause incorrect signature ranking when UNKNOWN-typed operands are involved.

private static int distance(CoercionTag from, CoercionTag to, int acc) {
  if (from == to) return acc;
  if (from == CoercionTag.UNKNOWN) return IMPOSSIBLE_WIDENING;
  // UNDEFINED (NULL literal) widens to any concrete type at unit cost — mirrors v2 where every
  // concrete ExprCoreType has UNDEFINED as a parent.
  if (from == CoercionTag.UNDEFINED && to != CoercionTag.UNKNOWN) return acc + 1;
  // STRING widens directly to DOUBLE (custom rule mirroring v2 behavior).
Possible Issue

In isComparable, the code checks temporalKind(type1) != null && temporalKind(type1) == temporalKind(type2) to allow cross-form temporal comparisons (standard SQL DATE/TIME/TIMESTAMP vs UDT). However, temporalKind returns SqlTypeName.DATE/TIME/TIMESTAMP for both standard and UDT forms, so two different UDT temporal types (e.g., ExprDateType and ExprTimeType) will have different temporalKind results and correctly fail the equality check. But the subsequent check type1 instanceof AbstractExprRelDataType<?> && type1.getClass() == type2.getClass() is redundant when both are UDTs of the same temporal kind, because the temporalKind equality already passed. More critically, if type1 is a standard DATE and type2 is a UDT DATE, the temporalKind check passes and returns true, but if type1 is a standard DATE and type2 is a standard TIME, the check correctly returns false. The logic is sound, but the ordering and redundancy may confuse future maintainers or cause subtle bugs if temporalKind is modified.

// Same UDT class is comparable, OR cross-form temporal equivalence (standard ↔ UDT).
if (temporalKind(type1) != null && temporalKind(type1) == temporalKind(type2)) {
  return true;
}
if (type1 instanceof AbstractExprRelDataType<?> && type1.getClass() == type2.getClass()) {
  return true;
}

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 683bc8a

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Prevent infinite recursion in shuttle

The default path applies rexShuttle to all unhandled node types, but rexShuttle
holds a reference to the outer TemporalUdtRewriteShuttle (via relShuttle). This can
cause infinite recursion if a node type triggers the default path and contains a
subquery whose inner plan revisits the same node type. Add a guard to prevent
re-entry or ensure rexShuttle is only applied to nodes that genuinely need RexNode
rewriting.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [183-185]

 if (other instanceof Project project) {
   return rebuildProject(project);
 }
 if (other instanceof Filter filter) {
   return rebuildFilter(filter);
 }
 if (other instanceof Calc calc) {
   return rebuildCalc(calc);
 }
 if (other instanceof Aggregate agg) {
   return rebuildAggregate(agg);
 }
 if (other instanceof Values values) {
   return rebuildValues(values);
 }
 if (other instanceof TableScan) {
   return wrapTableScanIfNeeded(other);
 }
 // Default path: visit children, rebuild via parent.copy(traits, newInputs). Then apply rex
-// shuttle (for nodes without RexNode validation in the constructor, e.g. Sort, Join, Union).
+// shuttle only if the node type is known to store RexNodes (e.g. Sort, Join, Union).
 RelNode visited = super.visit(other);
-return visited.accept(rexShuttle);
+if (visited instanceof org.apache.calcite.rel.core.Sort
+    || visited instanceof org.apache.calcite.rel.core.Join
+    || visited instanceof org.apache.calcite.rel.core.Union) {
+  return visited.accept(rexShuttle);
+}
+return visited;
Suggestion importance[1-10]: 7

__

Why: The suggestion identifies a potential infinite recursion risk when rexShuttle (which holds a reference to the outer shuttle) is applied to all unhandled node types. However, the proposed fix restricts rexShuttle application to only Sort, Join, and Union, which may be too narrow and could break rewriting for other node types that store RexNodes. A more robust solution would involve cycle detection or ensuring rexShuttle is only applied when necessary.

Medium
General
Avoid redundant nullable type creation

The cast target is always made nullable, but the original targetType may already be
nullable. Calling createTypeWithNullability(targetType, true) when
targetType.isNullable() is already true can produce a redundant type wrapper. Check
nullability first to avoid unnecessary type factory calls.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [143-144]

-return builder.makeCast(
-    builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
+RelDataType nullableTarget = targetType.isNullable()
+    ? targetType
+    : builder.getTypeFactory().createTypeWithNullability(targetType, true);
+return builder.makeCast(nullableTarget, arg, true, true);
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies that calling createTypeWithNullability(targetType, true) when targetType is already nullable is redundant. The proposed check avoids unnecessary type factory calls, improving efficiency. However, the impact is minor since type factory operations are typically lightweight, and the code is already correct functionally.

Low
Memoize distance calculation for performance

The recursive distance calculation can revisit the same (from, to) pair multiple
times when the widening DAG has multiple paths. For large type hierarchies or deeply
nested calls, this can degrade performance. Consider memoizing intermediate results
in a map to avoid redundant recursion.

core/src/main/java/org/opensearch/sql/expression/function/CoercionUtils.java [294-310]

+private static final Map<Pair<CoercionTag, CoercionTag>, Integer> distanceCache = new ConcurrentHashMap<>();
+
 private static int distance(CoercionTag from, CoercionTag to, int acc) {
   if (from == to) return acc;
   if (from == CoercionTag.UNKNOWN) return IMPOSSIBLE_WIDENING;
-  // UNDEFINED (NULL literal) widens to any concrete type at unit cost — mirrors v2 where every
-  // concrete ExprCoreType has UNDEFINED as a parent.
   if (from == CoercionTag.UNDEFINED && to != CoercionTag.UNKNOWN) return acc + 1;
-  // STRING widens directly to DOUBLE (custom rule mirroring v2 behavior).
   if (from == CoercionTag.STRING && to == CoercionTag.DOUBLE) return acc + 1;
+  Pair<CoercionTag, CoercionTag> key = Pair.of(from, to);
+  Integer cached = distanceCache.get(key);
+  if (cached != null) return cached + acc;
   List<CoercionTag> parents = parentsOf(from);
   if (parents.isEmpty()) return IMPOSSIBLE_WIDENING;
   int best = IMPOSSIBLE_WIDENING;
   for (CoercionTag parent : parents) {
     int d = distance(parent, to, acc + 1);
     if (d < best) best = d;
   }
+  distanceCache.put(key, best - acc);
   return best;
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion proposes memoization to avoid redundant recursion in the distance calculation. While this could improve performance for large type hierarchies, the current widening DAG is small (a few dozen types) and the recursion depth is shallow. The added complexity of maintaining a cache (including thread-safety concerns with ConcurrentHashMap) may not be justified by the marginal performance gain. Additionally, the proposed implementation incorrectly caches best - acc instead of the absolute distance, which could lead to incorrect results.

Low

Previous suggestions

Suggestions up to commit 24cf40c
CategorySuggestion                                                                                                                                    Impact
General
Preserve source nullability in cast

The cast target is always made nullable, but the source arg may already be nullable.
When the source is nullable and the target is NOT NULL, the cast should preserve the
source's nullability to avoid stripping the null-carrying property. Check
arg.getType().isNullable() and only force nullable when the source is nullable.

core/src/main/java/org/opensearch/sql/expression/function/CoercionUtils.java [142-145]

 if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
+  boolean targetNullable = arg.getType().isNullable() || targetType.isNullable();
   return builder.makeCast(
-      builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
+      builder.getTypeFactory().createTypeWithNullability(targetType, targetNullable), arg, true, true);
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies that the cast target is always made nullable, but the logic is already sound: safe casts (safe=true) return null on failure, so the target must be nullable. The proposed change to check arg.getType().isNullable() would not improve correctness and may introduce subtle bugs if the source is NOT NULL but the cast can still fail.

Low
Log warning for non-rewritable scans

The fallback path wraps the scan in a LogicalProject(CAST...) when the scan is not
TemporalSchemaRewritable. This introduces a new projection node that may not be
expected by downstream rules or codegen. Consider logging a warning or throwing an
exception to surface scans that don't implement the interface, ensuring all
production scans are properly rewritable.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [293-303]

 if (rewritten == current) {
   return scan;
 }
-// Preferred path: rewrite the scan's row type in place so Calcite's Linq4j codegen sees the
-// same UDT (VARCHAR-backed) types that OpenSearchExprValueFactory delivers at runtime.
 if (scan instanceof TemporalSchemaRewritable rewritable) {
   return rewritable.withRowType(rewritten);
 }
 // Fallback for test stubs and non-OpenSearch scans: wrap in a LogicalProject(CAST...).
+// Log a warning so non-rewritable scans are surfaced during development.
+LOG.warn("Scan {} does not implement TemporalSchemaRewritable; wrapping in LogicalProject", scan.getClass().getSimpleName());
Suggestion importance[1-10]: 4

__

Why: Adding a warning log for non-rewritable scans is a reasonable debugging aid, but the fallback path is intentionally provided for test stubs and non-OpenSearch scans. The suggestion does not address a correctness issue and the warning may be noisy in test environments where the fallback is expected.

Low
Guard against infinite recursion

The recursive distance calculation does not guard against cycles in the parent
graph. If a future change introduces a cycle (e.g., A -> B -> A), the method will
recurse indefinitely and cause a stack overflow. Add a visited set or depth limit to
prevent infinite recursion.

core/src/main/java/org/opensearch/sql/expression/function/CoercionUtils.java [294-310]

 private static int distance(CoercionTag from, CoercionTag to, int acc) {
+  if (acc > 20) return IMPOSSIBLE_WIDENING; // depth limit to prevent infinite recursion
   if (from == to) return acc;
   if (from == CoercionTag.UNKNOWN) return IMPOSSIBLE_WIDENING;
-  // UNDEFINED (NULL literal) widens to any concrete type at unit cost — mirrors v2 where every
-  // concrete ExprCoreType has UNDEFINED as a parent.
   if (from == CoercionTag.UNDEFINED && to != CoercionTag.UNKNOWN) return acc + 1;
-  // STRING widens directly to DOUBLE (custom rule mirroring v2 behavior).
   if (from == CoercionTag.STRING && to == CoercionTag.DOUBLE) return acc + 1;
   List<CoercionTag> parents = parentsOf(from);
   if (parents.isEmpty()) return IMPOSSIBLE_WIDENING;
   int best = IMPOSSIBLE_WIDENING;
   for (CoercionTag parent : parents) {
     int d = distance(parent, to, acc + 1);
     if (d < best) best = d;
   }
   return best;
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion to add a depth limit is a defensive measure against cycles in the parent graph. However, the PARENTS map is statically built and does not contain cycles by construction. The depth limit would only mask a programming error rather than prevent a real runtime issue. A better approach would be to validate the graph at build time.

Low
Suggestions up to commit 3346e5a
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null check for type parameter

Add a null check for type before calling instanceof checks. If type is null, the
method will throw a NullPointerException when attempting the first instanceof check.
Return CoercionTag.UNKNOWN early if type is null to prevent runtime failures.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [205-237]

 private static CoercionTag tagOf(RelDataType type) {
+  if (type == null) return CoercionTag.UNKNOWN;
   if (type instanceof ExprDateType) return CoercionTag.DATE;
   if (type instanceof ExprTimeType) return CoercionTag.TIME;
   if (type instanceof ExprTimeStampType) return CoercionTag.TIMESTAMP;
   if (type instanceof ExprIPType) return CoercionTag.IP;
   if (type instanceof ExprBinaryType) return CoercionTag.BINARY;
   SqlTypeName name = type.getSqlTypeName();
   if (name == null) return CoercionTag.UNKNOWN;
   return switch (name) {
     ...
   };
 }
Suggestion importance[1-10]: 7

__

Why: The tagOf method performs instanceof checks on type without first verifying it is non-null. If type is null, a NullPointerException will be thrown. Adding an early null check improves robustness, though the method is internal and callers may already guarantee non-null inputs.

Medium
Guard against null argType

Verify that arg.getType() does not return null before proceeding with type
operations. If argType is null, subsequent calls to shouldCast, distance, and
resolveCommonType may fail or produce incorrect results. Add an early null check and
return null to signal casting failure.

core/src/main/java/org/opensearch/sql/expression/function/CoercionUtils.java [127-155]

 private static @Nullable RexNode cast(RexBuilder builder, RelDataType targetType, RexNode arg) {
   RelDataType argType = arg.getType();
+  if (argType == null) {
+    return null;
+  }
   if (!shouldCast(argType, targetType)) {
     return arg;
   }
   ...
   if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
     return builder.makeCast(
         builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
   }
   return resolveCommonType(argType, targetType)
       .map(
           common ->
               builder.makeCast(
                   builder.getTypeFactory().createTypeWithNullability(common, true),
                   arg,
                   true,
                   true))
       .orElse(null);
 }
Suggestion importance[1-10]: 7

__

Why: The cast method calls arg.getType() and uses the result without checking for null. If argType is null, subsequent calls to shouldCast, distance, and resolveCommonType may fail. Adding a null check prevents potential runtime errors, though the method signature already uses @Nullable to signal possible null returns.

Medium
Add null check for RelDataType parameter

Add a null check for t at the start of the method. If t is null, calling
t.getSqlTypeName() or t.isNullable() will throw a NullPointerException. Return t
unchanged (or a safe default) if it is null to prevent runtime failures.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [92-138]

 static RelDataType rewriteType(OpenSearchTypeFactory tf, RelDataType t) {
+  if (t == null) {
+    return t;
+  }
   SqlTypeName name = t.getSqlTypeName();
   boolean nullable = t.isNullable();
   if (name == SqlTypeName.TIMESTAMP) {
     return tf.createUDT(ExprUDT.EXPR_TIMESTAMP, nullable);
   }
   if (name == SqlTypeName.DATE) {
     return tf.createUDT(ExprUDT.EXPR_DATE, nullable);
   }
   if (name == SqlTypeName.TIME) {
     return tf.createUDT(ExprUDT.EXPR_TIME, nullable);
   }
   ...
 }
Suggestion importance[1-10]: 7

__

Why: The rewriteType method calls t.getSqlTypeName() and t.isNullable() without first checking if t is null. If t is null, a NullPointerException will be thrown. Adding a null check at the start of the method improves defensive programming, though callers may already ensure non-null inputs.

Medium
Add null checks for type parameters

Add null checks for expected and actual before invoking methods on them. If either
parameter is null, calling temporalKind, instanceof, or getSqlTypeName may throw a
NullPointerException. Return false early if either is null to prevent runtime
failures.

core/src/main/java/org/opensearch/sql/expression/function/PPLTypeChecker.java [576-586]

 private static boolean typesMatch(RelDataType expected, RelDataType actual) {
+  if (expected == null || actual == null) {
+    return false;
+  }
   if (temporalKind(expected) != null && temporalKind(expected) == temporalKind(actual)) {
     return true;
   }
   if (expected instanceof AbstractExprRelDataType<?>
       || actual instanceof AbstractExprRelDataType<?>) {
     return expected.getClass() == actual.getClass();
   }
   return expected.getSqlTypeName() == actual.getSqlTypeName();
 }
Suggestion importance[1-10]: 7

__

Why: The typesMatch method calls methods on expected and actual without verifying they are non-null. If either parameter is null, a NullPointerException may be thrown. Adding null checks at the start of the method prevents runtime failures, though the method is private and callers may already guarantee non-null inputs.

Medium
Suggestions up to commit b9d68eb
CategorySuggestion                                                                                                                                    Impact
Possible issue
Preserve source nullability in cast

The cast target type is always made nullable, but the source arg may already be
nullable. When the source is nullable and the target is NOT NULL, this creates a
nullable-to-nullable cast that still strips the null-carrying property in Calcite's
type system. Preserve the source's nullability by using arg.getType().isNullable()
|| targetType.isNullable() instead of hardcoding true.

core/src/main/java/org/opensearch/sql/expression/function/CoercionUtils.java [142-145]

 if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
+  boolean nullable = arg.getType().isNullable() || targetType.isNullable();
   return builder.makeCast(
-      builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
+      builder.getTypeFactory().createTypeWithNullability(targetType, nullable), arg, true, true);
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that hardcoding true for nullability may not preserve the source's nullability semantics. However, the PR explicitly states that casts are always safe (safe=true) and return null on failure, so making the target nullable is intentional. The suggestion is valid but may not align with the PR's design intent.

Medium
General
Use direct nullable array constructor

The array type is created with -1 cardinality, then wrapped with
createTypeWithNullability. Calcite's createArrayType already accepts a nullable
parameter. Use tf.createArrayType(rewritten, -1, nullable) directly to avoid
double-wrapping and potential type-system inconsistencies.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [109-111]

 if (rewritten != component) {
-  return tf.createTypeWithNullability(tf.createArrayType(rewritten, -1), nullable);
+  return tf.createArrayType(rewritten, -1, nullable);
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion proposes using createArrayType(rewritten, -1, nullable) directly instead of wrapping with createTypeWithNullability. This is a valid optimization if the method signature supports it, but the PR code is correct as-is and the improvement is marginal.

Low
Propagate nullability in map types

The map type is created with the original nullable flag even when the key or value
types have changed. If the rewritten key or value types introduce nullability, the
map's nullability should reflect that. Consider using nullable || keyR.isNullable()
|| valueR.isNullable() to propagate nullability correctly.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [129-131]

 if (keyR != key || valueR != value) {
-  return tf.createMapType(keyR, valueR, nullable);
+  boolean mapNullable = nullable || keyR.isNullable() || valueR.isNullable();
+  return tf.createMapType(keyR, valueR, mapNullable);
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion proposes propagating nullability from key/value types to the map type. While this could be more precise, the PR's approach of using the original nullable flag is consistent with the rewriting logic and does not introduce a bug. The suggestion is a minor enhancement.

Low
Suggestions up to commit e90ffd3
CategorySuggestion                                                                                                                                    Impact
General
Preserve source nullability in cast

The cast target type is always made nullable, but the source arg may already be
nullable. When the source is nullable and the target is NOT NULL, this creates a
nullable-to-nullable cast that still strips the null-carrying property in Calcite's
type system. Verify that targetType is already nullable before wrapping it, or use
arg.getType().isNullable() to preserve the source's nullability.

core/src/main/java/org/opensearch/sql/expression/function/CoercionUtils.java [142-145]

 if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
+  boolean nullable = arg.getType().isNullable() || targetType.isNullable();
   return builder.makeCast(
-      builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
+      builder.getTypeFactory().createTypeWithNullability(targetType, nullable), arg, true, true);
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that the cast target is always made nullable without considering the source's nullability. However, the PR comment at lines 132-141 explicitly states that the target must be nullable to support safe casts and NullPolicy.ANY short-circuiting. The suggestion's logic to preserve source nullability may conflict with this design decision, so the score is moderate.

Medium
Rewrite subquery type after inner plan

The shuttle recursively rewrites the inner plan of a subquery, but does not check
whether the rewritten inner plan's row type changed. If the inner plan's row type
was rewritten (e.g. temporal columns became UDT), the subquery's cached type may
become stale. Verify that clone.getType() matches rewrittenInner.getRowType() after
cloning, or rebuild the subquery with the new row type.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [382-399]

 @Override
 public RexNode visitSubQuery(RexSubQuery subQuery) {
   RexNode rewrittenOperands = super.visitSubQuery(subQuery);
   if (relShuttle == null) {
     return rewrittenOperands;
   }
   RexSubQuery base =
       (rewrittenOperands instanceof RexSubQuery) ? (RexSubQuery) rewrittenOperands : subQuery;
   RelNode rewrittenInner = base.rel.accept(relShuttle);
   if (rewrittenInner == base.rel && rewrittenOperands == subQuery) {
     return subQuery;
   }
-  RelDataType rewrittenType = rewriteType(tf, base.getType());
   RexSubQuery clone = base.clone(rewrittenInner);
-  if (rewrittenType != base.getType()) {
+  RelDataType rewrittenType = rewriteType(tf, clone.getType());
+  if (rewrittenType != clone.getType()) {
     clone = (RexSubQuery) clone.clone(rewrittenType, clone.getOperands());
   }
   return clone;
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion raises a valid concern about the subquery's cached type potentially becoming stale after rewriting the inner plan. However, the existing code already calls rewriteType(tf, base.getType()) and clones the subquery with the rewritten type if needed. The suggested change to call rewriteType on clone.getType() instead of base.getType() is a minor reordering that may improve clarity but does not address a critical bug.

Low
Guard UDT class comparison

The isComparable check allows cross-form temporal equivalence (standard ↔ UDT) and
same-UDT-class comparisons, but does not verify that both types are actually UDTs
before comparing classes. If type1 is a UDT and type2 is a standard type with the
same class (unlikely but possible in a mixed hierarchy), the check may incorrectly
return true. Add a guard to ensure both are UDTs before the class comparison.

core/src/main/java/org/opensearch/sql/expression/function/PPLTypeChecker.java [312-314]

 private static boolean isComparable(RelDataType type1, RelDataType type2) {
   ...
   if (temporalKind(type1) != null && temporalKind(type1) == temporalKind(type2)) {
     return true;
   }
-  if (type1 instanceof AbstractExprRelDataType<?> && type1.getClass() == type2.getClass()) {
+  if (type1 instanceof AbstractExprRelDataType<?> 
+      && type2 instanceof AbstractExprRelDataType<?>
+      && type1.getClass() == type2.getClass()) {
     return true;
   }
   ...
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion adds a guard to ensure both types are UDTs before comparing classes. While this is a defensive check, the existing code already checks type1 instanceof AbstractExprRelDataType<?> before the class comparison, which should prevent the scenario described. The additional check for type2 is redundant given the subsequent class equality check, so the impact is minimal.

Low
Rebuild Calc program with rewritten expressions

The RexProgram is rebuilt by rewriting the expression list but passing the original
projects and condition references unchanged. If the rewritten expressions have
different indices or types, the RexLocalRef indices in projects and condition may
point to stale or mismatched expressions. Verify that the RexLocalRef indices remain
valid after rewriting, or rebuild the program from scratch using RexProgramBuilder
methods that recompute the references.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [215-251]

 private RelNode rebuildCalc(Calc calc) {
   List<RelNode> newInputs = rewriteChildren(calc);
   RelNode newInput = newInputs.get(0);
   RexProgram program = calc.getProgram();
-  ...
-  List<RexNode> exprs = new ArrayList<>();
+  RexBuilder rexBuilder = calc.getCluster().getRexBuilder();
+  RexProgramBuilder builder = new RexProgramBuilder(newInput.getRowType(), rexBuilder);
   for (RexNode e : program.getExprList()) {
-    exprs.add(e.accept(rexShuttle));
+    builder.registerInput(e.accept(rexShuttle));
   }
-  List<RexLocalRef> projects = program.getProjectList();
-  RexLocalRef condition = program.getCondition();
-  RelDataType rewrittenOutput = rewriteRowType(typeFactory, program.getOutputRowType());
-  RexProgram rebuilt =
-      RexProgramBuilder.create(
-              rexBuilder,
-              newInput.getRowType(),
-              exprs,
-              projects,
-              condition,
-              rewrittenOutput,
-              true,
-              null)
-          .getProgram(false);
-  ...
+  for (RexLocalRef proj : program.getProjectList()) {
+    builder.addProject(proj.getIndex(), null);
+  }
+  if (program.getCondition() != null) {
+    builder.addCondition(program.getCondition());
+  }
+  RexProgram rebuilt = builder.getProgram(false);
+  if (calc instanceof LogicalCalc) {
+    return LogicalCalc.create(newInput, rebuilt);
+  }
+  return calc.copy(calc.getTraitSet(), newInput, rebuilt);
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion proposes rebuilding the RexProgram using RexProgramBuilder methods that recompute references. However, the existing code already uses RexProgramBuilder.create which handles the bookkeeping of RexLocalRef indices. The suggested approach using registerInput and addProject is more manual and may not correctly handle the implicit input refs at the head of the expression list, so the improvement is questionable.

Low
Suggestions up to commit 2f9778b
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix RexLocalRef index mismatch in Calc

The RexProgramBuilder.create call passes projects and condition which are
RexLocalRef instances referencing the OLD program's expression list. After rewriting
exprs, the indices in projects and condition may no longer be valid. Rebuild
projects and condition to reference the new expression list indices.

core/src/main/java/org/opensearch/sql/calcite/plan/rel/TemporalUdtRewriteShuttle.java [214-251]

 private RelNode rebuildCalc(Calc calc) {
   List<RelNode> newInputs = rewriteChildren(calc);
   RelNode newInput = newInputs.get(0);
   RexProgram program = calc.getProgram();
-  ...
-  List<RexNode> exprs = new ArrayList<>();
+  RexBuilder rexBuilder = calc.getCluster().getRexBuilder();
+  RexProgramBuilder builder = new RexProgramBuilder(newInput.getRowType(), rexBuilder);
   for (RexNode e : program.getExprList()) {
-    exprs.add(e.accept(rexShuttle));
+    builder.registerInput(e.accept(rexShuttle));
   }
-  List<RexLocalRef> projects = program.getProjectList();
-  RexLocalRef condition = program.getCondition();
-  RelDataType rewrittenOutput = rewriteRowType(typeFactory, program.getOutputRowType());
-  RexProgram rebuilt =
-      RexProgramBuilder.create(
-              rexBuilder,
-              newInput.getRowType(),
-              exprs,
-              projects,
-              condition,
-              rewrittenOutput,
-              true,
-              null)
-          .getProgram(false);
-  ...
+  for (RexLocalRef proj : program.getProjectList()) {
+    builder.addProject(proj.getIndex(), null);
+  }
+  if (program.getCondition() != null) {
+    builder.addCondition(program.getCondition());
+  }
+  RexProgram rebuilt = builder.getProgram(false);
+  if (calc instanceof LogicalCalc) {
+    return LogicalCalc.create(newInput, rebuilt);
+  }
+  return calc.copy(calc.getTraitSet(), newInput, rebuilt);
 }
Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies a critical bug: after rewriting exprs, the RexLocalRef indices in projects and condition still reference the old expression list. The improved code uses RexProgramBuilder properly by registering inputs and adding projects/conditions, ensuring indices remain valid. This is a high-impact fix for correctness.

High
Prevent infinite recursion in distance

The recursive distance calculation does not guard against cycles in the parent
graph. If a cycle exists (e.g., due to a configuration error), the method will
recurse indefinitely and cause a stack overflow. Add a visited set or depth limit to
prevent infinite recursion.

core/src/main/java/org/opensearch/sql/expression/function/CoercionUtils.java [277-293]

-private static int distance(CoercionTag from, CoercionTag to, int acc) {
+private static int distance(CoercionTag from, CoercionTag to, int acc, Set<CoercionTag> visited) {
   if (from == to) return acc;
-  if (from == CoercionTag.UNKNOWN) return IMPOSSIBLE_WIDENING;
-  // UNDEFINED (NULL literal) widens to any concrete type at unit cost — mirrors v2 where every
-  // concrete ExprCoreType has UNDEFINED as a parent.
+  if (from == CoercionTag.UNKNOWN || visited.contains(from)) return IMPOSSIBLE_WIDENING;
+  visited.add(from);
   if (from == CoercionTag.UNDEFINED && to != CoercionTag.UNKNOWN) return acc + 1;
-  // STRING widens directly to DOUBLE (custom rule mirroring v2 behavior).
   if (from == CoercionTag.STRING && to == CoercionTag.DOUBLE) return acc + 1;
   List<CoercionTag> parents = parentsOf(from);
   if (parents.isEmpty()) return IMPOSSIBLE_WIDENING;
   int best = IMPOSSIBLE_WIDENING;
   for (CoercionTag parent : parents) {
-    int d = distance(parent, to, acc + 1);
+    int d = distance(parent, to, acc + 1, visited);
     if (d < best) best = d;
   }
   return best;
 }
Suggestion importance[1-10]: 8

__

Why: The suggestion identifies a real risk: if the PARENTS map contains a cycle, the recursive distance method will overflow the stack. While the current PARENTS map is acyclic by construction, adding a visited set is a defensive measure that prevents future configuration errors from causing runtime failures. The suggestion is valid and improves robustness.

Medium
Handle nullability in type matching

The method does not handle nullability correctly. Two types with the same
SqlTypeName but different nullability (e.g., INTEGER vs INTEGER NOT NULL) will
match, which may cause incorrect signature matching. Consider using
SqlTypeUtil.equalSansNullability or explicitly ignoring nullability if that is the
intended behavior.

core/src/main/java/org/opensearch/sql/expression/function/PPLTypeChecker.java [576-586]

 private static boolean typesMatch(RelDataType expected, RelDataType actual) {
   // Standard ↔ UDT temporal equivalence: same temporal "kind" matches across forms.
   if (temporalKind(expected) != null && temporalKind(expected) == temporalKind(actual)) {
     return true;
   }
   if (expected instanceof AbstractExprRelDataType<?>
       || actual instanceof AbstractExprRelDataType<?>) {
     return expected.getClass() == actual.getClass();
   }
-  return expected.getSqlTypeName() == actual.getSqlTypeName();
+  return SqlTypeUtil.equalSansNullability(OpenSearchTypeFactory.TYPE_FACTORY, expected, actual);
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that typesMatch compares SqlTypeName directly without considering nullability. However, the PR's intent is to match types for signature validation where nullability is typically ignored. Using SqlTypeUtil.equalSansNullability would be more explicit and robust, though the current implementation may work in practice if nullability is not critical for signature matching.

Medium
Cast to target type via common

When distance(argType, targetType) is IMPOSSIBLE_WIDENING, the code falls back to
resolveCommonType(argType, targetType) and casts to the common type. However, the
caller expects a cast to targetType, not to an intermediate common type. This may
produce a cast to the wrong type. Either cast to targetType after casting to the
common type, or return null if no direct widening path exists.

core/src/main/java/org/opensearch/sql/expression/function/CoercionUtils.java [127-138]

 private static @Nullable RexNode cast(RexBuilder builder, RelDataType targetType, RexNode arg) {
   RelDataType argType = arg.getType();
   if (!shouldCast(argType, targetType)) {
     return arg;
   }
   if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
     return builder.makeCast(targetType, arg, true, true);
   }
   return resolveCommonType(argType, targetType)
-      .map(common -> builder.makeCast(common, arg, true, true))
+      .map(common -> {
+        RexNode intermediate = builder.makeCast(common, arg, true, true);
+        return builder.makeCast(targetType, intermediate, true, true);
+      })
       .orElse(null);
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion raises a valid concern: when distance is IMPOSSIBLE_WIDENING, the code casts to a common type instead of the target type. However, the caller (castArguments) expects a cast to the target type. The improved code chains two casts (arg → common → target), which is more correct but may introduce redundant casts. The issue is real but the fix may need refinement to avoid double-casting overhead.

Low

@penghuo penghuo force-pushed the feat/expr_lazy_udt_v1 branch 2 times, most recently from 2f9778b to e90ffd3 Compare July 14, 2026 15:59
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e90ffd3

@penghuo penghuo force-pushed the feat/expr_lazy_udt_v1 branch from e90ffd3 to b9d68eb Compare July 14, 2026 16:51
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b9d68eb

@penghuo penghuo force-pushed the feat/expr_lazy_udt_v1 branch from b9d68eb to 3346e5a Compare July 14, 2026 17:36
@penghuo penghuo added the enhancement New feature or request label Jul 14, 2026
{
"calcite": {
"logical":"LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], cnt=[$19])\n LogicalUnion(all=[true])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], _id=[$13], _index=[$14], _score=[$15], _maxscore=[$16], _sort=[$17], _routing=[$18], cnt=[null:BIGINT])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n LogicalProject(account_number=[null:BIGINT], firstname=[null:VARCHAR], address=[null:VARCHAR], birthdate=[null:EXPR_TIMESTAMP VARCHAR], gender=[$0], city=[null:VARCHAR], lastname=[null:VARCHAR], balance=[null:BIGINT], employer=[null:VARCHAR], state=[null:VARCHAR], age=[null:INTEGER], email=[null:VARCHAR], male=[null:BOOLEAN], _id=[null:VARCHAR], _index=[null:VARCHAR], _score=[null:REAL], _maxscore=[null:REAL], _sort=[null:BIGINT], _routing=[null:VARCHAR], cnt=[$1])\n LogicalAggregate(group=[{0}], cnt=[COUNT($1)])\n LogicalProject(gender=[$4], balance=[$7])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n",
"logical":"LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], cnt=[$19])\n LogicalUnion(all=[true])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], _id=[$13], _index=[$14], _score=[$15], _maxscore=[$16], _sort=[$17], _routing=[$18], cnt=[null:BIGINT])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n LogicalProject(account_number=[null:BIGINT], firstname=[null:VARCHAR], address=[null:VARCHAR], birthdate=[null:TIMESTAMP(9)], gender=[$0], city=[null:VARCHAR], lastname=[null:VARCHAR], balance=[null:BIGINT], employer=[null:VARCHAR], state=[null:VARCHAR], age=[null:INTEGER], email=[null:VARCHAR], male=[null:BOOLEAN], _id=[null:VARCHAR], _index=[null:VARCHAR], _score=[null:REAL], _maxscore=[null:REAL], _sort=[null:BIGINT], _routing=[null:VARCHAR], cnt=[$1])\n LogicalAggregate(group=[{0}], cnt=[COUNT($1)])\n LogicalProject(gender=[$4], balance=[$7])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

birthdate=[null:EXPR_TIMESTAMP VARCHAR] -> birthdate=[null:TIMESTAMP(9)]
This is expected change. Logical plan date type should be TIMESTAMP(9).

{
"calcite": {
"logical":"LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], cnt=[$19])\n LogicalUnion(all=[true])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], _id=[$13], _index=[$14], _score=[$15], _maxscore=[$16], _sort=[$17], _routing=[$18], cnt=[null:BIGINT])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n LogicalProject(account_number=[null:BIGINT], firstname=[null:VARCHAR], address=[null:VARCHAR], birthdate=[null:EXPR_TIMESTAMP VARCHAR], gender=[$0], city=[null:VARCHAR], lastname=[null:VARCHAR], balance=[null:BIGINT], employer=[null:VARCHAR], state=[null:VARCHAR], age=[null:INTEGER], email=[null:VARCHAR], male=[null:BOOLEAN], _id=[null:VARCHAR], _index=[null:VARCHAR], _score=[null:REAL], _maxscore=[null:REAL], _sort=[null:BIGINT], _routing=[null:VARCHAR], cnt=[$1])\n LogicalAggregate(group=[{0}], cnt=[COUNT($1)])\n LogicalProject(gender=[$4], balance=[$7])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n",
"logical":"LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], cnt=[$19])\n LogicalUnion(all=[true])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], _id=[$13], _index=[$14], _score=[$15], _maxscore=[$16], _sort=[$17], _routing=[$18], cnt=[null:BIGINT])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n LogicalProject(account_number=[null:BIGINT], firstname=[null:VARCHAR], address=[null:VARCHAR], birthdate=[null:TIMESTAMP(9)], gender=[$0], city=[null:VARCHAR], lastname=[null:VARCHAR], balance=[null:BIGINT], employer=[null:VARCHAR], state=[null:VARCHAR], age=[null:INTEGER], email=[null:VARCHAR], male=[null:BOOLEAN], _id=[null:VARCHAR], _index=[null:VARCHAR], _score=[null:REAL], _maxscore=[null:REAL], _sort=[null:BIGINT], _routing=[null:VARCHAR], cnt=[$1])\n LogicalAggregate(group=[{0}], cnt=[COUNT($1)])\n LogicalProject(gender=[$4], balance=[$7])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

birthdate=[null:EXPR_TIMESTAMP VARCHAR] -> birthdate=[null:TIMESTAMP(9)]
This is expected change. Logical plan date type should be TIMESTAMP(9).

@penghuo penghuo force-pushed the feat/expr_lazy_udt_v1 branch 2 times, most recently from 24cf40c to 683bc8a Compare July 14, 2026 20:33
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 683bc8a

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>
@penghuo penghuo force-pushed the feat/expr_lazy_udt_v1 branch from 683bc8a to 60a2c71 Compare July 14, 2026 20:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request PPL Piped processing language

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant