Skip to content

Commit bab3bfe

Browse files
committed
[SPARK-57748][SQL] Use a dedicated tree-pattern bit for TIME-to-TIMESTAMP cast rewrites in ComputeCurrentTime
Replaces the broad CAST pruning predicate in ComputeCurrentTime with a dedicated CAST_TO_TIMESTAMP tree-pattern bit set on Cast nodes targeting any timestamp type (NTZ or LTZ family). The rule now only descends into plans containing a TIME->TIMESTAMP cast; node-level isTimeToTimestampNTZ/isTimeToTimestampLTZ guards keep rewrite semantics unchanged.
1 parent e28edaf commit bab3bfe

4 files changed

Lines changed: 101 additions & 11 deletions

File tree

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/Cast.scala

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,20 @@ case class Cast(
689689

690690
override protected def withNewChildInternal(newChild: Expression): Cast = copy(child = newChild)
691691

692-
final override def nodePatternsInternal(): Seq[TreePattern] = Seq(CAST)
692+
// CAST_TO_TIMESTAMP must be set on a superset of the targets accepted by
693+
// Cast.isTimeToTimestampNTZ / isTimeToTimestampLTZ. ComputeCurrentTime relies on
694+
// this bit to reach TIME->TIMESTAMP rewrites; if narrowed, those casts silently
695+
// escape stabilization with no test catching the drift (two independent match lists).
696+
//
697+
// We key on the target `dataType` only, not `child.dataType`: node patterns are
698+
// computed eagerly at construction before the child is resolved, so reading
699+
// child.dataType can throw (the OuterReference / RETURNS TABLE case removed in
700+
// commit 51136ecb5e2). Narrowing by source type would reintroduce that failure.
701+
final override def nodePatternsInternal(): Seq[TreePattern] = dataType match {
702+
case _: TimestampNTZType | _: TimestampNTZNanosType |
703+
TimestampType | _: TimestampLTZNanosType => Seq(CAST, CAST_TO_TIMESTAMP)
704+
case _ => Seq(CAST)
705+
}
693706

694707
override def contextIndependentFoldable: Boolean = {
695708
child.contextIndependentFoldable && !Cast.needsTimeZone(child.dataType, dataType)

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/finishAnalysis.scala

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,17 @@ object ComputeCurrentTime extends Rule[LogicalPlan] {
120120
val currentDates = collection.mutable.HashMap.empty[ZoneId, Literal]
121121
val localTimestamps = collection.mutable.HashMap.empty[ZoneId, Literal]
122122

123-
// The CAST bit is included so this rule can find TIME -> TIMESTAMP_NTZ and TIME ->
124-
// TIMESTAMP_LTZ casts (which derive their date fields from CURRENT_DATE) and stabilize them
125-
// below. CAST is a broad pattern, so this widens the rule's traversal to most plans; the
126-
// precise `Cast.isTimeToTimestampNTZ` / `Cast.isTimeToTimestampLTZ` guards keep the rewrite
127-
// scoped. We intentionally do not tag these casts with CURRENT_LIKE instead: inline-table
128-
// validation treats CURRENT_LIKE as safe to defer, so tagging would let unrelated non-foldable
129-
// NTZ/LTZ-target casts (e.g. CAST(rand() AS TIMESTAMP_NTZ)) bypass that validation (see
130-
// SPARK-57618 and ResolveInlineTablesSuite).
123+
// CAST_TO_TIMESTAMP is a dedicated tree-pattern bit set on Cast nodes whose target type is
124+
// any timestamp type (NTZ or LTZ family). This lets the rule reach both TIME -> TIMESTAMP_NTZ
125+
// and TIME -> TIMESTAMP_LTZ rewrites (which derive date fields from CURRENT_DATE) without the
126+
// broad CAST pattern that previously widened traversal to nearly every plan. Node-level
127+
// isTimeToTimestamp{NTZ,LTZ} guards keep rewrite semantics unchanged.
128+
// We intentionally do NOT tag these casts with CURRENT_LIKE: inline-table validation treats
129+
// CURRENT_LIKE as safe to defer, so tagging would let unrelated non-foldable timestamp-target
130+
// casts (e.g. CAST(rand() AS TIMESTAMP_NTZ)) bypass validation (see SPARK-57618).
131131
def transformCondition(treePatternbits: TreePatternBits): Boolean = {
132-
treePatternbits.containsPattern(CURRENT_LIKE) || treePatternbits.containsPattern(CAST)
132+
treePatternbits.containsPattern(CURRENT_LIKE) ||
133+
treePatternbits.containsPattern(CAST_TO_TIMESTAMP)
133134
}
134135

135136
plan.transformDownWithSubqueriesAndPruning(transformCondition) {

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ object TreePattern extends Enumeration {
4141
val BINARY_COMPARISON: Value = Value
4242
val CASE_WHEN: Value = Value
4343
val CAST: Value = Value
44+
val CAST_TO_TIMESTAMP: Value = Value
4445
val COALESCE: Value = Value
4546
val COMMON_EXPR_REF: Value = Value
4647
val CONCAT: Value = Value

sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/ComputeCurrentTimeSuite.scala

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ import org.apache.spark.sql.catalyst.expressions.{Add, Alias, Cast, CurrentDate,
2828
import org.apache.spark.sql.catalyst.plans.PlanTest
2929
import org.apache.spark.sql.catalyst.plans.logical.{Filter, LocalRelation, LogicalPlan, Project}
3030
import org.apache.spark.sql.catalyst.rules.RuleExecutor
31+
import org.apache.spark.sql.catalyst.trees.TreePattern
3132
import org.apache.spark.sql.catalyst.util.DateTimeUtils
3233
import org.apache.spark.sql.internal.SQLConf
33-
import org.apache.spark.sql.types.{DateType, IntegerType, TimestampLTZNanosType, TimestampNTZNanosType, TimestampNTZType, TimestampType, TimeType}
34+
import org.apache.spark.sql.types.{DateType, IntegerType, StringType, TimestampLTZNanosType, TimestampNTZNanosType, TimestampNTZType, TimestampType, TimeType}
3435
import org.apache.spark.unsafe.types.UTF8String
3536

3637
class ComputeCurrentTimeSuite extends PlanTest {
@@ -342,4 +343,78 @@ class ComputeCurrentTimeSuite extends PlanTest {
342343
}
343344
literals
344345
}
346+
347+
test("SPARK-57748: TIME->TIMESTAMP cast is rewritten even with no CURRENT_LIKE node") {
348+
val timeLit = Literal(0L, TimeType(6))
349+
Seq(TimestampNTZType, TimestampType).foreach { target =>
350+
val in = Project(Seq(Alias(Cast(timeLit, target), "a")()), LocalRelation())
351+
val plan = Optimize.execute(in.analyze).asInstanceOf[Project]
352+
val remaining = plan.expressions.flatMap(_.collect {
353+
case c: Cast if Cast.isTimeToTimestampNTZ(c.child.dataType, c.dataType)
354+
|| Cast.isTimeToTimestampLTZ(c.child.dataType, c.dataType) => c
355+
})
356+
assert(remaining.isEmpty,
357+
s"TIME->$target cast should be rewritten with no CURRENT_LIKE present")
358+
}
359+
}
360+
361+
test("SPARK-57748: CAST_TO_TIMESTAMP tree pattern is set for NTZ target types") {
362+
// Cast with TimestampNTZType target should contain CAST_TO_TIMESTAMP
363+
val ntzCast = Cast(Literal(0L, TimeType(6)), TimestampNTZType)
364+
assert(ntzCast.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
365+
assert(ntzCast.containsPattern(TreePattern.CAST)) // existing CAST tag preserved
366+
367+
// Cast with TimestampNTZNanosType target should also contain CAST_TO_TIMESTAMP
368+
val ntzNanosCast = Cast(Literal(0L, TimeType(6)), TimestampNTZNanosType(9))
369+
assert(ntzNanosCast.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
370+
assert(ntzNanosCast.containsPattern(TreePattern.CAST))
371+
}
372+
373+
test("SPARK-57748: CAST_TO_TIMESTAMP tree pattern is NOT set for non-timestamp targets") {
374+
// Cast to StringType should NOT contain CAST_TO_TIMESTAMP
375+
val stringCast = Cast(Literal(0L, TimeType(6)), StringType)
376+
assert(!stringCast.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
377+
assert(stringCast.containsPattern(TreePattern.CAST))
378+
379+
// Cast to IntegerType should NOT contain CAST_TO_TIMESTAMP
380+
val intCast = Cast(Literal("10"), IntegerType)
381+
assert(!intCast.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
382+
assert(intCast.containsPattern(TreePattern.CAST))
383+
}
384+
385+
test("SPARK-57748: CAST_TO_TIMESTAMP tree pattern is set for LTZ targets") {
386+
// Cast to TimestampType (LTZ micro) should contain CAST_TO_TIMESTAMP because
387+
// ComputeCurrentTime rewrites TIME->LTZ casts via the same predicate.
388+
val ltzCast = Cast(Literal(0L, TimeType(6)), TimestampType)
389+
assert(ltzCast.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
390+
assert(ltzCast.containsPattern(TreePattern.CAST))
391+
392+
// Cast to TimestampLTZNanosType should also contain CAST_TO_TIMESTAMP
393+
val ltzNanosCast = Cast(Literal(0L, TimeType(6)), TimestampLTZNanosType(9))
394+
assert(ltzNanosCast.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
395+
assert(ltzNanosCast.containsPattern(TreePattern.CAST))
396+
}
397+
398+
test("SPARK-57748: CAST_TO_TIMESTAMP is keyed on target type, not source type") {
399+
// Source type does not matter - only the target determines the pattern bit
400+
val fromString = Cast(Literal("2024-01-01"), TimestampNTZType)
401+
assert(fromString.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
402+
403+
val fromInt = Cast(Literal(42), TimestampNTZType)
404+
assert(fromInt.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
405+
406+
// Even with an expression child (rand()), the target type determines the bit
407+
import org.apache.spark.sql.catalyst.expressions.Rand
408+
val fromRand = Cast(Rand(Literal(0L)), TimestampNTZType)
409+
assert(fromRand.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
410+
}
411+
412+
test("SPARK-57748: plan with non-timestamp cast only does not contain CAST_TO_TIMESTAMP") {
413+
val timeLit = Literal(0L, TimeType(6))
414+
val plan = Project(Seq(
415+
Alias(Cast(timeLit, IntegerType), "a")()),
416+
LocalRelation())
417+
assert(!plan.containsPattern(TreePattern.CAST_TO_TIMESTAMP))
418+
assert(plan.containsPattern(TreePattern.CAST))
419+
}
345420
}

0 commit comments

Comments
 (0)