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 aa01135)

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

The cast method creates a nullable target type unconditionally, even when the signature target type is NOT NULL. This can defeat NullPolicy.ANY short-circuiting for downstream UDF calls when a nullable source is cast into a NOT NULL target, because the cast strips the null-carrying property. Additionally, a safe cast whose runtime result may be null (e.g. CAST('malformed' AS BIGINT)) can throw NumberFormatException instead of yielding null when the declared target type is NOT NULL. The comment explains the rationale, but the logic always sets nullable=true regardless of the original target type's nullability, which may cause issues if the signature explicitly requires a NOT NULL type.

private static @Nullable RexNode cast(RexBuilder builder, RelDataType targetType, RexNode arg) {
  RelDataType argType = arg.getType();
  if (!shouldCast(argType, targetType)) {
    return arg;
  }
  // Make the cast target nullable. Signature target types come from
  // PPLTypeChecker.getRelDataTypes(...) which produces NOT NULL Calcite types (e.g. INTEGER,
  // DOUBLE). We always use safe cast (safe=true), which returns null on parse failure or on a
  // null source — so the target must be nullable, otherwise:
  //   (1) a nullable source cast into a NOT NULL target strips the null-carrying property and
  //       defeats NullPolicy.ANY short-circuiting for downstream UDF calls (null gets unboxed
  //       to a primitive default like 0L in Linq4j codegen);
  //   (2) a safe cast whose runtime result may be null (e.g. CAST('malformed' AS BIGINT))
  //       throws NumberFormatException instead of yielding null when the declared target type
  //       is NOT NULL.
  if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
    return builder.makeCast(
        builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
  }
Possible Issue

In rebuildValues, when a RexLiteral is rewritten to a RexCall (CAST), the code falls back to wrapTableScanIfNeeded(values) instead of continuing to build the new tuple. This means the entire LogicalValues node is replaced with a LogicalProject(CAST...) over the original values, which may not preserve the intended row structure if only some columns need casting. The fallback is correct for the general case, but the comment suggests this is a workaround for LogicalValues only accepting RexLiterals. If the original values node has multiple tuples, the fallback may produce a different plan shape than expected.

    // 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 temporalKind helper returns the same SqlTypeName (DATE/TIME/TIMESTAMP) for both standard SQL types and their UDT counterparts. This is used in typesMatch and isComparable to treat standard and UDT temporal types as equivalent. However, if a function signature explicitly requires a UDT (e.g. ExprDateType) and the input is a standard DATE, typesMatch will return true even though the actual class instances differ. This may allow a standard DATE to pass validation when the signature expects a UDT, or vice versa, leading to runtime type mismatches if the function implementation relies on the specific class.

private static SqlTypeName temporalKind(RelDataType type) {
  if (type instanceof ExprDateType || type.getSqlTypeName() == SqlTypeName.DATE) {
    return SqlTypeName.DATE;
  }
  if (type instanceof ExprTimeType || type.getSqlTypeName() == SqlTypeName.TIME) {
    return SqlTypeName.TIME;
  }
  if (type instanceof ExprTimeStampType || type.getSqlTypeName() == SqlTypeName.TIMESTAMP) {
    return SqlTypeName.TIMESTAMP;
  }
  return null;
}
Possible Issue

When casting to a standard SQL temporal type (DATE/TIME/TIMESTAMP), the code lowers the cast to a PPL UDF (DATE/TIME/TIMESTAMP) so runtime String → temporal parsing matches v2 semantics. However, the comment states 'The shuttle in OpenSearchCalcitePreparingStmt rewrites these to UDT at execution time.' If the shuttle runs before Volcano optimization, the cast target is already a standard type at this point. If the shuttle does not run (e.g. in a test or a non-standard execution path), the cast target remains standard SQL, and the runtime may see a Long-backed temporal type instead of the expected VARCHAR-backed UDT. This could cause ClassCastException if the UDF implementation expects a String.

} else if (sqlType == SqlTypeName.DATE
    || sqlType == SqlTypeName.TIME
    || sqlType == SqlTypeName.TIMESTAMP) {
  // Standard SQL temporal target. Lower to PPL UDF so the runtime String → temporal parsing
  // matches v2 semantics. The shuttle in OpenSearchCalcitePreparingStmt rewrites these to UDT
  // at execution time.
  if (RexLiteral.isNullLiteral(exp)) {
    return super.makeCast(pos, type, exp, matchNullability, safe, format);
  }
  if (sqlType == SqlTypeName.DATE) {
    return makeCall(type, PPLBuiltinOperators.DATE, List.of(exp));
  } else if (sqlType == SqlTypeName.TIME) {
    return makeCall(type, PPLBuiltinOperators.TIME, List.of(exp));
  } else {
    return makeCall(type, PPLBuiltinOperators.TIMESTAMP, List.of(exp));
  }
}

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to aa01135

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard against null variablesSet

