This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 6c5c5331bbb8 [SPARK-57412][SQL] Apply default collation to generated
column expression references
6c5c5331bbb8 is described below
commit 6c5c5331bbb8497edf3418994f67fda8b8ce4648
Author: Szehon Ho <[email protected]>
AuthorDate: Tue Jun 16 15:56:29 2026 -0700
[SPARK-57412][SQL] Apply default collation to generated column expression
references
### What changes were proposed in this pull request?
`ApplyDefaultCollation` now re-points default string/char/varchar
`AttributeReference`s inside a column's generation expression to the table's
default collation, in the same place it assigns the default collation to the
`ColumnDefinition` itself. The `ColumnDefinition` case guard is broadened so
this also runs when the generated column's own output type is not a default
string type (e.g. `c INT GENERATED ALWAYS AS (LENGTH(s))`).
### Why are the changes needed?
This is a regression from SPARK-55347, which moved generated-column
validation from the standalone `GeneratedColumnAnalyzer` (previously run at
planning time against the final, collation-applied schema) to inline analysis
plus `CheckAnalysis` / `GeneratedColumnExpression.validate`.
Generated columns are not allowed to involve non-UTF8-binary collated
string types.
**Before SPARK-55347 (worked):**
1. The generation expression was kept as a SQL string, not resolved during
analysis.
2. `ApplyDefaultCollation` applied the table's `DEFAULT COLLATION` to the
`ColumnDefinition`s.
3. Later, at planning time, the expression was parsed and resolved against
the **final** (collated) columns via `GeneratedColumnAnalyzer`.
4. The resolved `AttributeReference`s carried the real collation, so the
non-UTF8 collation check correctly rejected the table.
**After SPARK-55347 (bug):**
1. The generation expression is resolved early during analysis, so its
`AttributeReference`s capture the column types as `UTF8_BINARY`.
2. `ApplyDefaultCollation` applies the `DEFAULT COLLATION` to the
`ColumnDefinition`s, but not to those already-resolved `AttributeReference`s
inside the `GeneratedColumnExpression`.
3. `CheckAnalysis` runs `GeneratedColumnExpression.validate`, which walks
the expression tree; the `AttributeReference`s still show `UTF8_BINARY`.
4. The check passes and the table is wrongly created.
In short, the ordering flipped: before, columns were collated *then* the
expression was resolved; after, the expression is resolved *then* the columns
are collated, leaving the references stale.
Example that wrongly succeeded:
```sql
CREATE TABLE t (
c1 STRING,
c2 STRING GENERATED ALWAYS AS (SUBSTRING(c1, 0, 1))
) USING <v2source> DEFAULT COLLATION UTF8_LCASE;
```
The identical schema with `c1 STRING COLLATE UTF8_LCASE` is correctly
rejected.
### Does this PR introduce _any_ user-facing change?
Yes. `CREATE`/`REPLACE TABLE` with a generated column whose expression
involves a non-UTF8-binary collated column (via `DEFAULT COLLATION`) now
correctly fails with `UNSUPPORTED_EXPRESSION_GENERATED_COLUMN`, consistent with
the behavior for an explicit per-column `COLLATE`. This is a fix within the
unreleased master/branch that carries SPARK-55347.
### How was this patch tested?
New tests in `CollationSuite` covering:
- a generated column rejected via table-level `DEFAULT COLLATION`,
- a non-string-output generated column (`INT GENERATED ALWAYS AS
(LENGTH(c1))`) rejected for both explicit and default collation,
- a generated column allowed under `DEFAULT COLLATION UTF8_BINARY`.
Existing `CollationSuite`,
`DefaultCollation{String,CharVarchar}TestSuite{V1,V2}`,
`DataSourceV2DataFrameSuite`, and `DataSourceV2SQLSuite` generated-column /
generation-expression tests all pass.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Cursor
Closes #56465 from szehon-ho/fix-generated-column-collation.
Authored-by: Szehon Ho <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../catalyst/analysis/ApplyDefaultCollation.scala | 50 ++++++-
.../spark/sql/collation/CollationSuite.scala | 165 ++++++++++++++++++++-
2 files changed, 210 insertions(+), 5 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ApplyDefaultCollation.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ApplyDefaultCollation.scala
index ca9418a0c6ef..4485c299d218 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ApplyDefaultCollation.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ApplyDefaultCollation.scala
@@ -20,7 +20,7 @@ package org.apache.spark.sql.catalyst.analysis
import scala.util.control.NonFatal
import org.apache.spark.SparkException
-import org.apache.spark.sql.catalyst.expressions.{Cast,
DefaultStringProducingExpression, Expression, Literal, SubqueryExpression}
+import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Cast,
DefaultStringProducingExpression, Expression, Literal, SubqueryExpression}
import org.apache.spark.sql.catalyst.plans.logical.{AddColumns, AlterColumns,
AlterColumnSpec, AlterViewAs, ColumnDefinition, CreateTable,
CreateTableAsSelect, CreateTempView, CreateUserDefinedFunction, CreateView,
LogicalPlan, QualifiedColType, ReplaceColumns, ReplaceTable,
ReplaceTableAsSelect, TableSpec, V2CreateTablePlan}
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.trees.CurrentOrigin
@@ -245,6 +245,19 @@ object ApplyDefaultCollation extends Rule[LogicalPlan] {
private def transform(plan: LogicalPlan, collation: String): LogicalPlan = {
plan resolveOperators {
+ // The ResolvedIdentifier child of CreateTable/ReplaceTable exposes the
resolved table
+ // columns as output attributes. Re-point any default
string/char/varchar typed ones to the
+ // default collation so the resolved schema stays consistent with the
column definitions.
+ // Matched before the generic case below so it is not routed through
transformPlan, which
+ // does not rewrite bare output attributes.
+ case ri: ResolvedIdentifier
+ if ri.output.exists(a =>
hasDefaultStringCharOrVarcharType(a.dataType)) =>
+ ri.copy(output = ri.output.map {
+ case a if hasDefaultStringCharOrVarcharType(a.dataType) =>
+ a.withDataType(replaceDefaultStringCharAndVarcharTypes(a.dataType,
collation))
+ case a => a
+ })
+
case p if isCreateOrAlterPlan(p) ||
AnalysisContext.get.collation.isDefined =>
transformPlan(p, collation)
@@ -348,9 +361,24 @@ object ApplyDefaultCollation extends Rule[LogicalPlan] {
* collated types.
*/
private def transformExpression: PartialFunction[Expression, String =>
Expression] = {
- case columnDef: ColumnDefinition if
hasDefaultStringCharOrVarcharType(columnDef.dataType) =>
- collation => columnDef.copy(dataType =
- replaceDefaultStringCharAndVarcharTypes(columnDef.dataType, collation))
+ case columnDef: ColumnDefinition
+ if hasDefaultStringCharOrVarcharType(columnDef.dataType) ||
+ generationExpressionNeedsCollation(columnDef) =>
+ collation =>
+ // Re-point any default string/char/varchar typed references inside
the generation
+ // expression to the default collation, matching the collation applied
to the columns
+ // they reference. Other nodes (e.g. literals and casts) are handled
by the dedicated
+ // cases below as part of the bottom-up traversal.
+ val newGenExpr = columnDef.generationExpression.map { genExpr =>
+ val newChild = genExpr.child.transform {
+ case a: AttributeReference if
hasDefaultStringCharOrVarcharType(a.dataType) =>
+
a.withDataType(replaceDefaultStringCharAndVarcharTypes(a.dataType, collation))
+ }
+ if (newChild eq genExpr.child) genExpr else genExpr.copy(child =
newChild)
+ }
+ columnDef.copy(
+ dataType =
replaceDefaultStringCharAndVarcharTypes(columnDef.dataType, collation),
+ generationExpression = newGenExpr)
case cast: Cast if hasDefaultStringCharOrVarcharType(cast.dataType) &&
cast.containsTag(Cast.USER_SPECIFIED_CAST) =>
@@ -371,6 +399,20 @@ object ApplyDefaultCollation extends Rule[LogicalPlan] {
subquery.withNewPlan(newPlan)
}
+ /**
+ * Returns true if the column's generation expression references a default
string/char/varchar
+ * type.
+ */
+ private def generationExpressionNeedsCollation(columnDef: ColumnDefinition):
Boolean = {
+ columnDef.generationExpression.exists { genExpr =>
+ // Only inspect AttributeReferences, avoiding calling dataType on
unresolved nodes.
+ genExpr.child.exists {
+ case a: AttributeReference =>
hasDefaultStringCharOrVarcharType(a.dataType)
+ case _ => false
+ }
+ }
+ }
+
/**
* Casts [[DefaultStringProducingExpression]] in the plan to the `newType`.
*/
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/collation/CollationSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/collation/CollationSuite.scala
index a6f4de1c80e3..86f5add99c8e 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/collation/CollationSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/collation/CollationSuite.scala
@@ -22,8 +22,9 @@ import scala.jdk.CollectionConverters.MapHasAsJava
import com.ibm.icu.util.VersionInfo.ICU_VERSION
import org.apache.spark.SparkException
-import org.apache.spark.sql.{AnalysisException, Row}
+import org.apache.spark.sql.{AnalysisException, QueryTest, Row}
import org.apache.spark.sql.catalyst.ExtendedAnalysisException
+import org.apache.spark.sql.catalyst.analysis.ResolvedIdentifier
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.util.CollationFactory
import org.apache.spark.sql.connector.{DatasourceV2SQLBase,
FakeV2ProviderWithCustomSchema}
@@ -981,6 +982,168 @@ class CollationSuite extends DatasourceV2SQLBase with
AdaptiveSparkPlanHelper {
"generation expression cannot contain non utf8 binary collated
string type"))
}
+ test("Generated column expressions using table default collation - errors
out") {
+ // The referenced columns are declared as plain STRING (default
UTF8_BINARY), but the
+ // table-level DEFAULT COLLATION makes them effectively non-UTF8 binary
collated. The
+ // generation expression must be rejected the same way an explicit
per-column COLLATE would be.
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(
+ s"""
+ |CREATE TABLE testcat.test_table(
+ | c1 STRING,
+ | c2 STRING GENERATED ALWAYS AS (SUBSTRING(c1, 0, 1))
+ |)
+ |USING $v2Source
+ |DEFAULT COLLATION UNICODE
+ |""".stripMargin)
+ },
+ condition = "UNSUPPORTED_EXPRESSION_GENERATED_COLUMN",
+ parameters = Map(
+ "fieldName" -> "c2",
+ "expressionStr" -> "SUBSTRING(c1, 0, 1)",
+ "reason" ->
+ "generation expression cannot contain non utf8 binary collated
string type"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(
+ s"""
+ |CREATE TABLE testcat.test_table(
+ | c1 STRING,
+ | c2 STRING GENERATED ALWAYS AS (LOWER(c1))
+ |)
+ |USING $v2Source
+ |DEFAULT COLLATION UTF8_LCASE
+ |""".stripMargin)
+ },
+ condition = "UNSUPPORTED_EXPRESSION_GENERATED_COLUMN",
+ parameters = Map(
+ "fieldName" -> "c2",
+ "expressionStr" -> "LOWER(c1)",
+ "reason" ->
+ "generation expression cannot contain non utf8 binary collated
string type"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(
+ s"""
+ |CREATE TABLE testcat.test_table(
+ | struct1 STRUCT<a: STRING>,
+ | c2 STRING GENERATED ALWAYS AS (UCASE(struct1.a))
+ |)
+ |USING $v2Source
+ |DEFAULT COLLATION UNICODE
+ |""".stripMargin)
+ },
+ condition = "UNSUPPORTED_EXPRESSION_GENERATED_COLUMN",
+ parameters = Map(
+ "fieldName" -> "c2",
+ "expressionStr" -> "UCASE(struct1.a)",
+ "reason" ->
+ "generation expression cannot contain non utf8 binary collated
string type"))
+ }
+
+ test("Generated column with non-string output referencing collated column -
errors out") {
+ // The non-UTF8 collation check is structural: a generation expression
that references a
+ // collated string column is rejected even when the generated column's own
type is not a
+ // string (here INT). This must hold both for an explicit per-column
collation and for a
+ // table-level DEFAULT COLLATION.
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(
+ s"""
+ |CREATE TABLE testcat.test_table(
+ | c1 STRING COLLATE UNICODE,
+ | c2 INT GENERATED ALWAYS AS (LENGTH(c1))
+ |)
+ |USING $v2Source
+ |""".stripMargin)
+ },
+ condition = "UNSUPPORTED_EXPRESSION_GENERATED_COLUMN",
+ parameters = Map(
+ "fieldName" -> "c2",
+ "expressionStr" -> "LENGTH(c1)",
+ "reason" ->
+ "generation expression cannot contain non utf8 binary collated
string type"))
+
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(
+ s"""
+ |CREATE TABLE testcat.test_table(
+ | c1 STRING,
+ | c2 INT GENERATED ALWAYS AS (LENGTH(c1))
+ |)
+ |USING $v2Source
+ |DEFAULT COLLATION UNICODE
+ |""".stripMargin)
+ },
+ condition = "UNSUPPORTED_EXPRESSION_GENERATED_COLUMN",
+ parameters = Map(
+ "fieldName" -> "c2",
+ "expressionStr" -> "LENGTH(c1)",
+ "reason" ->
+ "generation expression cannot contain non utf8 binary collated
string type"))
+ }
+
+ test("Generated column with UTF8_BINARY default collation is allowed") {
+ withTable("testcat.test_table") {
+ sql(
+ s"""
+ |CREATE TABLE testcat.test_table(
+ | c1 STRING,
+ | c2 STRING GENERATED ALWAYS AS (SUBSTRING(c1, 0, 1))
+ |)
+ |USING $v2Source
+ |DEFAULT COLLATION UTF8_BINARY
+ |""".stripMargin)
+ }
+ }
+
+ test("Unresolved column in generated column under DEFAULT COLLATION reports
a friendly error") {
+ val query =
+ s"""
+ |CREATE TABLE testcat.test_table(
+ | c1 STRING,
+ | c2 INT GENERATED ALWAYS AS (LENGTH(c_typo))
+ |)
+ |USING $v2Source
+ |DEFAULT COLLATION UNICODE
+ |""".stripMargin
+ val start = query.indexOf("c_typo")
+ checkError(
+ exception = intercept[AnalysisException](sql(query)),
+ condition = "UNRESOLVED_COLUMN.WITH_SUGGESTION",
+ parameters = Map(
+ "objectName" -> "`c_typo`",
+ "proposal" -> "`c1`, `c2`"),
+ queryContext = Array(ExpectedContext("c_typo", start, start +
"c_typo".length - 1)))
+ }
+
+ test("CREATE TABLE with DEFAULT COLLATION correctly applies collation") {
+ val t = "testcat.ns1.tbl"
+ withTable(t) {
+ val queryExecutions = QueryTest.withQueryExecutionsCaptured(spark) {
+ sql(s"CREATE TABLE $t (c1 STRING, c2 STRING COLLATE UNICODE) " +
+ s"DEFAULT COLLATION UTF8_LCASE")
+ }
+ val resolvedIdentifier = queryExecutions.flatMap { qe =>
+ qe.analyzed.collectFirst { case ri: ResolvedIdentifier => ri }
+ }.headOption
+ assert(resolvedIdentifier.nonEmpty,
+ "Expected a ResolvedIdentifier in the analyzed CREATE TABLE plan")
+ val output = resolvedIdentifier.get.output
+ assert(output.nonEmpty,
+ "Expected the ResolvedIdentifier to expose the table columns as
output")
+
+ // c1 has no explicit collation, so it inherits the table default
collation.
+ assert(output.find(_.name ==
"c1").map(_.dataType).contains(StringType("UTF8_LCASE")))
+ // c2 has an explicit collation and must be left untouched.
+ assert(output.find(_.name ==
"c2").map(_.dataType).contains(StringType("UNICODE")))
+ }
+ }
+
test("Cast expression for collations") {
checkAnswer(
sql(s"SELECT collation(cast('a' as string collate utf8_lcase))"),
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]