dongjoon-hyun commented on code in PR #59545:
URL: https://github.com/apache/spark/pull/59545#discussion_r4425480938


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -170,6 +174,275 @@ class CheckConstraintSuite extends QueryTest with 
CommandSuiteBase with DDLComma
     }
   }
 
+  test("SPARK-60032: Check constraint added by ALTER TABLE reading the current 
time") {
+    // The check compares with the time of the write, not the time of the 
ALTER TABLE. 'now' is
+    // read after the snapshot `current_timestamp()` takes, so only `<=` holds 
for a row written
+    // with `current_timestamp()`.
+    Seq(
+      ("TIMESTAMP", "current_timestamp()", "current_timestamp()", "INTERVAL 1 
DAY"),
+      ("TIMESTAMP", "CAST('now' AS TIMESTAMP)", "current_timestamp()", 
"INTERVAL 1 DAY"),
+      ("TIMESTAMP", "to_timestamp('now')", "current_timestamp()", "INTERVAL 1 
DAY"),
+      ("TIMESTAMP", "CAST('23:59:59.999999' AS TIMESTAMP)", 
"current_timestamp()",

Review Comment:
   Nit: this row and the `CAST('today' AS DATE)` row at L191 fail if the clock 
passes midnight during the rejected INSERT.
   
   The row value comes from the `ComputeCurrentTime` snapshot, and the 
condition reads the date later: `SpecialDatetimeValues` runs after that rule 
([Optimizer.scala 
L371-L373](https://github.com/apache/spark/blob/57f662dda0b4c42e5e6d8adc59d4617dabd8dd2d/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala#L371-L373)),
 and `ConstantFolding` evaluates the time-only cast after that. If the date 
changes in between, the row written a day ahead is no longer past the 
condition, so the INSERT succeeds and `intercept` fails.
   
   The window is a few milliseconds a day. The `'now'` rows and the TIME cast 
row do not have it. Could these two rows use a step of two days?
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -120,16 +127,47 @@ case class CheckConstraint(
     condition: String,
     override val userProvidedName: String = null,
     override val tableName: String = null,
-    override val userProvidedCharacteristic: ConstraintCharacteristic = 
ConstraintCharacteristic.empty)
+    override val userProvidedCharacteristic: ConstraintCharacteristic = 
ConstraintCharacteristic.empty,
+    keepPredicate: Boolean = false,
+    omitPredicate: Boolean = false)
   extends UnaryExpression
   with TableConstraint
-  with ImplicitCastInputTypes {
+  with ImplicitCastInputTypes
+  with AnalysisAwareExpression[CheckConstraint] {
 // scalastyle:on line.size.limit
 
+  final override val nodePatterns: Seq[TreePattern] = 
Seq(ANALYSIS_AWARE_EXPRESSION)
+
   override def inputTypes: Seq[AbstractDataType] = Seq(BooleanType)
 
+  override def stringArgs: Iterator[Any] =
+    Iterator(child, condition, userProvidedName, tableName, 
userProvidedCharacteristic)
+
+  // A write parses `condition` again. It cannot bind a parameter marker that 
the legacy parameter
+  // substitution leaves there, resolves an IDENTIFIER clause in its own 
session, and does not
+  // resolve a column qualified by the table name the way ALTER TABLE does. 
None of them can be
+  // told apart after analysis, so the parser sets `keepPredicate` for them, 
and for any name with
+  // more than one part, since it cannot tell a table qualifier from a struct 
column. Such a
+  // condition, or one that `resolvesDifferentlyOnWrite`, keeps the predicate 
built from the
+  // optimized plan, as before.
+  override def markAsAnalyzed(): CheckConstraint = copy(omitPredicate =

Review Comment:
   Minor: a paren-less `current_date`, `current_timestamp`, `current_user` or 
`user` in such a condition can now bind to a column added later, and the check 
then changes silently.
   
   - By default these keywords parse as an `UnresolvedAttribute` 
([AstBuilder.scala 
L4082-L4090](https://github.com/apache/spark/blob/57f662dda0b4c42e5e6d8adc59d4617dabd8dd2d/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala#L4082-L4090)).
   - A write resolves that name to a column first, and to the function only 
when no column matches ([ColumnResolutionHelper.scala 
L158-L165](https://github.com/apache/spark/blob/57f662dda0b4c42e5e6d8adc59d4617dabd8dd2d/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ColumnResolutionHelper.scala#L158-L165)).
   - `markAsAnalyzed` stores such a condition without a predicate, and ADD 
COLUMN does not look at the constraints.
   
   Before this PR, the stored predicate held a literal, so the new column 
changed nothing. From reading the code:
   
   ```sql
   CREATE TABLE t (id INT, owner STRING);
   ALTER TABLE t ADD CONSTRAINT c CHECK (owner = user);
   ALTER TABLE t ADD COLUMN user STRING;
   INSERT INTO t VALUES (1, 'bob', NULL);   -- owner = NULL is NULL, so the row 
passes
   INSERT INTO t VALUES (2, 'bob', 'bob');  -- passes: the check now compares 
two columns of the row
   ```
   
   `d <= current_date` behaves the same way after `ADD COLUMN current_date 
DATE`. This is the effect I noted for variables in [the earlier 
thread](https://github.com/apache/spark/pull/59545#discussion_r4349708565), 
which keeping the predicate now avoids for them. CREATE TABLE constraints 
without a predicate already take this path.
   
   I don't see a small fix here, because the name exists only in the condition 
text. Could the user-facing section mention this next to the note about 
re-parsing, with SPARK-60453 or a separate JIRA tracking it?
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -171,6 +209,65 @@ case class CheckConstraint(
   }
 }
 
+object CheckConstraint {
+  /**
+   * Whether `e`, with its `RuntimeReplaceable`s replaced, reads the current 
time, date, catalog,
+   * user and the like, which the optimizer replaces with their values. The 
CURRENT_LIKE pattern
+   * stands in for `ReplaceCurrentLike`, which needs a `CatalogManager`, and 
for the functions
+   * `ComputeCurrentTime` replaces. `ComputeCurrentTime` is still applied for 
the casts from TIME
+   * to a timestamp it rewrites, which carry no CURRENT_LIKE bit.
+   */
+  private def readsCurrentContext(e: Expression): Boolean = {
+    val replaced = ReplaceExpressions.replace(e)
+    replaced.containsPattern(CURRENT_LIKE) ||
+      (ComputeCurrentTime.applyForExpression(replaced) ne replaced) ||
+      replaced.exists {
+        case c: Cast => castReadsCurrentDate(c)
+        case _ => false
+      }
+  }
+
+  /**
+   * Whether `cast` reads the current date: `SpecialDatetimeValues` replaces 
it with the value of
+   * 'now', 'today', 'tomorrow' or 'yesterday', or it casts a string with only 
a time part to a
+   * timestamp with local time zone, which completes it with the current date. 
Only a cast of a
+   * constant string at the top level counts, not one into an array, map or 
struct of timestamps,
+   * from a VARIANT, or of a string that only the optimizer makes constant.
+   */
+  private def castReadsCurrentDate(cast: Cast): Boolean = cast match {

Review Comment:
   Nit: the constant-string logic now exists twice.
   
   L239-L240 repeat the foldable and `StringType` guard of 
`FoldableStringCast`, and L241-L246 repeat the evaluation and the catch of 
`readsCurrentDate` ([finishAnalysis.scala 
L321-L329](https://github.com/apache/spark/blob/57f662dda0b4c42e5e6d8adc59d4617dabd8dd2d/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/finishAnalysis.scala#L321-L329)).
 So `CAST('12:00:00' AS TIMESTAMP)` evaluates its string twice.
   
   Could one `private[sql]` helper next to `FoldableStringCast` return the 
constant string of a cast, with the guard and the catch, so that this method 
only decides on the value?
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -171,6 +209,65 @@ case class CheckConstraint(
   }
 }
 
+object CheckConstraint {
+  /**
+   * Whether `e`, with its `RuntimeReplaceable`s replaced, reads the current 
time, date, catalog,
+   * user and the like, which the optimizer replaces with their values. The 
CURRENT_LIKE pattern
+   * stands in for `ReplaceCurrentLike`, which needs a `CatalogManager`, and 
for the functions
+   * `ComputeCurrentTime` replaces. `ComputeCurrentTime` is still applied for 
the casts from TIME
+   * to a timestamp it rewrites, which carry no CURRENT_LIKE bit.
+   */
+  private def readsCurrentContext(e: Expression): Boolean = {
+    val replaced = ReplaceExpressions.replace(e)
+    replaced.containsPattern(CURRENT_LIKE) ||
+      (ComputeCurrentTime.applyForExpression(replaced) ne replaced) ||
+      replaced.exists {
+        case c: Cast => castReadsCurrentDate(c)
+        case _ => false
+      }
+  }
+
+  /**
+   * Whether `cast` reads the current date: `SpecialDatetimeValues` replaces 
it with the value of
+   * 'now', 'today', 'tomorrow' or 'yesterday', or it casts a string with only 
a time part to a
+   * timestamp with local time zone, which completes it with the current date. 
Only a cast of a
+   * constant string at the top level counts, not one into an array, map or 
struct of timestamps,
+   * from a VARIANT, or of a string that only the optimizer makes constant.
+   */
+  private def castReadsCurrentDate(cast: Cast): Boolean = cast match {
+    case c if SpecialDatetimeValues.readsCurrentDate(c) => true
+    case Cast(s, TimestampType | _: TimestampLTZNanosType, _, _)

Review Comment:
   Nit: no test fails if `| _: TimestampLTZNanosType` is removed here.
   
   The only row with this type is the `IF(false, ...)` one in "with an error in 
a branch", which passes either way. The rows of the current-time test use only 
TIMESTAMP, TIMESTAMP_NTZ and DATE. Without this arm, `CAST('23:59:59' AS 
TIMESTAMP_LTZ(9))` is frozen at the ALTER TABLE date again, since that cast 
also completes the string with the current date ([SparkDateTimeUtils.scala 
L906-L911](https://github.com/apache/spark/blob/57f662dda0b4c42e5e6d8adc59d4617dabd8dd2d/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkDateTimeUtils.scala#L906-L911)).
   
   Could the current-time test get a row with this cast?
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveTableConstraints.scala:
##########
@@ -128,16 +128,22 @@ class ResolveTableConstraints(val catalogManager: 
CatalogManager) extends Rule[L
 
   private def buildColumnExtractors(
       expr: Expression,
+      query: LogicalPlan,
       columnExtractors: mutable.Map[String, Expression]): Unit = {
     expr match {
       case u: UnresolvedExtractValue =>
         // When extracting a value from a Map or Array type, we display only 
the specific extracted
         // value rather than the entire Map or Array structure for clarity and 
readability.
         columnExtractors(u.sql) = u
+      // A name that is not a column of the write, such as `current_timestamp` 
without parentheses
+      // or a variable, is not a value of the row.
       case u: UnresolvedAttribute =>
-        columnExtractors(u.name) = u
+        val resolver = catalogManager.v1SessionCatalog.conf.resolver
+        if (query.resolve(u.nameParts, resolver).isDefined) {

Review Comment:
   Nit: `query.resolve` throws when a name goes through a field that does not 
exist, and here that error loses its position in the condition.
   
   `ColumnResolutionHelper` runs the same lookup inside `withPosition` 
([L152](https://github.com/apache/spark/blob/57f662dda0b4c42e5e6d8adc59d4617dabd8dd2d/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ColumnResolutionHelper.scala#L152)),
 so before this change the error pointed into `predicateSql`. For example, take 
`from_json(s.j, 'a INT').a > 1`, which is stored without a predicate. After 
`ALTER TABLE t DROP COLUMN s.j`, a write now fails on this line with the same 
error but without the query context.
   
   ```suggestion
           if (withPosition(u)(query.resolve(u.nameParts, resolver)).isDefined) 
{
   ```
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to