Verify that filter.getVariablesSet() is never null before wrapping it in
ImmutableSet.copyOf. If getVariablesSet() can return null, the code will throw a
NullPointerException at runtime. Consider adding a null check or using a safe
default like Collections.emptySet().

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

 private RelNode rebuildFilter(Filter filter) {
   List<RelNode> newInputs = rewriteChildren(filter);
   RelNode newInput = newInputs.get(0);
   RexNode rewrittenCondition = filter.getCondition().accept(rexShuttle);
-  // Preserve variablesSet so correlated subqueries (Filter referencing $cor0) keep the
-  // CorrelationId binding and Calcite doesn't fail with "Correlation variable $cor0 should be
-  // defined" during decorrelation/optimization.
+  Set<CorrelationId> variablesSet = filter.getVariablesSet();
+  if (variablesSet == null) {
+    variablesSet = Collections.emptySet();
+  }
   return LogicalFilter.create(
-      newInput, rewrittenCondition, ImmutableSet.copyOf(filter.getVariablesSet()));
+      newInput, rewrittenCondition, ImmutableSet.copyOf(variablesSet));
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential NullPointerException if filter.getVariablesSet() returns null. Adding a null check improves robustness, though the actual risk depends on Calcite's contract for getVariablesSet().

Medium
Prevent IndexOutOfBoundsException on empty inputs

Verify that newInputs is never empty before calling get(0). If rewriteChildren can
return an empty list, this will throw an IndexOutOfBoundsException. Add a defensive
check or document the precondition that the aggregate must have exactly one input.

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

 private RelNode rebuildAggregate(Aggregate agg) {
   List<RelNode> newInputs = rewriteChildren(agg);
+  if (newInputs.isEmpty()) {
+    throw new IllegalStateException("Aggregate must have at least one input");
+  }
   RelNode newInput = newInputs.get(0);
-  List<AggregateCall> rewrittenCalls = new ArrayList<>();
-  for (AggregateCall call : agg.getAggCallList()) {
-    RelDataType target = rewriteType(typeFactory, call.getType());
-    ...
-    if (target == call.getType() && !rexListChanged) {
-      rewrittenCalls.add(call);
-    } else {
-      rewrittenCalls.add(
-          AggregateCall.create(
-              call.getAggregation(),
-              call.isDistinct(),
-              call.isApproximate(),
-              call.ignoreNulls(),
-              rewrittenRexList,
-              call.getArgList(),
-              call.filterArg,
-              call.distinctKeys,
-              call.collation,
-              target,
-              call.getName()));
-    }
-  }
-  return agg.copy(
-      agg.getTraitSet(), newInput, agg.getGroupSet(), agg.getGroupSets(), rewrittenCalls);
+  ...
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies a potential IndexOutOfBoundsException if rewriteChildren returns an empty list. However, Calcite's Aggregate nodes always have exactly one input by design, so the risk is low. The defensive check improves code clarity.

Low
General
Clarify null return semantics

The cast method returns null when no common type is found, but the caller
castArguments does not always handle this null gracefully. Ensure that all call
sites check for null before using the result, or document that null is an expected
return value indicating cast 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 (!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);
+  Optional<RelDataType> commonOpt = resolveCommonType(argType, targetType);
+  if (commonOpt.isEmpty()) {
+    // Log or throw to make the failure explicit
+    return null;
+  }
+  return builder.makeCast(
+      builder.getTypeFactory().createTypeWithNullability(commonOpt.get(), true),
+      arg,
+      true,
+      true);
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion raises a valid concern about null handling, but the existing code already returns null when no common type is found, and callers like castArguments do check for null. The improved code does not materially change behavior, so the impact is moderate.

Low

Previous suggestions

Suggestions up to commit c3fc9ca
CategorySuggestion                                                                                                                                    Impact
General
Handle cast failure for exact matches

When an exact match is found (distance == TYPE_EQUAL), the method immediately
returns the cast arguments. However, if castArguments returns null (indicating a
cast failure), the caller receives null even though other signatures might succeed.
Consider continuing the loop to try remaining signatures when castArguments fails.

core/src/main/java/org/opensearch/sql/expression/function/CoercionUtils.java [66-68]

 if (distance == TYPE_EQUAL) {
-  return castArguments(builder, paramTypes, arguments);
+  List<RexNode> result = castArguments(builder, paramTypes, arguments);
+  if (result != null) {
+    return result;
+  }
 }
 if (distance != IMPOSSIBLE_WIDENING) {
   rankedSignatures.add(Pair.of(paramTypes, distance));
 }
Suggestion importance[1-10]: 6

__

Why: Valid point that when castArguments returns null for an exact match, the method immediately returns null without trying other signatures. The improved code checks the result and continues if casting fails, which could improve robustness in edge cases.

Low
Avoid unnecessary struct type allocation

The changed flag is set but never used to short-circuit the loop or optimize the
struct rebuild. If no fields change, the method still constructs a new struct type
with identical field lists. Consider returning row early when !changed after the
loop to avoid unnecessary allocations.

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

 if (rewritten != f.getType()) {
   changed = true;
 }
+names.add(f.getName());
+types.add(rewritten);
+}
+if (!changed) {
+  return row;
+}
+return tf.createStructType(types, names, row.isNullable());
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies that the changed flag is set but the method always constructs a new struct type. However, the code already returns row unchanged at line 89 when !changed, so the optimization is already present. The suggestion misreads the existing logic.

