Skip to content

Decouple PPL logical plans from UDT temporal types#5547

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

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. Scope: DATE/TIME/TIMESTAMP only. IP/BINARY UDTs and ExprType usage in UDF runtime and pushdown paths are intentionally unchanged.

1. Decouple `ExprType` from the Calcite planning surface. Coercion (`CoercionUtils`), type checking (`PPLTypeChecker`), UDF operand metadata (`PPLOperandTypes`), UDF return-type inference, `visitCast`, and `ExtendedRexBuilder` now operate on `RelDataType` directly instead of converting to/from `ExprType` at every step.

2. Remove DATE/TIME/TIMESTAMP UDTs from logical plans. `convertExprTypeToRelDataType` returns standard `DATE`/`TIME(9)`/`TIMESTAMP(9)` for temporal columns. A new `TemporalUdtRewriteShuttle` runs at the prepare-statement boundary (three entry points) and rewrites those back to UDTs just before physical execution, so Linq4j codegen keeps seeing the VARCHAR-backed representation that `OpenSearchExprValueFactory` delivers. `AbstractCalciteIndexScan` rewrites its schema in place via a `TemporalSchemaRewritable` marker interface. `DatetimeExtension` + `DatetimeUdtNormalizeRule` (from #5408) are removed since the analysis pipeline no longer produces temporal UDTs. Nullability and format-string precision (TIMESTAMP(9)) preserved via coercion-cast tweaks.

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 429010b)

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) and returns a LogicalProject wrapping the original Values node. However, the original Values node still has the old (standard temporal) row type, not the rewritten UDT row type. This means the fallback path produces a plan where the Values node's row type does not match the CAST expressions in the wrapping Project, which can cause validation failures or runtime type mismatches when Calcite's optimizer or codegen processes the plan.

  // 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 recursion with an accumulator but does not guard against infinite loops when the parent graph contains cycles. If a CoercionTag's parent list ever forms a cycle (e.g., due to a future code change), the method will recurse indefinitely and cause a StackOverflowError. Although the current parent map does not contain cycles, the lack of a visited-set or depth limit makes the code fragile to future modifications.

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;
  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;
}
Possible Issue

In visitSubQuery, the code checks if (relShuttle == null) and returns early without rewriting the inner plan. This occurs when TemporalRexShuttle is constructed via the single-argument constructor (used by the static rewriteRex method). If rewriteRex is ever called on a RexNode tree that contains a subquery, the subquery's inner RelNode will not be rewritten, leaving standard temporal types in the subquery plan even though the outer plan expects UDT types. This can cause type mismatches at runtime when the subquery is executed.

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()) {
    clone = (RexSubQuery) clone.clone(rewrittenType, clone.getOperands());
  }
  return clone;
}

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to b37b1f5

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Add depth limit to prevent stack overflow

The recursive distance method may cause stack overflow for deeply nested widening
chains or cycles if the parent map is misconfigured. Add a depth limit or cycle
detection to prevent unbounded recursion, especially since the widening DAG is
user-extensible via PARENTS.

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

 private static int distance(CoercionTag from, CoercionTag to, int acc) {
+  final int MAX_DEPTH = 20;
+  if (acc > MAX_DEPTH) return IMPOSSIBLE_WIDENING;
   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]: 6

__

Why: The suggestion correctly identifies a potential stack overflow risk in the recursive distance method if the widening DAG contains cycles or is deeply nested. Adding a depth limit is a reasonable defensive measure, though the current PARENTS map is statically defined and unlikely to cause issues in practice. The improvement is valid but addresses a low-probability edge case.

Low
Pre-check literals to avoid partial iteration

The fallback to wrapTableScanIfNeeded(values) when a literal becomes a RexCall may
introduce a LogicalProject wrapper that Volcano cannot handle. Consider pre-checking
whether any literal will require a CAST before iterating, or document that this path
is only hit in test scenarios where the wrapper is acceptable.

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

 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.
+  // Pre-check: if any literal will become a RexCall (CAST), fall back immediately.
+  boolean needsCast = values.getTuples().stream()
+      .flatMap(tuple -> tuple.stream())
+      .anyMatch(lit -> !(lit.accept(rexShuttle) instanceof RexLiteral));
+  if (needsCast) {
+    return wrapTableScanIfNeeded(values);
+  }
   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);
-      }
+      newTuple.add((RexLiteral) lit.accept(rexShuttle));
     }
     newTuples.add(newTuple.build());
   }
   return LogicalValues.create(values.getCluster(), rewritten, newTuples.build());
 }
 return wrapTableScanIfNeeded(values);
Suggestion importance[1-10]: 5

__

Why: The suggestion proposes pre-checking whether any literal will become a RexCall before iterating, which could avoid partial iteration and improve efficiency. However, the current code already handles this case by falling back to wrapTableScanIfNeeded when a RexCall is encountered, so the improvement is marginal and primarily stylistic.

