dongjoon-hyun commented on code in PR #59545:
URL: https://github.com/apache/spark/pull/59545#discussion_r4327531723
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -120,16 +122,32 @@ 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,
+ analyzedChild: Option[Expression] = None)
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)
+
+ override def markAsAnalyzed(): CheckConstraint = copy(analyzedChild =
Some(child))
+
+ // ALTER TABLE builds the constraint from the optimized plan, where the
current time, date,
+ // catalog, user and the like are replaced by their values at the time of
the ALTER TABLE. A
+ // condition that reads one is stored without a predicate, so that writes
check `predicateSql`.
def toV2Constraint: Constraint = {
- val predicate = new V2ExpressionBuilder(child,
true).buildPredicate().orNull
+ val predicate = if (analyzedChild.exists(readsCurrentContext)) {
Review Comment:
**This guard never runs on CREATE TABLE or REPLACE TABLE, so they still
build the predicate from the analyzed condition, the path that the PR
description rules out because of `CAST('today' AS DATE)`.**
`ResolveTableSpec` converts the constraints during analysis
(ResolveTableSpec.scala:110), before `HandleSpecialCommand` marks them, so
`analyzedChild` is `None` there and L149 hands the analyzed condition to
`V2ExpressionBuilder`. The builder folds every `contextIndependentFoldable`
sub-expression (V2ExpressionBuilder.scala:108-114), which is true for a string
cast to DATE or TIMESTAMP_NTZ because `needsTimeZone` has no case for them
(Cast.scala:716-718). An ordinary `Cast.eval` does not know special values, and
`tryFold` rethrows outside a conditional branch. So, from reading the code:
```sql
CREATE TABLE cat.ns.t (id INT, d DATE, CONSTRAINT c CHECK (d <= CAST('today'
AS DATE)));
-- ANSI on: fails with CAST_INVALID_INPUT
-- ANSI off: stores the predicate d <= NULL, so INSERT ... VALUES (1,
DATE'9999-12-31') passes
```
`CAST('now' AS TIMESTAMP_NTZ)` and the implicit cast in `d <= 'today'`
behave the same, while `CAST('now' AS TIMESTAMP)` is fine because it needs a
time zone. This is already in 4.1.0, but after this PR the same condition
behaves differently depending on whether CREATE TABLE or ALTER TABLE added it.
It also qualifies my [earlier comment on
#59217](https://github.com/apache/spark/pull/59217#discussion_r4199939321):
building from the analyzed expression fixes `current_*()`, but CREATE TABLE
already shows that it breaks special datetime strings.
The obvious fix, `readsCurrentContext(analyzedChild.getOrElse(child))`, has
a trap. `ResolveTableSpec` (Analyzer.scala:697) can run before
`ResolveTimeZone` (Analyzer.scala:722) in the same iteration, a string-to-DATE
cast counts as resolved without a time zone, and `SpecialDatetimeValues` reads
`cast.zoneId` (finishAnalysis.scala:320), so it could throw even on
`CAST('2020-01-01' AS DATE)`. Could CREATE and REPLACE TABLE get the same
treatment, e.g. after `ResolveTimeZone.resolveTimeZones(child)`, with CREATE
TABLE rows in the new test, or could this go to a separate JIRA that the
description mentions?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -142,6 +160,17 @@ case class CheckConstraint(
.build()
}
+ /**
+ * Whether the rules that compute the current time, date, catalog and the
like change part of `e`,
+ * applied to `e` as the optimizer has it, with its `RuntimeReplaceable`s
replaced.
+ */
+ private def readsCurrentContext(e: Expression): Boolean = {
+ val replaced = ReplaceExpressions.replace(e)
+ replaced.containsPattern(CURRENT_LIKE) ||
+ (ComputeCurrentTime.applyForExpression(replaced) ne replaced) ||
Review Comment:
Minor: time-only timestamp strings read the current date too, and nothing
here detects them. For a string with only a time part, `stringToTimestamp`
takes the date from `LocalDate.now(zoneId)` (SparkDateTimeUtils.scala:907-908),
so `ConstantFolding` freezes `CAST('12:00:00' AS TIMESTAMP)`, or the implicit
cast in `ts >= '08:00'`, at the ALTER TABLE date. This line does not catch it
because `Cast.isTimeToTimestampLTZ` is false for a string source, and L171 does
not because '12:00:00' is not a special value:
```sql
-- on 2026-10-20
ALTER TABLE t ADD CONSTRAINT c CHECK (ts <= CAST('23:59:59' AS TIMESTAMP));
-- the stored predicate compares ts with 2026-10-20 23:59:59
-- on 2026-10-21
INSERT INTO t VALUES (1, current_timestamp()); -- CHECK_CONSTRAINT_VIOLATION
```
CREATE TABLE does not fold this cast because it needs a time zone, so there
the check stays live. It is the string form of the TIME-to-TIMESTAMP cast this
line handles. Could it be detected the same way, or listed in the description
as not covered?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -142,6 +160,17 @@ case class CheckConstraint(
.build()
}
+ /**
+ * Whether the rules that compute the current time, date, catalog and the
like change part of `e`,
+ * applied to `e` as the optimizer has it, with its `RuntimeReplaceable`s
replaced.
+ */
+ private def readsCurrentContext(e: Expression): Boolean = {
Review Comment:
**Typed literals such as `TIMESTAMP 'now'` and `DATE 'today'` are still
frozen at the ALTER TABLE time, because the parser has already turned them into
plain literals.**
`AstBuilder.visitTypeConstructor` converts special values while parsing
(AstBuilder.scala:4777, 4817, 4834), so `analyzedChild` holds only a `Literal`,
none of the three checks here fires, and L149 stores the ALTER-time value,
while `predicateSql`, and so the error message, still says `'today'`:
```sql
ALTER TABLE t ADD CONSTRAINT c CHECK (d <= DATE 'today');
-- the next day:
INSERT INTO t VALUES (1, current_date()); -- CHECK_CONSTRAINT_VIOLATION on
d <= DATE 'today'
```
Likewise `created >= TIMESTAMP 'yesterday'` keeps accepting stale rows. The
migration guide names the typed literal as one of the two spellings of special
values, next to the cast this PR handles (sql-migration-guide.md:318). This PR
also makes the result depend on the rest of the condition: `created >=
TIMESTAMP 'yesterday' AND created <= current_timestamp()` gets no predicate
because of `current_timestamp()`, so its typed literal is re-parsed on every
write, while `created >= TIMESTAMP 'yesterday'` alone stays frozen. The
analyzed tree cannot tell these apart, so a fix would need the parser to record
it, e.g. in `visitCheckConstraint`. Note that a test row comparing `TIMESTAMP
'now'` with a `current_timestamp()` value cannot pass under any design, since
the write parses `predicateSql` before `ComputeCurrentTime` takes its snapshot;
a row that writes `TIMESTAMP 'now'`, or an assertion on `predicate()`, would
work. Could the description at least say that typed literals are still f
rozen, with a follow-up JIRA?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -170,6 +170,60 @@ 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.
Review Comment:
Minor: a check that a row can satisfy today and fail later now also fails
row-level operations on rows they do not change. This test covers INSERT, but
group-based DELETE, UPDATE and MERGE copy the unchanged rows of each rewritten
group through the same `CheckInvariant` (RewriteDeleteFromTable.scala:85-90;
only a delta DELETE is skipped, ResolveTableConstraints.scala:35-38):
```sql
-- day D: rows (1, D) and (2, D + 1) in one group of a copy-on-write table
ALTER TABLE t ADD CONSTRAINT not_past CHECK (d >= current_date());
-- day D + 1:
DELETE FROM t WHERE id > 1; -- copies row 1 and fails with
CHECK_CONSTRAINT_VIOLATION
```
Before this PR the frozen `d >= <day D>` let it succeed. The constraint is
also stored as VALID (constraints.scala:159), which Check.java:39-40 defines as
"the existing data is guaranteed to satisfy the constraint", and that stops
being true over time. CREATE TABLE constraints already behave this way. Could
the user-facing section mention it, and could the questions of whether such a
constraint should be VALID and whether copied rows need checking go to a
separate JIRA?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -120,16 +122,32 @@ 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,
+ analyzedChild: Option[Expression] = None)
Review Comment:
Nit: only a Boolean derived from this field is ever read,
`analyzedChild.exists(readsCurrentContext)` at L146. Computing it once in
`markAsAnalyzed` and storing `readsCurrentContext: Boolean = false` would give
the same result on every path, make the `stringArgs` override optional, and
keep the next reader from building the predicate from `analyzedChild` as the
sibling expressions do, which is what c61d6028b87 did and what the description
now rules out. Keeping the tree matches the siblings, so this is a judgment
call. Could this field be a Boolean, or could L142-144 at least say why the
predicate is not built from it?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -142,6 +160,17 @@ case class CheckConstraint(
.build()
}
+ /**
+ * Whether the rules that compute the current time, date, catalog and the
like change part of `e`,
Review Comment:
Minor: these comments do not say how the check works or why the predicate is
not built from `analyzedChild`, which is what would stop the next change from
undoing this one.
- This scaladoc says the rules are "applied to `e`", but the catalog,
database, user and path functions are caught by the CURRENT_LIKE pattern at
L169, not by applying `ReplaceCurrentLike`, which needs a `CatalogManager`.
Nothing links this list to `Optimizer.FinishAnalysis`.
- After L169, the term at L170 only matters for TIME-to-TIMESTAMP casts,
which `ComputeCurrentTime` deliberately leaves without CURRENT_LIKE
(finishAnalysis.scala:145-147).
- L142-144 say why the predicate is null, but not why it is not built from
`analyzedChild` as the sibling expressions do (the `CAST('today' AS DATE)`
folding), nor that CREATE and REPLACE TABLE reach `toV2Constraint` with
`analyzedChild = None`.
- The comment of the second test (CheckConstraintSuite.scala:205-207) does
not say why it uses `nullif`: `V2ExpressionBuilder` has no `NullIf` case, so
the test also fails if the predicate is built from the analyzed condition,
which `tbl.id > 0` would not catch.
Could the scaladoc say, for example, "The CURRENT_LIKE pattern stands in for
ReplaceCurrentLike, which needs a CatalogManager, and for the functions
ComputeCurrentTime replaces. ComputeCurrentTime is still applied for the TIME
-> TIMESTAMP casts it rewrites, which carry no CURRENT_LIKE bit.", and could
L144 add why `analyzedChild` is not used?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -120,16 +122,32 @@ 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,
+ analyzedChild: Option[Expression] = None)
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)
+
+ override def markAsAnalyzed(): CheckConstraint = copy(analyzedChild =
Some(child))
Review Comment:
Nit: under the legacy `spark.sql.legacy.earlyEvalCurrentTime=true`, `child`
is already folded when this captures it. ResolveTableSpec.scala:39-45 runs
`ComputeCurrentTime(ResolveTimeZone(plan))` during analysis on any plan that
contains a command, `AddCheckConstraint` included, so with that flag
`current_timestamp()`, `now()`, `current_date()` and `localtimestamp()` stay
frozen while `CAST('now' AS TIMESTAMP)` and `current_user()` do not. The doc of
the flag says it sacrifices correctness, so this may be acceptable. Could
L142-144 or the description mention it, with narrowing that branch to the
OPTIONS values it exists for as a separate JIRA?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -142,6 +160,17 @@ case class CheckConstraint(
.build()
}
+ /**
+ * Whether the rules that compute the current time, date, catalog and the
like change part of `e`,
+ * applied to `e` as the optimizer has it, with its `RuntimeReplaceable`s
replaced.
+ */
+ private def readsCurrentContext(e: Expression): Boolean = {
+ val replaced = ReplaceExpressions.replace(e)
+ replaced.containsPattern(CURRENT_LIKE) ||
Review Comment:
Minor: no test pins this term, and it is the only one that catches
`current_database()`, `current_catalog()`, `current_user()` and
`current_path()`. The new test's `current_timestamp()` and `current_date()`
rows are also caught by `ComputeCurrentTime` at L170, and its 'now' and 'today'
rows by L171, so deleting this term leaves both new tests green while `ALTER
TABLE ... CHECK (db = current_database())` goes back to freezing the ALTER-time
namespace through `ReplaceCurrentLike`. The description lists
`current_database()` and `current_user()`, but the existing `current_database`
tests (CheckConstraintSuite.scala:609-712) use CREATE TABLE only. Could a test
run `USE ns1`, add `CHECK (db = current_database())` with ALTER TABLE, assert
that `predicate()` is null, then `USE ns2` and insert 'ns2', restoring the
namespace afterwards?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -120,16 +122,32 @@ 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,
+ analyzedChild: Option[Expression] = None)
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)
+
+ override def markAsAnalyzed(): CheckConstraint = copy(analyzedChild =
Some(child))
+
+ // ALTER TABLE builds the constraint from the optimized plan, where the
current time, date,
+ // catalog, user and the like are replaced by their values at the time of
the ALTER TABLE. A
+ // condition that reads one is stored without a predicate, so that writes
check `predicateSql`.
Review Comment:
Minor: under the legacy
`spark.sql.legacy.parameterSubstitution.constantsOnly=true`, a parameter marker
stays in `predicateSql`, so a current-context condition with a marker now makes
every write fail. In that mode the parser leaves the markers in the text and
`BindParameters` binds them during analysis (SparkSqlParser.scala:158-161), so
`condition` keeps `:d`. Before this PR the stored predicate held the bound
value; now the predicate is null and each write parses `predicateSql` with `:d`
unbound:
```scala
spark.sql(
"ALTER TABLE t ADD CONSTRAINT c CHECK (ts >= current_timestamp() -
make_interval(0, 0, 0, :d))",
Map("d" -> 1))
// every later INSERT, UPDATE and MERGE fails with UNBOUND_SQL_PARAMETER
```
I have not run it, and CREATE TABLE has the same problem when its predicate
cannot be translated. DEFAULT and GENERATED expressions reject markers for this
reason (AstBuilder.scala:5567-5569, 5596-5598), but `visitCheckConstraint` does
not. Could it reject them the same way?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -120,16 +122,32 @@ 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,
+ analyzedChild: Option[Expression] = None)
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)
+
+ override def markAsAnalyzed(): CheckConstraint = copy(analyzedChild =
Some(child))
+
+ // ALTER TABLE builds the constraint from the optimized plan, where the
current time, date,
+ // catalog, user and the like are replaced by their values at the time of
the ALTER TABLE. A
+ // condition that reads one is stored without a predicate, so that writes
check `predicateSql`.
def toV2Constraint: Constraint = {
- val predicate = new V2ExpressionBuilder(child,
true).buildPredicate().orNull
+ val predicate = if (analyzedChild.exists(readsCurrentContext)) {
+ null
Review Comment:
Minor: this adds a second reason for a null predicate, which the public
`Check.predicate()` does not document. Check.java:77-78 says it returns null
"if the condition cannot be expressed using supported predicate expressions",
and the class doc says the predicate is exposed "whenever it can be expressed
using supported expressions" (Check.java:32-33). Under ANSI, `ts <= CAST('now'
AS TIMESTAMP)` gets a V2 cast predicate from CREATE TABLE
(V2ExpressionBuilder.scala:163-165) but null from ALTER TABLE after this PR, so
a connector that reads null as "cannot be enforced natively" behaves
differently depending on which DDL added the constraint. Spark itself falls
back to `predicateSql`, so its own enforcement is fine. Could the Javadoc of
`predicate()` add a sentence such as "Spark may also leave it null for a
condition that reads the current time, date, catalog, user and the like, so
that each write evaluates it from predicateSql()."?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -120,16 +122,32 @@ 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,
+ analyzedChild: Option[Expression] = None)
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)
+
+ override def markAsAnalyzed(): CheckConstraint = copy(analyzedChild =
Some(child))
+
+ // ALTER TABLE builds the constraint from the optimized plan, where the
current time, date,
+ // catalog, user and the like are replaced by their values at the time of
the ALTER TABLE. A
+ // condition that reads one is stored without a predicate, so that writes
check `predicateSql`.
Review Comment:
Minor: with no predicate, writes re-parse and re-resolve the whole condition
under their own session settings, not only its current-context part.
ResolveTableConstraints.scala:79 parses `predicateSql` with the writer's parser
and conf, while the predicate stored before this PR carried resolved column
names and already converted literals, and its V2 round trip uses ANSI casts
(V2ExpressionUtils.scala:268). From reading the code:
- With `spark.sql.ansi.doubleQuotedIdentifiers=true` under ANSI, `CHECK
("created" <= current_timestamp())` refers to the column, but a writer with the
default settings parses `"created"` as a string, so every write fails with
CAST_INVALID_INPUT under ANSI, and with ANSI off the check is NULL and accepts
every row.
- `CHECK (created >= TIMESTAMP '2024-01-01 00:00:00' AND created <=
current_timestamp())` added in UTC gets a lower bound 8 hours later for a
writer in America/Los_Angeles.
- `CHECK (CAST(code AS INT) > 0 AND ts <= current_timestamp())` silently
accepts bad codes from a writer with ANSI off.
CREATE TABLE constraints without a predicate already behave this way, and
views avoid it by storing the confs they were created with. Could the
user-facing section say that such constraints are re-parsed with each writer's
session settings, and could storing those confs be a follow-up JIRA?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/constraints.scala:
##########
@@ -120,16 +122,32 @@ 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,
+ analyzedChild: Option[Expression] = None)
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)
+
+ override def markAsAnalyzed(): CheckConstraint = copy(analyzedChild =
Some(child))
+
+ // ALTER TABLE builds the constraint from the optimized plan, where the
current time, date,
+ // catalog, user and the like are replaced by their values at the time of
the ALTER TABLE. A
+ // condition that reads one is stored without a predicate, so that writes
check `predicateSql`.
def toV2Constraint: Constraint = {
- val predicate = new V2ExpressionBuilder(child,
true).buildPredicate().orNull
+ val predicate = if (analyzedChild.exists(readsCurrentContext)) {
+ null
+ } else {
+ new V2ExpressionBuilder(child, true).buildPredicate().orNull
Review Comment:
**The second reason in the description, that `V2ExpressionBuilder` evaluates
`CAST('today' AS DATE)` as an ordinary cast, is a bug in
`Cast.contextIndependentFoldable`, and it already breaks DEFAULT values and
generated columns as well.**
`contextIndependentFoldable` promises folding "without relying on external
context, such as current time zone", so that the expression "can be safely
evaluated during DDL operations like creating tables, views, or constraints"
(Expression.scala:106-116). Cast.scala:716-718 decides it from the types alone,
but a special string cast to DATE or TIMESTAMP_NTZ reads the clock and the
session time zone. `DefaultValueExpression.toV2` and `toV2CurrentDefault`
(ColumnDefinition.scala:284, 294) and `GeneratedColumnExpression.toV2`
(GeneratedColumnExpression.scala:135) run the same builder on their
`analyzedChild`. From reading the code, on a V2 catalog:
```sql
CREATE TABLE testcat.t (id INT, d DATE DEFAULT CAST('today' AS DATE));
-- ANSI on: fails with CAST_INVALID_INPUT, and so do ADD COLUMN and SET
DEFAULT
-- ANSI off: stores a NULL literal as the V2 current default, which
-- ResolveDefaultColumnsUtil.scala:219-221 prefers over the SQL text,
-- so INSERT INTO testcat.t(id) VALUES (1) writes NULL instead of today
```
If `contextIndependentFoldable` were false for a foldable string child that
is a special value, the second reason would go away, and so would the CREATE
TABLE problem at L146. Its only reader in the main code is
V2ExpressionBuilder.scala:108, and query pushdown is not affected because the
optimizer has already replaced these casts with literals.
`DateTimeUtils.extractSpecialValue` is private now; a `private[sql]` helper
around it could serve both this and cloud-fan's 'epoch' point at L171. Could
this be filed as a separate JIRA and referenced from the description?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -170,6 +170,60 @@ 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.
+ Seq(
+ ("TIMESTAMP", "current_timestamp()", "current_timestamp()", "INTERVAL 1
DAY"),
+ ("TIMESTAMP", "CAST('now' AS TIMESTAMP)", "current_timestamp()",
"INTERVAL 1 DAY"),
Review Comment:
Minor: at write time `CAST('now' AS TIMESTAMP)` and `current_timestamp()`
are different instants, so only `<=` holds at the boundary.
`ComputeCurrentTime` folds `current_timestamp()` from one snapshot
(finishAnalysis.scala:116), but `SpecialDatetimeValues` runs after it and folds
'now' with its own `Instant.now()` (DateTimeUtils.scala:807). So `CHECK
(expires_at >= CAST('now' AS TIMESTAMP))` rejects `INSERT INTO t VALUES (1,
current_timestamp())` unless both land in the same microsecond, while the same
check written with `current_timestamp()` accepts it, and so did the frozen
value before this PR. This row passes because it uses `<=` and the two rules
run in that order. The mechanism is not new, as `SELECT current_timestamp() >=
CAST('now' AS TIMESTAMP)` is false as well. Could the comment at L174 or the
description say that 'now' is read after the `current_timestamp()` snapshot?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -170,6 +170,60 @@ 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.
+ 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_NTZ", "CAST('now' AS TIMESTAMP_NTZ)", "localtimestamp()",
"INTERVAL 1 DAY"),
+ ("DATE", "current_date()", "current_date()", "1"),
+ ("DATE", "CAST('today' AS DATE)", "current_date()", "1")
+ ).foreach { case (dataType, now, current, step) =>
+ Seq("true", "false").foreach { ansi =>
+ withSQLConf(SQLConf.ANSI_ENABLED.key -> ansi) {
+ withNamespaceAndTable("ns", "tbl", nonPartitionCatalog) { t =>
+ sql(s"CREATE TABLE $t (id INT, created $dataType) $defaultUsing")
+ sql(s"ALTER TABLE $t ADD CONSTRAINT valid_time CHECK (created <=
$now)")
+
+ sql(s"INSERT INTO $t VALUES (1, $current)")
+ checkAnswer(spark.table(t).select("id"), Seq(Row(1)))
+
+ val error = intercept[SparkRuntimeException] {
+ sql(s"INSERT INTO $t VALUES (2, $current + $step)")
+ }
+ assert(error.getCondition == "CHECK_CONSTRAINT_VIOLATION")
+ assert(error.getMessageParameters.get("constraintName") ==
"valid_time")
+ assert(error.getMessageParameters.get("expression") == s"created
<= $now")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-60032: Check constraint added by ALTER TABLE on a column
qualified by the table") {
+ // ALTER TABLE resolves `tbl.id` as the column `id` of the table `tbl`,
not as the field `id` of
+ // its struct column `tbl`. A write that parses `predicateSql` reads
`tbl.id` as the field, so
+ // the test fails if this constraint is stored without a predicate.
+ withNamespaceAndTable("ns", "tbl", nonPartitionCatalog) { t =>
+ sql(s"CREATE TABLE $t (id BIGINT, tbl STRUCT<id: BIGINT>) $defaultUsing")
+ sql(s"ALTER TABLE $t ADD CONSTRAINT c1 CHECK (nullif(tbl.id, 3) > 0)")
Review Comment:
Minor: this is pre-existing and outside this PR, but `ALTER TABLE ... ADD
CONSTRAINT ... CHECK` accepts a view and stores the constraint on its base
table after validating only the rows the view returns. Only the CHECK branch of
`visitAddTableConstraint` resolves the target with `createUnresolvedRelation`
(AstBuilder.scala:7067), which follows views (Analyzer.scala:1297-1298); the
other constraints use `UnresolvedTable` (AstBuilder.scala:7071), which fails
with EXPECT_TABLE_NOT_VIEW (Analyzer.scala:1311-1319). From reading the code:
```sql
-- cat.ns.t has a row with id = 5
CREATE TEMPORARY VIEW v AS SELECT * FROM cat.ns.t WHERE id > 50;
ALTER TABLE v ADD CONSTRAINT c CHECK (id > 10);
-- validates NOT(id > 10) AND id > 50, finds no row, and adds c to cat.ns.t
as VALID
```
With a view `SELECT id * 100 AS id FROM t`, the validation checks `id * 100
> 10` but the stored predicate is `id > 10`. Could this be a separate JIRA?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -170,6 +170,60 @@ 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.
+ 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_NTZ", "CAST('now' AS TIMESTAMP_NTZ)", "localtimestamp()",
"INTERVAL 1 DAY"),
+ ("DATE", "current_date()", "current_date()", "1"),
+ ("DATE", "CAST('today' AS DATE)", "current_date()", "1")
+ ).foreach { case (dataType, now, current, step) =>
+ Seq("true", "false").foreach { ansi =>
+ withSQLConf(SQLConf.ANSI_ENABLED.key -> ansi) {
+ withNamespaceAndTable("ns", "tbl", nonPartitionCatalog) { t =>
+ sql(s"CREATE TABLE $t (id INT, created $dataType) $defaultUsing")
+ sql(s"ALTER TABLE $t ADD CONSTRAINT valid_time CHECK (created <=
$now)")
Review Comment:
Minor: this is pre-existing and outside this PR, but an ALTER TABLE CHECK
whose condition folds to TRUE or NULL fails with an internal error, and a
condition on the current time is a natural way to hit it. The child
`Filter(Not(cond), relation)` (AstBuilder.scala:7068) folds to `Filter(false)`,
`PruneFilters` turns it into an empty `LocalRelation` before
`V2ScanRelationPushDown` runs, and the only strategy for `AddCheckConstraint`
needs a `DataSourceV2ScanRelation` (DataSourceV2Strategy.scala:810), so
planning fails with "No plan for AddCheckConstraint":
```sql
ALTER TABLE t ADD CONSTRAINT c CHECK (current_date() > DATE'2023-01-01');
-- INTERNAL_ERROR
-- the same for CHECK (1 > 0), CHECK (NULL), and CHECK (id IS NOT NULL) on a
NOT NULL column
```
CREATE TABLE accepts these conditions (L1252-1270). Could this be a separate
JIRA, with an ALTER TABLE version of that test?
--
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]