Low
Avoid unnecessary aggregate node rebuild

The method always calls agg.copy(...) even when no aggregate calls were rewritten
and the input is unchanged. This creates a new Aggregate node unnecessarily. Track
whether any changes occurred and return agg directly if both the input and all calls
remain identical.

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

 private RelNode rebuildAggregate(Aggregate agg) {
   List<RelNode> newInputs = rewriteChildren(agg);
   RelNode newInput = newInputs.get(0);
   List<AggregateCall> rewrittenCalls = new ArrayList<>();
+  boolean callsChanged = false;
   for (AggregateCall call : agg.getAggCallList()) {
-    RelDataType target = rewriteType(typeFactory, call.getType());
     ...
     if (target == call.getType() && !rexListChanged) {
       rewrittenCalls.add(call);
     } else {
-      rewrittenCalls.add(
-          AggregateCall.create(...));
+      callsChanged = true;
+      rewrittenCalls.add(AggregateCall.create(...));
     }
   }
-  return agg.copy(
-      agg.getTraitSet(), newInput, agg.getGroupSet(), agg.getGroupSets(), rewrittenCalls);
+  if (newInput == agg.getInput(0) && !callsChanged) {
+    return agg;
+  }
+  return agg.copy(...);
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion to track callsChanged and return agg directly when nothing changed is a valid optimization. However, the impact is minor since agg.copy(...) with identical inputs is relatively cheap, and this path is not performance-critical.

Low
Cache nullable type to avoid redundant calls

The method calls builder.getTypeFactory().createTypeWithNullability(targetType,
true) twice when distance != IMPOSSIBLE_WIDENING and again inside the
resolveCommonType map. If resolveCommonType returns targetType (which can happen
when the common type equals the target), the nullable variant is created
redundantly. Consider caching the nullable target type or restructuring to avoid
duplicate calls.

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

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

__

Why: The suggestion identifies redundant createTypeWithNullability calls, but the optimization is marginal. The resolveCommonType path is a fallback that rarely executes, and caching the nullable target adds local variable overhead. The readability/performance trade-off is not compelling.

Low
Suggestions up to commit 60a2c71
CategorySuggestion                                                                                                                                    Impact
General
Add recursion depth limit

The recursive distance calculation may cause stack overflow for deeply nested type
hierarchies or cycles in the parent graph. Consider adding a visited set or a depth
limit to prevent unbounded 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 (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 (acc > 20) return IMPOSSIBLE_WIDENING; // Depth limit to prevent stack overflow
   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]: 4

__

Why: Adding a depth limit is a reasonable defensive measure against stack overflow in recursive functions. However, the widening DAG is explicitly designed to be acyclic and shallow (numeric chain is 6 levels deep), so the risk is minimal. The hardcoded limit of 20 is arbitrary and may mask legitimate deep hierarchies if the type system evolves.

Low
Preserve target type nullability constraints

The cast logic always forces the target type to be nullable, which may override
explicit non-null constraints from the signature. Consider preserving the original
nullability of targetType when it is already nullable, and only force nullability
when the source is nullable or when the cast is safe and may produce null.

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 (!shouldCast(argType, targetType)) {
     return arg;
   }
-  // Make the cast target nullable. Signature target types come from
-  // PPLTypeChecker.getRelDataTypes(...) which produces NOT NULL Calcite types (e.g. INTEGER,
-  // DOUBLE). We always use safe cast (safe=true), which returns null on parse failure or on a
-  // null source — so the target must be nullable, otherwise:
-  //   (1) a nullable source cast into a NOT NULL target strips the null-carrying property and
-  //       defeats NullPolicy.ANY short-circuiting for downstream UDF calls (null gets unboxed
-  //       to a primitive default like 0L in Linq4j codegen);
-  //   (2) a safe cast whose runtime result may be null (e.g. CAST('malformed' AS BIGINT))
-  //       throws NumberFormatException instead of yielding null when the declared target type
-  //       is NOT NULL.
+  boolean forceNullable = argType.isNullable() || !targetType.isNullable();
   if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
-    return builder.makeCast(
-        builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
+    RelDataType castTarget = forceNullable 
+        ? builder.getTypeFactory().createTypeWithNullability(targetType, true) 
+        : targetType;
+    return builder.makeCast(castTarget, arg, true, true);
   }
   return resolveCommonType(argType, targetType)
       .map(
-          common ->
-              builder.makeCast(
-                  builder.getTypeFactory().createTypeWithNullability(common, true),
-                  arg,
-                  true,
-                  true))
+          common -> {
+            RelDataType castTarget = forceNullable 
+                ? builder.getTypeFactory().createTypeWithNullability(common, true) 
+                : common;
+            return builder.makeCast(castTarget, arg, true, true);
+          })
       .orElse(null);
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion misunderstands the design intent. The comment explicitly states that safe casts must produce nullable targets to handle parse failures and null sources correctly. The proposed logic (forceNullable = argType.isNullable() || !targetType.isNullable()) is also incorrect and would not preserve nullability as claimed.