Low
Avoid redundant nullability wrapping

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 (from signature),
forcing nullable=true is correct. However, verify that targetType itself doesn't
already carry the desired nullability from the signature, avoiding redundant
wrapping.

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

 if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
-  return builder.makeCast(
-      builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
+  RelDataType castTarget = targetType.isNullable() ? targetType : builder.getTypeFactory().createTypeWithNullability(targetType, true);
+  return builder.makeCast(castTarget, arg, true, true);
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion correctly identifies that targetType is always wrapped with nullable=true, but the check targetType.isNullable() is redundant since the code explicitly creates a nullable type regardless. The improvement is minor and doesn't address a bug, just a potential micro-optimization.

Low

Previous suggestions

Suggestions up to commit f5d1237
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, the cast strips
the nullable property, which can defeat NullPolicy.ANY short-circuiting. Verify that
targetType is already nullable before wrapping it, or preserve the source's
nullability in the cast result.

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

 if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
+  boolean targetNullable = targetType.isNullable() || argType.isNullable();
   return builder.makeCast(
-      builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
+      builder.getTypeFactory().createTypeWithNullability(targetType, targetNullable), 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 null-carrying properties. The suggestion's concern about stripping nullable properties is valid but the PR's design intentionally makes all cast targets nullable, so the impact is moderate rather than critical.

Medium
Preserve array type nullability correctly

The array type is created with createArrayType(rewritten, -1) and then wrapped with
createTypeWithNullability. However, createArrayType may already produce a nullable
type depending on the component type. Verify that the final array type's nullability
matches the original type's nullability to avoid inconsistencies.

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

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

__

Why: The suggestion raises a valid concern about potential nullability inconsistencies when rewriting array types. The code creates an array type and then wraps it with createTypeWithNullability, but doesn't verify if the intermediate array type already has the correct nullability. The improved code adds a check to avoid redundant wrapping, which is a minor improvement in correctness and efficiency.

Low
Document subquery limitation in rewriteRex

The static rewriteRex method creates a new TemporalRexShuttle without passing a
relShuttle reference, so the shuttle cannot recurse into subquery plans. If this
method is called on a RexNode containing a subquery, the subquery's inner plan will
not be rewritten. Consider passing a relShuttle or documenting that this method
should not be used for RexNodes with subqueries.

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

 public static RexNode rewriteRex(OpenSearchTypeFactory tf, RexNode node) {
+  // Note: this method does not rewrite subquery plans. Use the shuttle's visitSubQuery if needed.
   return node.accept(new TemporalRexShuttle(tf));
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies that the static rewriteRex method creates a TemporalRexShuttle without a relShuttle reference, preventing subquery plan rewriting. However, this appears to be intentional for the static utility method, and the class already has a proper instance method that handles subqueries via the visitSubQuery override. Adding documentation would improve clarity but the limitation is not a bug.

Low
Check nullability when matching UDT types

The method compares UDT types by class equality, but does not check nullability. If
expected is nullable and actual is not (or vice versa), the types may still match by
class but differ in nullability, which could cause runtime issues. Consider
verifying that nullability is compatible when matching UDT types.

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

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

__

Why: The suggestion proposes checking nullability when matching UDT types by class equality. While this could add strictness, the PR's design appears to intentionally ignore nullability in signature matching (as evidenced by the comment "nullability-insensitive equality" at line 548-549). The method is used for signature matching where nullability differences are typically acceptable, so enforcing nullability equality here may be overly restrictive and could break valid signature matches.

Low
Suggestions up to commit 429010b
CategorySuggestion                                                                                                                                    Impact
Possible issue
Apply row type rewrite in fallback

When a rewritten literal is not a RexLiteral, the method falls back to
wrapTableScanIfNeeded(values), which wraps the original (non-rewritten) Values node.
This means the fallback path does not apply the row type rewrite, potentially
leaving standard temporal types in place. Ensure the fallback path also applies the
row type rewrite by passing the rewritten row type to the wrapper.

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();
     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);
+          return wrapValuesWithRewrittenRowType(values, rewritten);
         }
       }
       newTuples.add(newTuple.build());
     }
     return LogicalValues.create(values.getCluster(), rewritten, newTuples.build());
   }
-  return wrapTableScanIfNeeded(values);
+  return wrapValuesWithRewrittenRowType(values, rewritten);
 }
 
