peter-toth commented on code in PR #59965:
URL: https://github.com/apache/spark/pull/59965#discussion_r4435126197
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveTableConstraints.scala:
##########
@@ -123,23 +131,41 @@ class ResolveTableConstraints(val catalogManager:
CatalogManager) extends Rule[L
val colRef = UnresolvedAttribute.quoted(col.name)
val columnExtractors = Seq(col.name -> colRef)
val genExprSql = Option(genExpr.getSql()).getOrElse(catalystExpr.sql)
- CheckInvariant(
- EqualNullSafe(colRef, catalystExpr),
- columnExtractors,
- "Generated Column",
- s"${col.name} <=> $genExprSql")
+ analyzeGeneratedColumnCheck(
+ CheckInvariant(
+ EqualNullSafe(colRef, catalystExpr),
+ columnExtractors,
+ "Generated Column",
+ s"${col.name} <=> $genExprSql"),
+ v2Write.query)
}
}.toSeq
}
+ /**
+ * Analyzes the check of a provided value under the settings an auto-filled
value is analyzed
+ * under, so that a row Spark auto-filled passes the check when a later
write carries it. What
+ * those settings cannot resolve is left to the analysis of the write, as
for an auto-filled
+ * value.
+ */
+ private def analyzeGeneratedColumnCheck(check: Expression, query:
LogicalPlan): Expression = {
+ SQLConf.withExistingConf(StoredExpressionText.conf(conf)) {
+ analyze(Filter(check, LocalRelation(query.output)))
+ } match {
+ case Filter(analyzed, _: LocalRelation) => analyzed
+ // A catalog can store a text that analyzes to another plan. The write
analyzes it as is.
+ case _ => check
+ }
+ }
+
/**
* Convert a V2 GenerationExpression to a Catalyst expression.
* Try the V2 expression first, fall back to parsing the SQL string.
*/
private def buildGenerationCatalystExpression(genExpr:
GenerationExpression): Expression = {
Option(genExpr.getExpression)
.flatMap(V2ExpressionUtils.toCatalyst)
-
.getOrElse(catalogManager.v1SessionCatalog.parser.parseExpression(genExpr.getSql))
+
.getOrElse(StoredExpressionText.parse(catalogManager.v1SessionCatalog.parser,
genExpr.getSql))
Review Comment:
**Finding 5.** For a table created with ANSI off, the check of a provided
value and the auto-fill can still read the expression differently:
- The auto-fill parses the text and analyzes it with ANSI on
(`Analyzer.scala:4143`).
- The check takes the V2 form first, here. That form comes from the analysis
in the DDL's session, so it carries that session's implicit casts. They are
already explicit, so `analyzeGeneratedColumnCheck` doesn't change them.
In round 1 I said the V2 form doesn't take over, but that only holds for `a
+ 1`. `V2ExpressionBuilder` keeps a non-ANSI cast when it is an upcast, so
`coalesce` and `CASE WHEN` do get a V2 form. I ran this with ANSI off for the
DDL and the writes, on `testcat`:
```sql
CREATE TABLE t (id INT, s STRING, b STRING GENERATED ALWAYS AS (coalesce(s,
1)));
-- stored V2 form: COALESCE(s, '1')
INSERT INTO t (id, s) VALUES (1, '007'); -- head: b = '7' (s cast
to BIGINT), base: b = '007'
INSERT INTO t (id, s, b) VALUES (3, '007', '7'); -- head:
CHECK_CONSTRAINT_VIOLATION
```
Each insert ran on a fresh table, because `InMemoryTable` drops the V2 form
on its first write. `CASE WHEN id > 0 THEN s ELSE 1 END` behaves the same. Base
auto-fills `'007'`, and its check accepts `'007'`. So on head, a later write
that carries an auto-filled row fails on a connector that keeps the V2 form,
such as a copy-on-write `DELETE`. I couldn't run that `DELETE`, because
`InMemoryRowLevelOperationTableCatalog` builds its tables from a `StructType`
and drops the V2 form. That is also why the new DELETE test doesn't reach this
path. The description's "keeps a row that Spark auto-filled valid when a later
write carries it" doesn't hold here yet.
Reading the text first when there is one makes both sides agree. This also
takes the parser change from finding 9. With it, the second insert passes, and
the full `GeneratedColumnWriteSuite` and `DataSourceV2DataFrameSuite` still
pass:
```scala
Option(genExpr.getSql)
.map(StoredExpressionText.parse(CatalystSqlParser, _))
.orElse(Option(genExpr.getExpression).flatMap(V2ExpressionUtils.toCatalyst))
.get
```
The `GenerationExpression` javadoc and the comment above this method say
Spark converts the V2 form first. So they would need the same change. The other
way is to make the auto-fill read the V2 form first too. Either way, a test
with the two inserts above on `testcat` would pin it.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/StoredExpressionText.scala:
##########
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.util
+
+import org.apache.spark.sql.catalyst.analysis.{Analyzer,
ExpressionWithUnresolvedIdentifier}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Expression, ExprId}
+import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface}
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Reads the SQL text of an expression stored with a table, such as a CHECK
condition or a
+ * generation expression, independently of the settings of the session that
reads it.
+ */
+object StoredExpressionText {
+
+ /**
+ * The settings a stored text is read under. The session time zone and case
sensitivity stay with
+ * the given session, because the settings of the DDL are not known here and
pinning them would
+ * silently change existing tables of a non-UTC or a case-sensitive
deployment.
+ */
+ def conf(session: SQLConf): SQLConf = {
+ val c = new SQLConf
+ Analyzer.retainResolutionConfigsForAnalysis(newConf = c, existingConf =
session)
+ c.setConfString(SQLConf.ANSI_ENABLED.key, "true")
+ c.setConfString(SQLConf.SESSION_LOCAL_TIMEZONE.key,
session.sessionLocalTimeZone)
Review Comment:
**Finding 7.** The rationale for the time zone here also applies to
`spark.sql.timestampType`, but that setting is pinned. `readsAsInSession` can't
catch it, because it acts during analysis. `to_timestamp`, `make_timestamp` and
similar functions take their result type from it when they resolve. With the
session time zone `America/Los_Angeles` and
`spark.sql.timestampType=TIMESTAMP_NTZ` for the DDL and the write, on base and
head:
```sql
CREATE TABLE t (a STRING, b TIMESTAMP_NTZ GENERATED ALWAYS AS
(to_timestamp(a))); -- accepted on both
INSERT INTO t (a) VALUES ('2024-03-10 02:30:00');
-- base: b = 2024-03-10 02:30:00, which SELECT to_timestamp(a) also returns
in this session
-- head: b = 2024-03-10 03:30:00 (TIMESTAMP_LTZ in the DST gap, then cast to
NTZ)
```
On head the check of a provided value accepts both `02:30` and `03:30`, so
nothing flags the change. There are two ways out, and I tried neither:
- Keep `spark.sql.timestampType` from the session, like the time zone.
`CAST(a AS TIMESTAMP)` then follows the writer again.
- Have `validate` also analyze the text under the write settings, and reject
it when the result differs from the analyzed child. That would also catch other
settings that act during analysis.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala:
##########
@@ -4122,11 +4122,37 @@ class Analyzer(
cleanedRelation
case other => other
}
- v2Write.withNewQuery(projection).withNewTable(cleanedTable)
+ v2Write
+ .withNewQuery(analyzeAutoFilledGeneratedColumns(projection,
autoFilledGenCols))
+ .withNewTable(cleanedTable)
} else {
v2Write
}
}
+
+ /**
+ * Analyzes the auto-filled generated columns of a write under the
settings their stored text
+ * is read under, so that the implicit casts and the functions of a
generation expression do
+ * not follow the session that writes.
+ */
+ private def analyzeAutoFilledGeneratedColumns(
+ projection: LogicalPlan,
+ autoFilled: Set[String]): LogicalPlan = projection match {
+ case p @ Project(projectList, child) if autoFilled.nonEmpty =>
+ val filled = projectList.filter(c => autoFilled.contains(c.name))
+ SQLConf.withExistingConf(StoredExpressionText.conf(conf)) {
+ execute(Project(filled, LocalRelation(child.output)))
+ } match {
+ // A text these settings cannot resolve stays unresolved here, and
the analysis of the
Review Comment:
**Finding 6.** This comment doesn't hold once the text contains a node that
is fixed before the session takes over. Neither does the migration guide's "A
text that does not resolve with ANSI mode on ... also still follows that
session". `StoredExpressionText.parse` builds `Cast` and the arithmetic nodes
with ANSI eval modes, and the nested pass resolves functions such as `abs` with
ANSI too. The session then only finishes what is left, and the ANSI parts stay.
I ran both cases below on base and head, with ANSI off for the DDL and for
every write:
```sql
CREATE TABLE t1 (d DATE, b BOOLEAN GENERATED ALWAYS AS (CAST(d AS BOOLEAN)));
INSERT INTO t1 (d) VALUES (DATE'2024-01-01');
-- base: b = NULL from an ANSI-off writer; head:
DATATYPE_MISMATCH.CAST_WITH_CONF_SUGGESTION from any writer
CREATE TABLE t2 (flag BOOLEAN, a INT,
b INT GENERATED ALWAYS AS (CASE WHEN flag = 1 THEN abs(a) ELSE 0 END));
INSERT INTO t2 (flag, a) VALUES (true, -2147483648);
-- base: b = -2147483648; head: ARITHMETIC_OVERFLOW
```
The first case makes a 4.3 table created with ANSI off unwritable from any
session. CREATE accepts it on head too, because `readsAsInSession` compares
under the session's ANSI value. This also corrects my verdict on finding 3.
`flag = 1` alone behaves as on base, but these shapes don't.
Neither of these is tried here:
- **Make the comment true.** Read a column that the fixed settings don't
resolve entirely under the session. Parse its text again under `SQLConf.get`,
and leave it to the outer analysis instead of keeping the partly resolved tree.
Do the same in `analyzeGeneratedColumnCheck`. Doing it per column would also
fix `case _ => p` below. Today that case sends every auto-filled column back to
the session when one catalog-stored text analyzes to another plan.
- **Reject at CREATE.** Analyze the text under the write settings, and
reject it when it doesn't resolve. Then say in the migration guide that
existing tables with such a text fail.
A test with `t1` would pin either one.
##########
docs/sql-migration-guide.md:
##########
@@ -69,6 +69,8 @@ license: |
- Since Spark 4.4, `UPDATE` and `MERGE` on a Data Source V2 table fail with
`DATATYPE_MISMATCH.INVALID_ROW_LEVEL_OPERATION_ASSIGNMENTS` when they assign to
a metadata column of the table. This includes `UPDATE SET *` and `INSERT *` in
`MERGE WITH SCHEMA EVOLUTION` when the source has a column with the name of a
metadata column of the target table. Previously, these assignments passed
analysis and were usually ignored. To restore the previous behavior, set
`spark.sql.legacy.ignoreMetadataColumnAssignments` to `true`.
- Since Spark 4.4, `CREATE TABLE`, `REPLACE TABLE`, and `ALTER TABLE ... ADD
CONSTRAINT` on a Data Source V2 table fail with
`USER_DEFINED_FUNCTION_IN_CHECK_CONSTRAINT` when the condition of a CHECK
constraint calls a user-defined function, including a Data Source V2 function.
`ALTER TABLE ... ADD CONSTRAINT` keeps failing with `UNSUPPORTED_SQL_UDF_USAGE`
on a SQL function and with `INVALID_WHERE_CONDITION` on an aggregate function.
Previously, a write could resolve the function name in its own session, so that
it enforced another rule or failed (SPARK-60777). To run these statements,
change the condition to call only built-in functions. Existing constraints that
call a user-defined function are not rejected.
- Since Spark 4.4, a write to a Data Source V2 table that parses the SQL text
of a CHECK constraint binds an unqualified built-in function name to
`system.builtin`, regardless of the SQL path and
`spark.sql.functionResolution.sessionOrder` (SPARK-60777). Previously, an
existing constraint could call another function with the same name.
+- Since Spark 4.4, a write to a Data Source V2 table parses and analyzes the
stored text of a generation expression with ANSI mode on and the default values
of the SQL configs that change how a text reads, such as
`spark.sql.parser.escapedStringLiterals`, regardless of the session of the
write. Only the session time zone, case sensitivity, and the configs that a
view also takes from the session that reads it follow that session. A text that
does not resolve with ANSI mode on, such as `flag = 1` on a `BOOLEAN` column,
also still follows that session. Previously, the session of the write decided
how the text read, so a write could silently store a wrong value (SPARK-60757).
As a result, with `spark.sql.ansi.enabled=false`, also for a table created with
that setting, a write can fail with an error such as `CAST_INVALID_INPUT` where
Spark 4.3 stored `NULL`, and an implicit cast can change the stored value. A
row that Spark 4.3 stored that way can fail with such an error or with `CHECK_C
ONSTRAINT_VIOLATION` when a later write, such as a `DELETE` that rewrites
rows, carries it.
Review Comment:
**Finding 10.** Two gaps in this entry:
- "Only the session time zone, case sensitivity, and the configs that a view
also takes from the session that reads it follow that session" leaves out two
things the PR description lists under "The following still depend on the
session that writes". Those are the functions found through `SET PATH`, and the
settings an expression reads during evaluation, such as
`spark.sql.legacy.timeParserPolicy`.
- It covers existing tables only for ANSI. A table created in 4.3 under a
non-default parser setting now gets the default reading of its text on every
write, with no error. An example is a deployment with
`spark.sql.parser.escapedStringLiterals=true`. Its earlier rows can then fail a
later `DELETE` that rewrites them. That is worth one sentence, together with
the fix: recreate the column with a text that reads the same.
The neighbouring entries name a `spark.sql.legacy.*` config that restores
the old behaviour. With finding 6, some existing tables can't be written at
all, so such a config could be worth having here too.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveTableConstraints.scala:
##########
@@ -123,23 +131,41 @@ class ResolveTableConstraints(val catalogManager:
CatalogManager) extends Rule[L
val colRef = UnresolvedAttribute.quoted(col.name)
val columnExtractors = Seq(col.name -> colRef)
val genExprSql = Option(genExpr.getSql()).getOrElse(catalystExpr.sql)
- CheckInvariant(
- EqualNullSafe(colRef, catalystExpr),
- columnExtractors,
- "Generated Column",
- s"${col.name} <=> $genExprSql")
+ analyzeGeneratedColumnCheck(
+ CheckInvariant(
+ EqualNullSafe(colRef, catalystExpr),
+ columnExtractors,
+ "Generated Column",
+ s"${col.name} <=> $genExprSql"),
+ v2Write.query)
}
}.toSeq
}
+ /**
+ * Analyzes the check of a provided value under the settings an auto-filled
value is analyzed
+ * under, so that a row Spark auto-filled passes the check when a later
write carries it. What
+ * those settings cannot resolve is left to the analysis of the write, as
for an auto-filled
+ * value.
+ */
+ private def analyzeGeneratedColumnCheck(check: Expression, query:
LogicalPlan): Expression = {
+ SQLConf.withExistingConf(StoredExpressionText.conf(conf)) {
+ analyze(Filter(check, LocalRelation(query.output)))
+ } match {
+ case Filter(analyzed, _: LocalRelation) => analyzed
+ // A catalog can store a text that analyzes to another plan. The write
analyzes it as is.
+ case _ => check
+ }
+ }
+
/**
* Convert a V2 GenerationExpression to a Catalyst expression.
* Try the V2 expression first, fall back to parsing the SQL string.
*/
private def buildGenerationCatalystExpression(genExpr:
GenerationExpression): Expression = {
Option(genExpr.getExpression)
.flatMap(V2ExpressionUtils.toCatalyst)
-
.getOrElse(catalogManager.v1SessionCatalog.parser.parseExpression(genExpr.getSql))
+
.getOrElse(StoredExpressionText.parse(catalogManager.v1SessionCatalog.parser,
genExpr.getSql))
Review Comment:
**Finding 9.** This is the only place that reads the text with the session
parser. `v1SessionCatalog.parser` is `SparkSqlParser`, which substitutes
`${...}` under the active conf. Here that is the fixed conf, where
`spark.sql.variable.substitute` is back to `true`. The auto-fill
(`TableOutputResolver.scala:429` and `:603`) and `readsAsInSession` use
`CatalystSqlParser`, which never substitutes. With
`spark.sql.variable.substitute=false` for the DDL and the writes, on base and
head:
```sql
CREATE TABLE t (a STRING, b STRING GENERATED ALWAYS AS (concat(a,
'${system:java.version}')));
INSERT INTO t (a) VALUES ('v'); -- b =
v${system:java.version} on both
INSERT INTO t (a, b) VALUES ('w', 'w${system:java.version}'); -- base:
succeeds; head: CHECK_CONSTRAINT_VIOLATION
```
`CONCAT` has no way back from its V2 form, so this path reads the text on
any connector. With `CatalystSqlParser` here (plus its import), the second
INSERT passes, and the full `GeneratedColumnWriteSuite` and
`DataSourceV2DataFrameSuite` still pass. It also stops a parser extension from
reading the text differently in the two paths. The change in finding 5 includes
this one.
```suggestion
.getOrElse(StoredExpressionText.parse(CatalystSqlParser,
genExpr.getSql))
```
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/StoredExpressionText.scala:
##########
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.util
+
+import org.apache.spark.sql.catalyst.analysis.{Analyzer,
ExpressionWithUnresolvedIdentifier}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Expression, ExprId}
+import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface}
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Reads the SQL text of an expression stored with a table, such as a CHECK
condition or a
+ * generation expression, independently of the settings of the session that
reads it.
+ */
+object StoredExpressionText {
+
+ /**
+ * The settings a stored text is read under. The session time zone and case
sensitivity stay with
+ * the given session, because the settings of the DDL are not known here and
pinning them would
+ * silently change existing tables of a non-UTC or a case-sensitive
deployment.
+ */
+ def conf(session: SQLConf): SQLConf = {
Review Comment:
**Finding 12.** An alternative to the fixed settings: store the creating
session's SQL configs with the generation expression, as views and SQL
functions already do, and read the text under them on every write. Concretely:
- **At CREATE / REPLACE TABLE:** `GeneratedColumnExpression.toV2` captures
the configs with `CapturesConfig.sqlConfigsToProps`
(`CapturesConfig.scala:66`), the helper that the view commands and
`CreateSQLFunctionCommand` use. `GenerationExpression` (`@Evolving`, since
4.3.0) carries them as a new `Map<String, String>`, or they go into the column
metadata next to `GENERATION_EXPRESSION`.
- **On a write:** `TableOutputResolver`, `ResolveOutputRelation` and
`ResolveTableConstraints` parse and analyze the text under a conf built the way
`View.effectiveSQLConf` (`basicLogicalOperators.scala:978`) builds one for a
view. That conf already keeps the session-bound configs, and it replaces
`StoredExpressionText.conf`. The time zone could be left out of the captured
set, if the rationale in the scaladoc above holds for it.
- **What goes away:** `readsAsInSession`, `normalize` and the CREATE-time
rejection. The text is read under the settings it was written in, so no session
can read it differently.
- **What it fixes:** findings 6, 7 and 8 by construction, and likely 5,
because the V2 form and the text would then come from the same analysis. A
legacy cast stays legal in a table created with ANSI off. `to_timestamp` keeps
the DDL's `spark.sql.timestampType`. `allowPrecisionLoss` no longer causes a
false rejection. It also removes most of the cases the migration guide has to
explain in finding 10.
The counter-arguments are real:
- Tables created in 4.3 have no stored configs, so they still need today's
fixed reading (or the session's). Most of this PR stays as their fallback.
- `GenerationExpression` is connector API. Every catalog has to round-trip
the new field, and one that drops it silently falls back to the fixed reading.
- With the DDL's ANSI value stored, a table created with ANSI off keeps
legacy semantics on writes. That reverses this PR's choice that such a write
fails with the ANSI error, though it is arguably closer to what the DDL meant.
- It is a larger change. This PR could land first as the fallback, with the
stored configs as a follow-up.
[SPARK-60453](https://issues.apache.org/jira/browse/SPARK-60453) asks the
same question for CHECK constraints ("whether to store the confs with the
constraint"), so one stored-conf mechanism could serve both.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/StoredExpressionText.scala:
##########
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.util
+
+import org.apache.spark.sql.catalyst.analysis.{Analyzer,
ExpressionWithUnresolvedIdentifier}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Expression, ExprId}
+import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface}
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Reads the SQL text of an expression stored with a table, such as a CHECK
condition or a
+ * generation expression, independently of the settings of the session that
reads it.
Review Comment:
**Finding 13.** CHECK conditions don't go through this object.
`ResolveTableConstraints.buildCatalystExpression` still parses them with the
session's parser settings, which
[SPARK-60453](https://issues.apache.org/jira/browse/SPARK-60453) tracks.
```suggestion
* Reads the SQL text of a generation expression stored with a table,
independently of the
* settings of the session that reads it.
```
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/StoredExpressionText.scala:
##########
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.util
+
+import org.apache.spark.sql.catalyst.analysis.{Analyzer,
ExpressionWithUnresolvedIdentifier}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Expression, ExprId}
+import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface}
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Reads the SQL text of an expression stored with a table, such as a CHECK
condition or a
+ * generation expression, independently of the settings of the session that
reads it.
+ */
+object StoredExpressionText {
+
+ /**
+ * The settings a stored text is read under. The session time zone and case
sensitivity stay with
+ * the given session, because the settings of the DDL are not known here and
pinning them would
+ * silently change existing tables of a non-UTC or a case-sensitive
deployment.
+ */
+ def conf(session: SQLConf): SQLConf = {
+ val c = new SQLConf
+ Analyzer.retainResolutionConfigsForAnalysis(newConf = c, existingConf =
session)
+ c.setConfString(SQLConf.ANSI_ENABLED.key, "true")
+ c.setConfString(SQLConf.SESSION_LOCAL_TIMEZONE.key,
session.sessionLocalTimeZone)
+ c.setConfString(SQLConf.CASE_SENSITIVE.key,
session.caseSensitiveAnalysis.toString)
+ c
+ }
+
+ /** Parses `text` under [[conf]]. */
+ def parse(parser: ParserInterface, text: String): Expression = {
+ SQLConf.withExistingConf(conf(SQLConf.get)) {
+ parser.parseExpression(text)
+ }
+ }
+
+ /**
+ * Whether `text` reads the same under [[conf]] as it does in the active
session. The comparison
+ * uses the ANSI value of the session, so that it is about how the text
reads.
+ */
+ def readsAsInSession(parser: ParserInterface, text: String): Boolean = {
+ val session = SQLConf.get
+ val fixed = conf(session)
+ fixed.setConfString(SQLConf.ANSI_ENABLED.key, session.ansiEnabled.toString)
Review Comment:
**Finding 8.** The parser builds the arithmetic nodes with a
`NumericEvalContext`, which also captures
`spark.sql.decimalOperations.allowPrecisionLoss`. `conf` resets that setting to
its default. So with the setting off, every generation expression with `+`,
`-`, `*`, `/` or `%` counts as reading differently:
```sql
SET spark.sql.decimalOperations.allowPrecisionLoss=false;
CREATE TABLE t (a INT, b INT GENERATED ALWAYS AS (a + 1));
-- base: accepted; head: UNSUPPORTED_EXPRESSION_GENERATED_COLUMN (reads
differently)
```
Aligning it like ANSI fixes this. With the change below the CREATE succeeds,
and the full `GeneratedColumnWriteSuite` and `DataSourceV2DataFrameSuite` still
pass. A decimal expression still analyzes with the default on a write. The
second option in finding 7 would cover that. A test with the CREATE above would
pin it.
```suggestion
fixed.setConfString(SQLConf.ANSI_ENABLED.key,
session.ansiEnabled.toString)
fixed.setConfString(SQLConf.DECIMAL_OPERATIONS_ALLOW_PREC_LOSS.key,
session.decimalOperationsAllowPrecisionLoss.toString)
```
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/StoredExpressionText.scala:
##########
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.util
+
+import org.apache.spark.sql.catalyst.analysis.{Analyzer,
ExpressionWithUnresolvedIdentifier}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Expression, ExprId}
+import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface}
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Reads the SQL text of an expression stored with a table, such as a CHECK
condition or a
+ * generation expression, independently of the settings of the session that
reads it.
+ */
+object StoredExpressionText {
+
+ /**
+ * The settings a stored text is read under. The session time zone and case
sensitivity stay with
+ * the given session, because the settings of the DDL are not known here and
pinning them would
+ * silently change existing tables of a non-UTC or a case-sensitive
deployment.
+ */
+ def conf(session: SQLConf): SQLConf = {
+ val c = new SQLConf
+ Analyzer.retainResolutionConfigsForAnalysis(newConf = c, existingConf =
session)
+ c.setConfString(SQLConf.ANSI_ENABLED.key, "true")
+ c.setConfString(SQLConf.SESSION_LOCAL_TIMEZONE.key,
session.sessionLocalTimeZone)
+ c.setConfString(SQLConf.CASE_SENSITIVE.key,
session.caseSensitiveAnalysis.toString)
+ c
+ }
+
+ /** Parses `text` under [[conf]]. */
+ def parse(parser: ParserInterface, text: String): Expression = {
+ SQLConf.withExistingConf(conf(SQLConf.get)) {
Review Comment:
**Finding 11.** With `spark.sql.path.enabled=true`, `SET PATH` can still put
a temporary function before a built-in function of a generation expression. I
ran this with a temporary `upper` that appends `-temp`:
```sql
CREATE TABLE t (id INT, a STRING, b STRING GENERATED ALWAYS AS (upper(a)));
SET PATH = system.session, system.builtin;
INSERT INTO t (id, a) VALUES (1, 'x'); -- b = 'x-temp'
INSERT INTO t (id, a, b) VALUES (2, 'y', 'Y'); -- CHECK_CONSTRAINT_VIOLATION
```
Base does the same, and the description lists it as still depending on the
writer. But SPARK-60777 added `ResolveTableConstraints.qualifyBuiltinFunctions`
for the same problem in CHECK conditions, and it fits here too. Applying the
same rewrite to the result of `parse` covers both the auto-fill and the check.
With it, the run above stores `X` and accepts `Y`. The full
`GeneratedColumnWriteSuite` and `DataSourceV2DataFrameSuite` still pass. CREATE
already rejects user-defined functions in a generation expression, so binding
every built-in name can't break a valid table. Moving the helper into
`StoredExpressionText` would let both rules share it. The `SET PATH` line in
the description could then go.
With a SQL temporary function (`CREATE TEMPORARY FUNCTION upper(s STRING)
RETURNS STRING RETURN 'shadowed'`), the provided-value INSERT fails with "Max
iterations (100) reached for batch Resolution" instead, on base too. That is a
separate bug, which may be worth a ticket.
--
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]