Low
Validate fallback plan structure consistency

The fallback to wrapTableScanIfNeeded when a literal becomes a RexCall may produce
an inconsistent plan structure. Verify that the wrapped plan correctly handles the
rewritten types and that downstream consumers can process the LogicalProject wrapper
without issues.

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

 private RelNode rebuildValues(Values values) {
   RelDataType current = values.getRowType();
   RelDataType rewritten = rewriteRowType(typeFactory, current);
   if (rewritten == current) {
     return values;
   }
   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();
+    boolean needsProjectWrapper = false;
     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);
+          needsProjectWrapper = true;
+          break;
         }
       }
+      if (needsProjectWrapper) break;
       newTuples.add(newTuple.build());
     }
-    return LogicalValues.create(values.getCluster(), rewritten, newTuples.build());
+    if (!needsProjectWrapper) {
+      return LogicalValues.create(values.getCluster(), rewritten, newTuples.build());
+    }
   }
   return wrapTableScanIfNeeded(values);
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion adds an early-exit flag (needsProjectWrapper) but does not address the stated concern about verifying downstream compatibility. The refactored code is functionally equivalent to the original and does not improve validation or error handling.

Low
Suggestions up to commit 683bc8a
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
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

@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

@penghuo penghuo force-pushed the feat/expr_lazy_udt_v1 branch 2 times, most recently from 60a2c71 to c3fc9ca Compare July 14, 2026 21:31
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit c3fc9ca

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 c3fc9ca to aa01135 Compare July 14, 2026 22:45
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit aa01135

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