+private RelNode wrapValuesWithRewrittenRowType(Values values, RelDataType rewrittenRowType) {
+  RexBuilder rb = values.getCluster().getRexBuilder();
+  List<RexNode> projects = new ArrayList<>();
+  List<String> names = new ArrayList<>();
+  for (int i = 0; i < values.getRowType().getFieldCount(); i++) {
+    RelDataTypeField src = values.getRowType().getFieldList().get(i);
+    RelDataType target = rewrittenRowType.getFieldList().get(i).getType();
+    RexNode ref = rb.makeInputRef(src.getType(), i);
+    if (src.getType() != target) {
+      ref = rb.makeCast(target, ref);
+    }
+    projects.add(ref);
+    names.add(src.getName());
+  }
+  return LogicalProject.create(values, ImmutableList.of(), projects, names);
+}
+
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that the fallback path in rebuildValues does not apply the row type rewrite when a literal cannot be rewritten to a RexLiteral. The proposed helper method wrapValuesWithRewrittenRowType ensures the rewritten row type is used in the fallback, which is consistent with the shuttle's goal of rewriting all temporal types. This is a valid improvement to maintain consistency.

Medium
General
Preserve source nullability in casts

The cast logic always forces the target type to be nullable, but this may not be
correct when the source is non-nullable and the cast is guaranteed to succeed.
Consider preserving the nullability of the source type when the cast is safe and the
source is non-nullable, to avoid unnecessary null checks in generated code.

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 targetNullable = argType.isNullable() || targetType.isNullable();
   if (distance(argType, targetType) != IMPOSSIBLE_WIDENING) {
     return builder.makeCast(
-        builder.getTypeFactory().createTypeWithNullability(targetType, true), arg, true, true);
+        builder.getTypeFactory().createTypeWithNullability(targetType, targetNullable), arg, true, true);
   }
   return resolveCommonType(argType, targetType)
       .map(
           common ->
               builder.makeCast(
-                  builder.getTypeFactory().createTypeWithNullability(common, true),
+                  builder.getTypeFactory().createTypeWithNullability(common, targetNullable),
                   arg,
                   true,
                   true))
       .orElse(null);
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion to preserve source nullability is reasonable but may not be correct in all cases. The PR explicitly forces nullable targets for safe casts to handle parse failures and null sources. Changing this logic could break NullPolicy.ANY short-circuiting and cause runtime exceptions, as noted in the PR's own comments. The suggestion overlooks these documented constraints.

Low
Add recursion depth limit

The recursive distance calculation does not guard against cycles in the parent
graph. If the PARENTS map is ever misconfigured to contain a cycle, this method will
overflow the stack. Consider adding a visited set or a 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]: 4

__

Why: The suggestion to guard against cycles in the parent graph is a defensive measure. However, the PARENTS map is statically defined and does not contain cycles by construction. Adding a depth limit is a reasonable safeguard but addresses a hypothetical issue rather than a demonstrated bug. The hardcoded limit of 20 is arbitrary and may not be appropriate for all cases.

Low
Log unhandled node types

The fallback path super.visit(other) followed by visited.accept(rexShuttle) may
produce inconsistent RelNodes for operators not explicitly handled. If a node type
stores RexNodes but isn't covered by the special cases, the default path will
rewrite children first, then apply the RexShuttle to the parent, potentially causing
validation failures. Consider adding a defensive check or logging a warning when an
unhandled node type with RexNodes is encountered.

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

 @Override
 public RelNode visit(RelNode other) {
-  // For nodes whose constructor validates RexNodes against input row type (Project, Filter,
-  // Calc), we must rebuild the node atomically: new (rewritten) inputs AND rewritten RexNodes
-  // must be applied together, so the constructor validation sees a self-consistent picture.
   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);
   }
   RelNode visited = super.visit(other);
-  return visited.accept(rexShuttle);
+  RelNode rewritten = visited.accept(rexShuttle);
+  if (rewritten != visited && LOG.isDebugEnabled()) {
+    LOG.debug("TemporalUdtRewriteShuttle applied RexShuttle to unhandled node type: {}", other.getClass().getSimpleName());
+  }
+  return rewritten;
 }
Suggestion importance[1-10]: 3

__

Why: Adding debug logging for unhandled node types is a minor improvement for observability. However, the fallback path is intentional and handles nodes that don't store RexNodes or don't require special treatment. The suggestion's concern about validation failures is speculative and not demonstrated by the PR's test coverage.

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

@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

@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

@penghuo
penghuo force-pushed the feat/expr_lazy_udt_v1 branch from aa01135 to 429010b Compare July 16, 2026 16:29
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 429010b

@penghuo
penghuo force-pushed the feat/expr_lazy_udt_v1 branch from 429010b to f5d1237 Compare July 16, 2026 16:40
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 f5d1237 to b37b1f5 Compare July 16, 2026 16:54
@penghuo

penghuo commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

close this PR.
TemporalUdtRewriteShuttle running on each RelNode tree does not make sense for now. We will get rid of Timestamp/Date/Time UDT when deprecated Calcite execution plan.

@penghuo penghuo closed this Jul 16, 2026
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