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 893622534aea [SPARK-54831][SQL] Support COMMENT ON COLUMN for table
columns
893622534aea is described below
commit 893622534aea57bb64d067f41949b99645797011
Author: Ganesha Shreedhara <[email protected]>
AuthorDate: Mon Jul 20 22:26:36 2026 +0800
[SPARK-54831][SQL] Support COMMENT ON COLUMN for table columns
Add SQL support for `COMMENT ON COLUMN`, in two forms:
- `COMMENT ON COLUMN table.column IS 'comment'` - the last identifier part
is the (top-level) column and the rest is the table path.
- `COMMENT ON TABLE table COLUMN (col1 IS 'c1', col2 IS 'c2', ...)` -
comment multiple columns in one command; supports nested fields (e.g.
`point.x`) at any depth.
Both forms parse into an `AlterColumns` plan over an `UnresolvedTable`, so
a view target is cleanly rejected with `EXPECT_TABLE_NOT_VIEW`, consistent with
`COMMENT ON TABLE` and `ALTER TABLE ... ALTER COLUMN`.
A new error condition
`INVALID_SQL_SYNTAX.COMMENT_ON_COLUMN_INCORRECT_IDENTIFIER` is raised when the
single-column form is given a bare column name.
A SQL reference doc page is added for the `COMMENT ON` statement.
`COMMENT ON COLUMN` is a common DDL command for documenting columns; it was
previously unsupported in Spark SQL.
New parser tests in `DDLParserSuite` and end-to-end tests in
`DataSourceV2SQLSuite` covering V1/V2 catalogs, multiple columns, nested
fields, `IS NULL`/empty comments, view rejection, and the nested-field boundary
of the single-column form.
### What changes were proposed in this pull request?
This PR adds SQL support for `COMMENT ON COLUMN` to set, update, and remove
comments on table columns, in two forms:
- Single column:
```sql
COMMENT ON COLUMN [catalog.][database.]table.column IS { 'comment' | NULL
}
The last identifier part is the (top-level) column name; the preceding
parts
qualify the table.
- Multiple columns / nested fields, on one table:
COMMENT ON TABLE table COLUMN ( col1 IS 'c1', col2.field IS 'c2', ... )
- Each column may be a nested field path (e.g. point.x) at any depth.
IS NULL (or an empty string) clears the comment, consistent with
COMMENT ON TABLE.
Implementation notes:
- Grammar: two new #commentColumn alternatives in SqlBaseParser.g4, plus
columnCommentList / columnComment rules. No new lexer tokens are
introduced (COLUMN, COMMENT, TABLE, etc. already exist).
- AstBuilder.visitCommentColumn builds an AlterColumns plan over an
UnresolvedTable, reusing the existing ALTER TABLE ... ALTER COLUMN
resolution and execution path (ResolveFieldNameAndPosition, CheckAnalysis,
DataSourceV2Strategy). Because the target is an UnresolvedTable, a view is
rejected cleanly with EXPECT_TABLE_NOT_VIEW, matching
COMMENT ON TABLE and ALTER TABLE ... ALTER COLUMN.
- The single-column form requires at least two identifier parts; a bare
column
name raises the new error condition
INVALID_SQL_SYNTAX.COMMENT_ON_COLUMN_INCORRECT_IDENTIFIER.
- Adds a SQL reference doc page for the COMMENT ON statement.
This supersedes the earlier #53593 (closed); that version added a dedicated
CommentOnColumn plan node, whereas this reuses the existing AlterColumns
infrastructure and additionally supports the multi-column / nested-field
form.
### Why are the changes needed?
Currently, Spark only supports setting column comments via:
1. CREATE TABLE with a COMMENT clause, or
2. ALTER TABLE ... ALTER COLUMN ... COMMENT (which cannot remove a comment).
COMMENT ON COLUMN provides standard SQL syntax that:
- follows SQL-standard conventions, consistent with the existing
COMMENT ON TABLE / COMMENT ON NAMESPACE commands,
- allows explicit removal of a column comment via IS NULL, and
- supports commenting multiple columns (and nested fields) in one statement.
### Does this PR introduce _any_ user-facing change?
Yes. It adds the COMMENT ON COLUMN and COMMENT ON TABLE ... COLUMN (...) SQL
commands and a corresponding SQL reference documentation page. There is no
change
to existing behavior.
### How was this patch tested?
New unit tests:
- DDLParserSuite: parsing of both syntax forms, identifier splitting,
IS NULL / empty / literal 'NULL' comments, nested-field paths, and the
INVALID_SQL_SYNTAX.COMMENT_ON_COLUMN_INCORRECT_IDENTIFIER error.
- DataSourceV2SQLSuite: end-to-end coverage over V1 (session) and V2
(non-session) catalogs — setting/updating/removing comments, multiple
columns,
deeply nested fields, view rejection (EXPECT_TABLE_NOT_VIEW), unknown column
(UNRESOLVED_COLUMN), the parent/child same-command restriction
(NOT_SUPPORTED_CHANGE_SAME_COLUMN), and the nested-field boundary of the
single-column form.
All added tests pass locally via
build/sbt "catalyst/testOnly *DDLParserSuite" and
build/sbt "sql/testOnly *DataSourceV2SQLSuiteV1Filter".
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)
Closes #57311 from ganeshashree/SPARK-54831.
Lead-authored-by: Ganesha Shreedhara <[email protected]>
Co-authored-by: Ganesha S <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../src/main/resources/error/error-conditions.json | 5 +
docs/sql-migration-guide.md | 1 +
docs/sql-ref-syntax-ddl-comment.md | 96 ++++++
docs/sql-ref-syntax.md | 1 +
.../spark/sql/catalyst/parser/SqlBaseParser.g4 | 11 +
.../org/apache/spark/sql/types/StructField.scala | 11 +
.../spark/sql/connector/catalog/TableChange.java | 15 +-
.../spark/sql/catalyst/analysis/Analyzer.scala | 4 +-
.../catalyst/analysis/ApplyDefaultCollation.scala | 4 +-
.../sql/catalyst/analysis/CheckAnalysis.scala | 27 +-
.../sql/catalyst/analysis/TypeCoercionBase.scala | 2 +-
.../spark/sql/catalyst/parser/AstBuilder.scala | 48 +++
.../plans/logical/v2AlterTableCommands.scala | 36 +-
.../sql/connector/catalog/CatalogV2Util.scala | 6 +-
.../spark/sql/catalyst/parser/DDLParserSuite.scala | 81 +++++
.../catalyst/analysis/ResolveSessionCatalog.scala | 42 ++-
.../apache/spark/sql/execution/command/ddl.scala | 53 ++-
.../analyzer-results/change-column.sql.out | 12 +-
.../sql-tests/analyzer-results/charvarchar.sql.out | 6 +-
.../identifier-clause-legacy.sql.out | 6 +
.../analyzer-results/identifier-clause.sql.out | 8 +-
.../sql-tests/inputs/identifier-clause.sql | 1 +
.../results/identifier-clause-legacy.sql.out | 8 +
.../sql-tests/results/identifier-clause.sql.out | 8 +
.../spark/sql/connector/DataSourceV2SQLSuite.scala | 382 ++++++++++++++++++++-
.../execution/command/PlanResolutionSuite.scala | 23 +-
26 files changed, 840 insertions(+), 57 deletions(-)
diff --git a/common/utils/src/main/resources/error/error-conditions.json
b/common/utils/src/main/resources/error/error-conditions.json
index 444a4ec0aee0..6b38eb1c23e3 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -4886,6 +4886,11 @@
"ANALYZE TABLE(S) ... COMPUTE STATISTICS ... <ctx> must be either
NOSCAN or empty."
]
},
+ "COMMENT_ON_COLUMN_INCORRECT_IDENTIFIER" : {
+ "message" : [
+ "COMMENT ON COLUMN requires an identifier with at least two parts.
Please qualify the column with the table name (e.g. `table.column`,
`database.table.column`, or `catalog.database.table.column`)."
+ ]
+ },
"CREATE_FUNC_WITH_COLUMN_CONSTRAINTS" : {
"message" : [
"CREATE FUNCTION with constraints on parameters is not allowed."
diff --git a/docs/sql-migration-guide.md b/docs/sql-migration-guide.md
index fe19681d3357..2607f6d0e56b 100644
--- a/docs/sql-migration-guide.md
+++ b/docs/sql-migration-guide.md
@@ -29,6 +29,7 @@ license: |
- Since Spark 4.3, the Spark Thrift Server rejects setting JVM system
properties through the `set:system:` session configuration overlay (for
example, in a JDBC connection string). To restore the previous behavior, set
`spark.sql.legacy.hive.thriftServer.allowSettingSystemProperties` to `true`.
- Since Spark 4.3, the adaptive execution rule
`org.apache.spark.sql.execution.adaptive.DynamicJoinSelection` has been renamed
to `DemoteBroadcastHashJoin`, which now only demotes broadcast hash joins
(emitting `NO_BROADCAST_HASH`). Its selection of shuffled hash join over sort
merge join has moved to a new physical rule gated by
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled` (default
`true`). If you previously disabled the shuffled-hash-join preference by
listing `o [...]
- Since Spark 4.3, the exact `percentile`, `percentile_cont`, and `median`
aggregate functions (including their `WITHIN GROUP (ORDER BY ...)` forms)
compute the linear interpolation between two neighboring values as `lower +
fraction * (higher - lower)` instead of `(1 - fraction) * lower + fraction *
higher`. The two are equal in exact arithmetic, but the new form is
monotonically non-decreasing in the requested percentage and avoids a rounding
error the old form could introduce. As a re [...]
+- Since Spark 4.3, the new `COMMENT ON COLUMN ... IS NULL` syntax removes a
column comment by passing a `null` comment to
`TableChange.updateColumnComment(String[], String)`, so a `UpdateColumnComment`
table change may now carry a `null` `newComment()`. Previously `newComment()`
was always non-null. DataSource V2 catalogs that handle `UpdateColumnComment`
should null-check `newComment()` and treat `null` as "remove the column
comment" (mirroring how `UpdateColumnDefaultValue` already car [...]
## Upgrading from Spark SQL 4.1 to 4.2
diff --git a/docs/sql-ref-syntax-ddl-comment.md
b/docs/sql-ref-syntax-ddl-comment.md
new file mode 100644
index 000000000000..397327cb4b7f
--- /dev/null
+++ b/docs/sql-ref-syntax-ddl-comment.md
@@ -0,0 +1,96 @@
+---
+layout: global
+title: COMMENT ON
+displayTitle: COMMENT ON
+license: |
+ 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.
+---
+
+### Description
+
+The `COMMENT ON` statement sets or updates the comment on a database, a table,
or
+one or more table columns. Setting a column comment to `NULL` removes it.
+
+### Syntax
+
+```sql
+COMMENT ON { DATABASE | SCHEMA | NAMESPACE } database_name IS comment
+
+COMMENT ON TABLE table_name IS comment
+
+COMMENT ON COLUMN table_name.column_name IS comment
+
+COMMENT ON TABLE table_name COLUMN ( column_name IS comment [ , ... ] )
+```
+
+### Parameters
+
+* **database_name**
+
+ Specifies the name of the database on which the comment is set.
+
+* **table_name**
+
+ Specifies the name of the table on which the comment is set. The name may
be
+ qualified with a catalog and/or database name.
+
+* **column_name**
+
+ Specifies the column on which the comment is set. In the single-column form
+ (`COMMENT ON COLUMN`), the identifier must have at least two parts: the
last
+ part is the column name and the preceding parts qualify the table (for
example
+ `table_name.column_name` or `database_name.table_name.column_name`). This
form
+ can only reference a top-level column. To comment on a nested field, or on
+ multiple columns at once, use the `COMMENT ON TABLE table_name COLUMN (
... )`
+ form, where each `column_name` may be a nested field path such as
`col.field`.
+
+ A parent field and one of its nested fields cannot be commented in the same
+ `COMMENT ON TABLE ... COLUMN` statement.
+
+ The target of a column comment must be a table; views are not supported.
+
+* **comment**
+
+ A `STRING` literal or `NULL`. For a column, setting the comment to `NULL`
removes
+ an existing comment, while an empty string `''` sets an empty comment. For
a database
+ or table, both `NULL` and `''` set an empty comment.
+
+### Examples
+
+```sql
+-- Set a comment on a database
+COMMENT ON DATABASE some_db IS 'This is a database';
+
+-- Set a comment on a table
+COMMENT ON TABLE some_db.some_table IS 'This is a table';
+
+-- Set a comment on a single column
+COMMENT ON COLUMN some_db.some_table.some_column IS 'This is a column';
+
+-- Remove a column comment
+COMMENT ON COLUMN some_db.some_table.some_column IS NULL;
+
+-- Set comments on multiple columns, including a nested field
+COMMENT ON TABLE some_db.some_table COLUMN (
+ id IS 'The identifier',
+ address.city IS 'The city part of the address'
+);
+```
+
+### Related Statements
+
+* [ALTER TABLE](sql-ref-syntax-ddl-alter-table.html)
+* [DESCRIBE TABLE](sql-ref-syntax-aux-describe-table.html)
diff --git a/docs/sql-ref-syntax.md b/docs/sql-ref-syntax.md
index 222c507423f8..cf123f677d26 100644
--- a/docs/sql-ref-syntax.md
+++ b/docs/sql-ref-syntax.md
@@ -28,6 +28,7 @@ Data Definition Statements are used to create or modify the
structure of databas
* [ALTER DATABASE](sql-ref-syntax-ddl-alter-database.html)
* [ALTER TABLE](sql-ref-syntax-ddl-alter-table.html)
* [ALTER VIEW](sql-ref-syntax-ddl-alter-view.html)
+ * [COMMENT ON](sql-ref-syntax-ddl-comment.html)
* [CREATE DATABASE](sql-ref-syntax-ddl-create-database.html)
* [CREATE FUNCTION (External)](sql-ref-syntax-ddl-create-function.html)
* [CREATE FUNCTION (SQL)](sql-ref-syntax-ddl-create-sql-function.html)
diff --git
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
index fd0bba2f9684..e64d88cce1ec 100644
---
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
+++
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
@@ -395,6 +395,9 @@ statement
| COMMENT ON namespace identifierReference IS
comment
#commentNamespace
| COMMENT ON TABLE identifierReference IS comment
#commentTable
+ | COMMENT ON TABLE identifierReference COLUMN
+ LEFT_PAREN columns=columnCommentList RIGHT_PAREN
#commentColumn
+ | COMMENT ON COLUMN columnComment
#commentColumn
| REFRESH TABLE identifierReference
#refreshTable
| REFRESH FUNCTION identifierReference
#refreshFunction
| REFRESH (stringLit | .*?)
#refreshResource
@@ -1938,6 +1941,14 @@ alterColumnAction
| dropDefault=DROP DEFAULT
;
+columnCommentList
+ : columnComment (COMMA columnComment)*
+ ;
+
+columnComment
+ : column=multipartIdentifier IS comment
+ ;
+
// Matches exactly one string literal without coalescing or parameter markers.
// Used in type constructors where coalescing is not allowed.
singleStringLitWithoutMarker
diff --git
a/sql/api/src/main/scala/org/apache/spark/sql/types/StructField.scala
b/sql/api/src/main/scala/org/apache/spark/sql/types/StructField.scala
index 5bb12cf80e45..255830f1ece6 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/types/StructField.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/types/StructField.scala
@@ -160,6 +160,17 @@ case class StructField(
copy(metadata = newMetadata)
}
+ /**
+ * Clears the StructField of its comment, if any.
+ */
+ def clearComment(): StructField = {
+ val newMetadata = new MetadataBuilder()
+ .withMetadata(metadata)
+ .remove("comment")
+ .build()
+ copy(metadata = newMetadata)
+ }
+
/**
* Return the comment of this StructField.
*/
diff --git
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableChange.java
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableChange.java
index 2cf2414f8052..0072512136e0 100644
---
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableChange.java
+++
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableChange.java
@@ -200,10 +200,10 @@ public interface TableChange {
* If the field does not exist, the change will result in an {@link
IllegalArgumentException}.
*
* @param fieldNames field names of the column to update
- * @param newComment the new comment
+ * @param newComment the new comment, or null to remove the existing comment
* @return a TableChange for the update
*/
- static TableChange updateColumnComment(String[] fieldNames, String
newComment) {
+ static TableChange updateColumnComment(String[] fieldNames, @Nullable String
newComment) {
return new UpdateColumnComment(fieldNames, newComment);
}
@@ -693,6 +693,11 @@ public interface TableChange {
return fieldNames;
}
+ /**
+ * Returns the new comment of the column, or null to remove the existing
comment (making the
+ * comment genuinely absent, as opposed to setting it to an empty string).
+ */
+ @Nullable
public String newComment() {
return newComment;
}
@@ -703,7 +708,7 @@ public interface TableChange {
if (o == null || getClass() != o.getClass()) return false;
UpdateColumnComment that = (UpdateColumnComment) o;
return Arrays.equals(fieldNames, that.fieldNames) &&
- newComment.equals(that.newComment);
+ Objects.equals(newComment, that.newComment);
}
@Override
@@ -715,7 +720,9 @@ public interface TableChange {
@Override
public String toString() {
- return "ALTER COLUMN " + TableChange.fieldPath(fieldNames) + " COMMENT";
+ return newComment == null
+ ? "ALTER COLUMN " + TableChange.fieldPath(fieldNames) + " DROP
COMMENT"
+ : "ALTER COLUMN " + TableChange.fieldPath(fieldNames) + "
COMMENT";
}
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
index 14574916d1ac..51882a9dd983 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
@@ -4191,10 +4191,10 @@ class Analyzer(
resolved.copyTagsFrom(a)
resolved
- case a @ AlterColumns(table: ResolvedTable, specs) =>
+ case a @ AlterColumns(table: ResolvedTable, specs, _) =>
val resolvedSpecs = specs.map {
case s @ AlterColumnSpec(
- ResolvedFieldName(path, field), dataType, _, _, position, _, _)
=>
+ ResolvedFieldName(path, field), dataType, _, _, position, _, _,
_) =>
val newDataType = dataType.flatMap { dt =>
// Hive style syntax provides the column type, even if it may
not have changed.
val existing =
CharVarcharUtils.getRawType(field.metadata).getOrElse(field.dataType)
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 4485c299d218..ff43b3668839 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
@@ -267,7 +267,7 @@ object ApplyDefaultCollation extends Rule[LogicalPlan] {
case replaceCols: ReplaceColumns =>
replaceCols.copy(columnsToAdd =
replaceColumnTypes(replaceCols.columnsToAdd, collation))
- case a @ AlterColumns(ResolvedTable(_, _, _, _), specs:
Seq[AlterColumnSpec]) =>
+ case a @ AlterColumns(ResolvedTable(_, _, _, _), specs:
Seq[AlterColumnSpec], _) =>
val newSpecs = specs.map {
case spec if shouldApplyDefaultCollationToAlterColumn(spec) =>
spec.copy(newDataType =
@@ -292,7 +292,7 @@ object ApplyDefaultCollation extends Rule[LogicalPlan] {
private def pruneRedundantAlterColumnTypes(plan: LogicalPlan): LogicalPlan =
{
plan match {
case alterColumns@AlterColumns(
- ResolvedTable(_, _, _, _), specs: Seq[AlterColumnSpec]) =>
+ ResolvedTable(_, _, _, _), specs: Seq[AlterColumnSpec], _) =>
val resolvedSpecs = specs.map { spec =>
if (isAlterColumnNOP(spec)) {
spec.copy(newDataType = None)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
index ff482e940d3f..86e789b0b2b1 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
@@ -1255,27 +1255,16 @@ trait CheckAnalysis extends LookupCatalog with
QueryErrorsBase with PlanToString
case RenameColumn(table: ResolvedTable, col: ResolvedFieldName, newName)
=>
checkColumnNotExists("rename", col.path :+ newName, table.schema)
- case AlterColumns(table: ResolvedTable, specs) =>
- val groupedColumns = specs.groupBy(_.column.name)
- groupedColumns.collect {
- case (name, occurrences) if occurrences.length > 1 =>
- alter.failAnalysis(
- errorClass = "NOT_SUPPORTED_CHANGE_SAME_COLUMN",
- messageParameters = Map(
- "table" -> toSQLId(table.name),
- "fieldName" -> toSQLId(name)))
- }
- groupedColumns.keys.foreach { name =>
- if (groupedColumns.keys.exists(child => child != name &&
child.startsWith(name))) {
- alter.failAnalysis(
- errorClass = "NOT_SUPPORTED_CHANGE_SAME_COLUMN",
- messageParameters = Map(
- "table" -> toSQLId(table.name),
- "fieldName" -> toSQLId(name)))
- }
+ case AlterColumns(table: ResolvedTable, specs, _) =>
+ AlterColumns.findRepeatedColumn(specs).foreach { name =>
+ alter.failAnalysis(
+ errorClass = "NOT_SUPPORTED_CHANGE_SAME_COLUMN",
+ messageParameters = Map(
+ "table" -> toSQLId(table.name),
+ "fieldName" -> toSQLId(name)))
}
specs.foreach {
- case AlterColumnSpec(col: ResolvedFieldName, dataType, nullable, _,
_, _, _) =>
+ case AlterColumnSpec(col: ResolvedFieldName, dataType, nullable, _,
_, _, _, _) =>
val fieldName = col.name.quoted
if (dataType.isDefined) {
val field = CharVarcharUtils.getRawType(col.field.metadata)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercionBase.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercionBase.scala
index f8175e971ed0..7c83a5282910 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercionBase.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/TypeCoercionBase.scala
@@ -134,7 +134,7 @@ abstract class TypeCoercionBase extends TypeCoercionHelper {
}
addColumns.copy(columnsToAdd = newCols)
- case alterColumns @ AlterColumns(_, specs) if alterColumns.resolved &&
+ case alterColumns @ AlterColumns(_, specs, _) if alterColumns.resolved &&
specs.exists(_.newDefaultExpression.isDefined) =>
val newSpecs = specs.map { c =>
val dataType =
c.column.asInstanceOf[ResolvedFieldName].field.dataType
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
index 4f525e810b8f..fe3e7dd25d33 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
@@ -7288,6 +7288,54 @@ class AstBuilder extends DataTypeAstBuilder
CommentOnTable(createUnresolvedTable(ctx.identifierReference, "COMMENT ON
TABLE"), comment)
}
+ override def visitCommentColumn(ctx: CommentColumnContext): LogicalPlan =
withOrigin(ctx) {
+ // Each column comment is either a new comment value, or `IS NULL` which
removes the existing
+ // comment. `IS NULL` and `IS ''` are distinguished here: the former
yields None (removal), the
+ // latter Some("") (an empty comment). The `comment` grammar rule is
`stringLit | NULL`, so a
+ // missing `stringLit()` means `IS NULL`.
+ def commentOf(c: CommentContext): Option[String] =
+ Option(c.stringLit()).map(s => string(visitStringLit(s)))
+
+ def alterColumnSpec(column: Seq[String], comment: Option[String]):
AlterColumnSpec =
+ AlterColumnSpec(
+ UnresolvedFieldName(column),
+ newDataType = None,
+ newNullability = None,
+ newComment = comment,
+ newPosition = None,
+ newDefaultExpression = None,
+ dropComment = comment.isEmpty)
+
+ // Only tables are supported: downstream rules
(ResolveFieldNameAndPosition, CheckAnalysis,
+ // DataSourceV2Strategy) all expect a ResolvedTable, so a view target must
be rejected during
+ // resolution. UnresolvedTable resolves only to a table and reports
EXPECT_TABLE_NOT_VIEW
+ // otherwise, matching COMMENT ON TABLE and ALTER TABLE ... ALTER COLUMN.
+ if (ctx.columnComment != null) {
+ // COMMENT ON COLUMN table.column IS 'comment'
+ val identifiers = visitMultipartIdentifier(ctx.columnComment.column)
+ if (identifiers.size < 2) {
+ throw new
ParseException("INVALID_SQL_SYNTAX.COMMENT_ON_COLUMN_INCORRECT_IDENTIFIER", ctx)
+ }
+ val tableId = identifiers.dropRight(1)
+ val columnId = identifiers.takeRight(1)
+ val spec = alterColumnSpec(columnId,
commentOf(ctx.columnComment.comment))
+ AlterColumns(
+ UnresolvedTable(tableId, "COMMENT ON COLUMN"), Seq(spec),
fromCommentOn = true)
+ } else {
+ // COMMENT ON TABLE table COLUMN (column1 IS 'comment1', column2 IS
'comment2', ...)
+ // The table target is an identifierReference, so it also supports
IDENTIFIER(...) clauses.
+ val specs = ctx.columns.columnComment.asScala.map { c =>
+ alterColumnSpec(visitMultipartIdentifier(c.column),
commentOf(c.comment))
+ }.toSeq
+ withIdentClause(
+ ctx.identifierReference,
+ tableId =>
+ AlterColumns(
+ UnresolvedTable(tableId, "COMMENT ON TABLE ... COLUMN"), specs,
+ fromCommentOn = true))
+ }
+ }
+
override def visitComment (ctx: CommentContext): String = {
Option(ctx.stringLit()).map(s => string(visitStringLit(s))).getOrElse("")
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/v2AlterTableCommands.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/v2AlterTableCommands.scala
index 68b3573ce5ce..66d1cd6f2df2 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/v2AlterTableCommands.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/v2AlterTableCommands.scala
@@ -218,6 +218,8 @@ case class RenameColumn(
* @param newPosition new position of column if set
* @param newDefaultExpression new default expression if set
* @param dropDefault whether to drop the default expression
+ * @param dropComment whether to remove the existing comment (making it
genuinely absent, as
+ * opposed to setting it to an empty string)
*/
case class AlterColumnSpec(
column: FieldName,
@@ -226,7 +228,8 @@ case class AlterColumnSpec(
newComment: Option[String],
newPosition: Option[FieldPosition],
newDefaultExpression: Option[DefaultValueExpression],
- dropDefault: Boolean = false) extends Expression with Unevaluable {
+ dropDefault: Boolean = false,
+ dropComment: Boolean = false) extends Expression with Unevaluable {
override def children: Seq[Expression] = Seq(column) ++ newPosition.toSeq ++
newDefaultExpression.toSeq
@@ -259,10 +262,16 @@ case class AlterColumnSpec(
/**
* The logical plan of the ALTER TABLE ... ALTER COLUMN command.
+ *
+ * `fromCommentOn` records whether this plan was produced by a `COMMENT ON ...
COLUMN` statement
+ * (as opposed to `ALTER TABLE ... ALTER COLUMN`). Both syntaxes build the
same plan, but the V1
+ * session-catalog routing only extends the multi-column / nested-field
comment support to the
+ * `COMMENT ON` form, so it must be able to tell the two apart after
resolution.
*/
case class AlterColumns(
table: LogicalPlan,
- specs: Seq[AlterColumnSpec]) extends AlterTableCommand {
+ specs: Seq[AlterColumnSpec],
+ fromCommentOn: Boolean = false) extends AlterTableCommand {
override def changes: Seq[TableChange] = {
specs.flatMap { spec =>
val column = spec.column
@@ -274,8 +283,12 @@ case class AlterColumns(
val nullabilityChange = spec.newNullability.map { nullable =>
TableChange.updateColumnNullability(colName, nullable)
}
- val commentChange = spec.newComment.map { newComment =>
- TableChange.updateColumnComment(colName, newComment)
+ val commentChange = if (spec.dropComment) {
+ Some(TableChange.updateColumnComment(colName, null: String))
+ } else {
+ spec.newComment.map { newComment =>
+ TableChange.updateColumnComment(colName, newComment)
+ }
}
val positionChange = spec.newPosition.map { newPosition =>
require(newPosition.resolved,
@@ -301,6 +314,21 @@ case class AlterColumns(
copy(table = newChild)
}
+object AlterColumns {
+ /**
+ * Returns the name of the first column that is altered more than once,
either directly (the same
+ * column appears in multiple specs) or through a parent/descendant
relationship (both a field and
+ * one of its nested fields are altered). Returns None when every column is
altered at most once.
+ * Used to reject `ALTER COLUMN` / `COMMENT ON ... COLUMN` statements that
change the same column
+ * twice, which is not supported.
+ */
+ def findRepeatedColumn(specs: Seq[AlterColumnSpec]): Option[Seq[String]] = {
+ val names = specs.map(_.column.name)
+ names.find(name => names.count(_ == name) > 1)
+ .orElse(names.find(name => names.exists(other => other != name &&
other.startsWith(name))))
+ }
+}
+
/**
* The logical plan of the following commands:
* - ALTER TABLE ... CLUSTER BY (col1, col2, ...)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala
index a4b136d33e6f..c0905ede4f0d 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala
@@ -265,7 +265,11 @@ private[sql] object CatalogV2Util {
case update: UpdateColumnComment =>
replace(schema, update.fieldNames.toImmutableArraySeq, field =>
- Some(field.withComment(update.newComment)))
+ if (update.newComment != null) {
+ Some(field.withComment(update.newComment))
+ } else {
+ Some(field.clearComment())
+ })
case update: UpdateColumnPosition =>
def updateFieldPos(struct: StructType, name: String): StructType = {
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/DDLParserSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/DDLParserSuite.scala
index f8d16cb0097c..4870c118dbc7 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/DDLParserSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/DDLParserSuite.scala
@@ -3093,6 +3093,87 @@ class DDLParserSuite extends AnalysisTest {
comparePlans(
parsePlan("COMMENT ON TABLE a.b.c IS 'xYz'"),
CommentOnTable(UnresolvedTable(Seq("a", "b", "c"), "COMMENT ON TABLE"),
"xYz"))
+
+ // `comment` is the new comment value, or None for `IS NULL` (which
removes the comment).
+ def alterColumnComment(
+ tableIdent: Seq[String],
+ columnComments: Seq[(Seq[String], Option[String])],
+ commandName: String): AlterColumns = {
+ AlterColumns(
+ UnresolvedTable(tableIdent, commandName),
+ columnComments.map { case (column, comment) =>
+ AlterColumnSpec(
+ UnresolvedFieldName(column),
+ newDataType = None,
+ newNullability = None,
+ newComment = comment,
+ newPosition = None,
+ newDefaultExpression = None,
+ dropComment = comment.isEmpty)
+ },
+ fromCommentOn = true)
+ }
+
+ // COMMENT ON COLUMN: last identifier part is the column name
+ Seq(
+ ("a.b", Seq("a"), Seq("b")),
+ ("a.b.c", Seq("a", "b"), Seq("c")),
+ ("a.b.c.d", Seq("a", "b", "c"), Seq("d"))
+ ).foreach { case (columnIdentifier, tableIdent, columnIdent) =>
+ comparePlans(
+ parsePlan(s"COMMENT ON COLUMN $columnIdentifier IS 'comment'"),
+ alterColumnComment(tableIdent, Seq((columnIdent, Some("comment"))),
"COMMENT ON COLUMN"))
+ }
+
+ // COMMENT ON COLUMN: IS NULL removes the comment (dropComment), while IS
'' sets an empty one.
+ comparePlans(
+ parsePlan("COMMENT ON COLUMN a.b.c.d IS NULL"),
+ alterColumnComment(Seq("a", "b", "c"), Seq((Seq("d"), None)), "COMMENT
ON COLUMN"))
+ comparePlans(
+ parsePlan("COMMENT ON COLUMN a.b.c.d IS ''"),
+ alterColumnComment(Seq("a", "b", "c"), Seq((Seq("d"), Some(""))),
"COMMENT ON COLUMN"))
+ comparePlans(
+ parsePlan("COMMENT ON COLUMN a.b.c.d IS 'NULL'"),
+ alterColumnComment(Seq("a", "b", "c"), Seq((Seq("d"), Some("NULL"))),
"COMMENT ON COLUMN"))
+
+ // COMMENT ON COLUMN requires at least two identifier parts.
+ checkError(
+ exception = parseException("COMMENT ON COLUMN a IS 'comment'"),
+ condition = "INVALID_SQL_SYNTAX.COMMENT_ON_COLUMN_INCORRECT_IDENTIFIER",
+ parameters = Map.empty,
+ context = ExpectedContext(
+ fragment = "COMMENT ON COLUMN a IS 'comment'",
+ start = 0,
+ stop = 31))
+
+ // COMMENT ON TABLE ... COLUMN (...) supports multiple columns and nested
fields.
+ comparePlans(
+ parsePlan("COMMENT ON TABLE a.b COLUMN (c IS 'comment')"),
+ alterColumnComment(
+ Seq("a", "b"),
+ Seq((Seq("c"), Some("comment"))),
+ "COMMENT ON TABLE ... COLUMN"))
+ comparePlans(
+ parsePlan(
+ """COMMENT ON TABLE a.b COLUMN (
+ | c IS 'comment 1',
+ | c.d IS 'comment 2',
+ | c.d.e IS 'comment 3')""".stripMargin),
+ alterColumnComment(
+ Seq("a", "b"),
+ Seq(
+ (Seq("c"), Some("comment 1")),
+ (Seq("c", "d"), Some("comment 2")),
+ (Seq("c", "d", "e"), Some("comment 3"))),
+ "COMMENT ON TABLE ... COLUMN"))
+
+ // COMMENT ON TABLE ... COLUMN: IS NULL removes a column comment.
+ comparePlans(
+ parsePlan("COMMENT ON TABLE a.b COLUMN (c IS NULL, d IS 'keep')"),
+ alterColumnComment(
+ Seq("a", "b"),
+ Seq((Seq("c"), None), (Seq("d"), Some("keep"))),
+ "COMMENT ON TABLE ... COLUMN"))
}
test("create table - without using") {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveSessionCatalog.scala
b/sql/core/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveSessionCatalog.scala
index 8d483bb0c95b..2ed873427e28 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveSessionCatalog.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveSessionCatalog.scala
@@ -81,7 +81,30 @@ class ResolveSessionCatalog(val catalogManager:
CatalogManager)
case ReplaceColumns(ResolvedV1TableIdentifier(ident), _) =>
throw QueryCompilationErrors.unsupportedTableOperationError(ident,
"REPLACE COLUMNS")
- case a @ AlterColumns(ResolvedTable(catalog, ident, table: V1Table, _),
specs)
+ // Comment-only changes that touch multiple columns or a nested field (e.g.
+ // `COMMENT ON TABLE t COLUMN (a IS 'x', b IS 'y')` or `... (point.x IS
'x')`) cannot be
+ // expressed by the single-column, top-level-only
`AlterTableChangeColumnCommand`. Handle them
+ // with a dedicated V1 command that applies all specs at once via
`applySchemaChanges`.
+ // This is scoped to the `COMMENT ON ... COLUMN` syntax (`fromCommentOn`);
the equivalent
+ // `ALTER TABLE ... ALTER COLUMN` multi-column / nested-field forms still
fall through to the
+ // next case and keep their existing `UNSUPPORTED_FEATURE.TABLE_OPERATION`
error on V1.
+ case a @ AlterColumns(rt @ ResolvedTable(catalog, _, table: V1Table, _),
specs, _)
+ if a.fromCommentOn && supportsV1Command(catalog) && a.resolved &&
+ isMultiOrNestedCommentOnly(specs) =>
+ // This rule rewrites AlterColumns into a command during resolution,
before CheckAnalysis
+ // gets to validate it, so re-run the "same column changed twice" check
here (the same one
+ // CheckAnalysis applies to the AlterColumns node on the V2 path). Use
`a.failAnalysis` so the
+ // error carries the same query context as the V2 path.
+ AlterColumns.findRepeatedColumn(specs).foreach { name =>
+ a.failAnalysis(
+ errorClass = "NOT_SUPPORTED_CHANGE_SAME_COLUMN",
+ messageParameters = Map(
+ "table" -> toSQLId(rt.name),
+ "fieldName" -> toSQLId(name)))
+ }
+ AlterTableChangeColumnCommentsCommand(table.catalogTable.identifier,
a.changes)
+
+ case a @ AlterColumns(ResolvedTable(catalog, ident, table: V1Table, _),
specs, _)
if supportsV1Command(catalog) && a.resolved &&
specs.forall(_.isDefaultValueTypeCoerced) =>
if (specs.size > 1) {
throw QueryCompilationErrors.unsupportedTableOperationError(
@@ -135,7 +158,8 @@ class ResolveSessionCatalog(val catalogManager:
CatalogManager)
dataType,
nullable = true,
builder.build())
- AlterTableChangeColumnCommand(table.catalogTable.identifier, colName,
newColumn)
+ AlterTableChangeColumnCommand(
+ table.catalogTable.identifier, colName, newColumn, dropComment =
s.dropComment)
case AlterTableClusterBy(ResolvedTable(catalog, _, table: V1Table, _),
clusterBySpecOpt)
if supportsV1Command(catalog) =>
@@ -987,4 +1011,18 @@ class ResolveSessionCatalog(val catalogManager:
CatalogManager)
catalog.isInstanceOf[CatalogExtension])
}
+ // True when the ALTER COLUMN specs only change comments (set or drop, no
type / nullability /
+ // position / default changes) and, taken together, touch multiple columns
or a nested field.
+ // Such changes come from `COMMENT ON TABLE ... COLUMN (...)` and cannot be
expressed by the
+ // single-column, top-level-only V1 `AlterTableChangeColumnCommand`, so they
are handled by the
+ // dedicated `AlterTableChangeColumnCommentsCommand` instead.
+ private def isMultiOrNestedCommentOnly(specs: Seq[AlterColumnSpec]): Boolean
= {
+ val commentOnly = specs.forall { s =>
+ (s.newComment.isDefined || s.dropComment) &&
+ s.newDataType.isEmpty && s.newNullability.isEmpty &&
s.newPosition.isEmpty &&
+ s.newDefaultExpression.isEmpty && !s.dropDefault
+ }
+ commentOnly && (specs.size > 1 || specs.exists(_.column.name.length > 1))
+ }
+
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/command/ddl.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/command/ddl.scala
index fe3ec61ce0f1..01f74180f38e 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/execution/command/ddl.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/command/ddl.scala
@@ -41,7 +41,7 @@ import
org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
import
org.apache.spark.sql.catalyst.util.ResolveDefaultColumnsUtils.CURRENT_DEFAULT_COLUMN_METADATA_KEY
import org.apache.spark.sql.catalyst.util.TypeUtils.toSQLId
import org.apache.spark.sql.classic.ClassicConversions.castToImpl
-import org.apache.spark.sql.connector.catalog.{CatalogManager, CatalogV2Util,
Identifier, TableCatalog}
+import org.apache.spark.sql.connector.catalog.{CatalogManager, CatalogV2Util,
Identifier, TableCatalog, TableChange}
import
org.apache.spark.sql.connector.catalog.CatalogManager.SESSION_CATALOG_NAME
import org.apache.spark.sql.connector.catalog.SupportsNamespaces._
import org.apache.spark.sql.errors.QueryCompilationErrors
@@ -381,7 +381,8 @@ case class AlterTableUnsetPropertiesCommand(
case class AlterTableChangeColumnCommand(
tableName: TableIdentifier,
columnName: String,
- newColumn: StructField) extends LeafRunnableCommand {
+ newColumn: StructField,
+ dropComment: Boolean = false) extends LeafRunnableCommand {
// TODO: support change column name/dataType/metadata/position.
override def run(sparkSession: SparkSession): Seq[Row] = {
@@ -420,9 +421,15 @@ case class AlterTableChangeColumnCommand(
val newDataSchema = table.dataSchema.fields.map { field =>
if (field.name == originColumn.name) {
- // Create a new column from the origin column with the new type and
new comment.
- val withNewTypeAndComment: StructField =
- addComment(withNewType(field, newColumn.dataType),
newColumn.getComment())
+ // Create a new column from the origin column with the new type and
new comment. When
+ // `dropComment` is set (COMMENT ON COLUMN ... IS NULL), the comment
is removed entirely
+ // rather than kept or set to an empty string.
+ val typedField = withNewType(field, newColumn.dataType)
+ val withNewTypeAndComment: StructField = if (dropComment) {
+ typedField.clearComment()
+ } else {
+ addComment(typedField, newColumn.getComment())
+ }
// Create a new column from the origin column with the new current
default value.
// The default value is already validated by ResolveSessionCatalog, so
we just need
// to copy the CURRENT_DEFAULT metadata. Note: we preserve the
original EXISTS_DEFAULT
@@ -489,6 +496,42 @@ case class AlterTableChangeColumnCommand(
}
}
+/**
+ * A command to set or remove comments on one or more columns of a table,
including nested fields.
+ * Used for the multi-column and nested-field forms of `COMMENT ON TABLE ...
COLUMN (...)` on the
+ * session catalog, which the single-column [[AlterTableChangeColumnCommand]]
cannot express. Only
+ * `UpdateColumnComment` changes are expected here; the comments are applied
to the data schema via
+ * [[CatalogV2Util.applySchemaChanges]].
+ */
+case class AlterTableChangeColumnCommentsCommand(
+ tableName: TableIdentifier,
+ changes: Seq[TableChange]) extends LeafRunnableCommand {
+
+ override def run(sparkSession: SparkSession): Seq[Row] = {
+ val catalog = sparkSession.sessionState.catalog
+ catalog.refreshTable(tableName)
+ val table = catalog.getTableRawMetadata(tableName)
+ val resolver = sparkSession.sessionState.conf.resolver
+ DDLUtils.verifyAlterTableType(catalog, table, isView = false)
+
+ // Comments on partition columns are not supported by this path.
+ changes.foreach {
+ case c: TableChange.UpdateColumnComment =>
+ val topLevel = c.fieldNames.head
+ if (table.partitionColumnNames.exists(resolver(topLevel, _))) {
+ throw
QueryCompilationErrors.cannotAlterPartitionColumn(table.qualifiedName, topLevel)
+ }
+ case _ =>
+ }
+
+ val newDataSchema = CatalogV2Util.applySchemaChanges(
+ table.dataSchema, changes, table.provider, "ALTER TABLE")
+ catalog.alterTableDataSchema(tableName, newDataSchema)
+
+ Seq.empty[Row]
+ }
+}
+
/**
* A command that sets the serde class and/or serde properties of a table/view.
*
diff --git
a/sql/core/src/test/resources/sql-tests/analyzer-results/change-column.sql.out
b/sql/core/src/test/resources/sql-tests/analyzer-results/change-column.sql.out
index cdb2ef60aa3e..5451f358faee 100644
---
a/sql/core/src/test/resources/sql-tests/analyzer-results/change-column.sql.out
+++
b/sql/core/src/test/resources/sql-tests/analyzer-results/change-column.sql.out
@@ -128,19 +128,19 @@ DescribeTableCommand
`spark_catalog`.`default`.`test_change`, false, [col_name#x
-- !query
ALTER TABLE test_change CHANGE a COMMENT 'this is column a'
-- !query analysis
-AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, a,
StructField(a,IntegerType,true)
+AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, a,
StructField(a,IntegerType,true), false
-- !query
ALTER TABLE test_change CHANGE b COMMENT '#*02?`'
-- !query analysis
-AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, b,
StructField(b,StringType,true)
+AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, b,
StructField(b,StringType,true), false
-- !query
ALTER TABLE test_change CHANGE c COMMENT ''
-- !query analysis
-AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, c,
StructField(c,IntegerType,true)
+AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, c,
StructField(c,IntegerType,true), false
-- !query
@@ -153,13 +153,13 @@ DescribeTableCommand
`spark_catalog`.`default`.`test_change`, false, [col_name#x
-- !query
ALTER TABLE test_change CHANGE a TYPE INT
-- !query analysis
-AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, a,
StructField(a,IntegerType,true)
+AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, a,
StructField(a,IntegerType,true), false
-- !query
ALTER TABLE test_change CHANGE a COMMENT 'this is column a'
-- !query analysis
-AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, a,
StructField(a,IntegerType,true)
+AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, a,
StructField(a,IntegerType,true), false
-- !query
@@ -200,7 +200,7 @@ DescribeTableCommand
`spark_catalog`.`default`.`test_change`, false, [col_name#x
-- !query
ALTER TABLE test_change CHANGE A COMMENT 'case insensitivity'
-- !query analysis
-AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, a,
StructField(a,IntegerType,true)
+AlterTableChangeColumnCommand `spark_catalog`.`default`.`test_change`, a,
StructField(a,IntegerType,true), false
-- !query
diff --git
a/sql/core/src/test/resources/sql-tests/analyzer-results/charvarchar.sql.out
b/sql/core/src/test/resources/sql-tests/analyzer-results/charvarchar.sql.out
index 8915835fffe8..67d9a1b8a0a5 100644
--- a/sql/core/src/test/resources/sql-tests/analyzer-results/charvarchar.sql.out
+++ b/sql/core/src/test/resources/sql-tests/analyzer-results/charvarchar.sql.out
@@ -152,7 +152,7 @@ org.apache.spark.sql.AnalysisException
-- !query
alter table char_tbl1 change column c type char(5)
-- !query analysis
-AlterTableChangeColumnCommand `spark_catalog`.`default`.`char_tbl1`, c,
StructField(c,CharType(5),true)
+AlterTableChangeColumnCommand `spark_catalog`.`default`.`char_tbl1`, c,
StructField(c,CharType(5),true), false
-- !query
@@ -280,13 +280,13 @@ DescribeTableCommand
`spark_catalog`.`default`.`char_part`, true, [col_name#x, d
-- !query
alter table char_part change column c1 comment 'char comment'
-- !query analysis
-AlterTableChangeColumnCommand `spark_catalog`.`default`.`char_part`, c1,
StructField(c1,CharType(5),true)
+AlterTableChangeColumnCommand `spark_catalog`.`default`.`char_part`, c1,
StructField(c1,CharType(5),true), false
-- !query
alter table char_part change column v1 comment 'varchar comment'
-- !query analysis
-AlterTableChangeColumnCommand `spark_catalog`.`default`.`char_part`, v1,
StructField(v1,VarcharType(6),true)
+AlterTableChangeColumnCommand `spark_catalog`.`default`.`char_part`, v1,
StructField(v1,VarcharType(6),true), false
-- !query
diff --git
a/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause-legacy.sql.out
b/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause-legacy.sql.out
index 8a69bd059764..4cecec03578a 100644
---
a/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause-legacy.sql.out
+++
b/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause-legacy.sql.out
@@ -1771,6 +1771,12 @@ org.apache.spark.sql.catalyst.parser.ParseException
}
+-- !query
+COMMENT ON TABLE IDENTIFIER('test_comment') COLUMN (c1 IS 'c1 comment', c2 IS
'c2 comment')
+-- !query analysis
+AlterTableChangeColumnCommentsCommand
`spark_catalog`.`identifier_clause_test_schema`.`test_comment`, [ALTER COLUMN
c1 COMMENT, ALTER COLUMN c2 COMMENT]
+
+
-- !query
DROP TABLE test_comment
-- !query analysis
diff --git
a/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause.sql.out
b/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause.sql.out
index d0caaa5b57ae..35ceffe4ac9f 100644
---
a/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause.sql.out
+++
b/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause.sql.out
@@ -1674,7 +1674,13 @@ CommentOnTable table comment
-- !query
ALTER TABLE test_comment ALTER COLUMN IDENTIFIER('c1') COMMENT 'column comment'
-- !query analysis
-AlterTableChangeColumnCommand
`spark_catalog`.`identifier_clause_test_schema`.`test_comment`, c1,
StructField(c1,IntegerType,true)
+AlterTableChangeColumnCommand
`spark_catalog`.`identifier_clause_test_schema`.`test_comment`, c1,
StructField(c1,IntegerType,true), false
+
+
+-- !query
+COMMENT ON TABLE IDENTIFIER('test_comment') COLUMN (c1 IS 'c1 comment', c2 IS
'c2 comment')
+-- !query analysis
+AlterTableChangeColumnCommentsCommand
`spark_catalog`.`identifier_clause_test_schema`.`test_comment`, [ALTER COLUMN
c1 COMMENT, ALTER COLUMN c2 COMMENT]
-- !query
diff --git a/sql/core/src/test/resources/sql-tests/inputs/identifier-clause.sql
b/sql/core/src/test/resources/sql-tests/inputs/identifier-clause.sql
index d9bafe7cc607..1425004e9fcc 100644
--- a/sql/core/src/test/resources/sql-tests/inputs/identifier-clause.sql
+++ b/sql/core/src/test/resources/sql-tests/inputs/identifier-clause.sql
@@ -226,6 +226,7 @@ DROP TABLE test_desc;
CREATE TABLE test_comment(c1 INT, c2 STRING) USING CSV;
COMMENT ON TABLE IDENTIFIER('test_comment') IS 'table comment';
ALTER TABLE test_comment ALTER COLUMN IDENTIFIER('c1') COMMENT 'column
comment';
+COMMENT ON TABLE IDENTIFIER('test_comment') COLUMN (c1 IS 'c1 comment', c2 IS
'c2 comment');
DROP TABLE test_comment;
-- Additional identifier tests with qualified table names in various commands
diff --git
a/sql/core/src/test/resources/sql-tests/results/identifier-clause-legacy.sql.out
b/sql/core/src/test/resources/sql-tests/results/identifier-clause-legacy.sql.out
index 53a5971301cd..cc8bd806365b 100644
---
a/sql/core/src/test/resources/sql-tests/results/identifier-clause-legacy.sql.out
+++
b/sql/core/src/test/resources/sql-tests/results/identifier-clause-legacy.sql.out
@@ -1999,6 +1999,14 @@ org.apache.spark.sql.catalyst.parser.ParseException
}
+-- !query
+COMMENT ON TABLE IDENTIFIER('test_comment') COLUMN (c1 IS 'c1 comment', c2 IS
'c2 comment')
+-- !query schema
+struct<>
+-- !query output
+
+
+
-- !query
DROP TABLE test_comment
-- !query schema
diff --git
a/sql/core/src/test/resources/sql-tests/results/identifier-clause.sql.out
b/sql/core/src/test/resources/sql-tests/results/identifier-clause.sql.out
index 6537797d928a..a45c2da46ada 100644
--- a/sql/core/src/test/resources/sql-tests/results/identifier-clause.sql.out
+++ b/sql/core/src/test/resources/sql-tests/results/identifier-clause.sql.out
@@ -1861,6 +1861,14 @@ struct<>
+-- !query
+COMMENT ON TABLE IDENTIFIER('test_comment') COLUMN (c1 IS 'c1 comment', c2 IS
'c2 comment')
+-- !query schema
+struct<>
+-- !query output
+
+
+
-- !query
DROP TABLE test_comment
-- !query schema
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
index 4f855e7e453d..0896df430a42 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
@@ -50,7 +50,7 @@ import
org.apache.spark.sql.execution.streaming.runtime.MemoryStream
import org.apache.spark.sql.internal.{SQLConf, StaticSQLConf}
import org.apache.spark.sql.internal.SQLConf.{PARTITION_OVERWRITE_MODE,
PartitionOverwriteMode, V2_SESSION_CATALOG_IMPLEMENTATION}
import org.apache.spark.sql.sources.SimpleScanSource
-import org.apache.spark.sql.types.{IntegerType, LongType, StringType,
StructField, StructType}
+import org.apache.spark.sql.types.{ArrayType, CharType, IntegerType, LongType,
StringType, StructField, StructType, VarcharType}
import org.apache.spark.unsafe.types.UTF8String
abstract class DataSourceV2SQLSuite
@@ -3170,6 +3170,386 @@ class DataSourceV2SQLSuiteV1Filter
}
}
+ test("SPARK-54831: COMMENT ON COLUMN") {
+ spark.conf.set(V2_SESSION_CATALOG_IMPLEMENTATION, "builtin")
+
+ // Session catalog (V1) table.
+ withTable("t") {
+ sql("CREATE TABLE t(k int COMMENT 'original comment', v string) USING
json")
+
+ assert(columnComment("t", "k") === Some("original comment"))
+
+ // Update an existing comment.
+ sql("COMMENT ON COLUMN t.k IS 'new comment'")
+ assert(columnComment("t", "k") === Some("new comment"))
+
+ // IS NULL removes the comment entirely, so it becomes genuinely absent
(not an empty string).
+ sql("COMMENT ON COLUMN t.k IS NULL")
+ assert(columnComment("t", "k").isEmpty)
+
+ // Setting back to literal 'NULL' must keep the string value.
+ sql("COMMENT ON COLUMN t.k IS 'NULL'")
+ assert(columnComment("t", "k") === Some("NULL"))
+
+ // Empty string sets an empty comment, which is distinct from removing
it.
+ sql("COMMENT ON COLUMN t.k IS ''")
+ assert(columnComment("t", "k") === Some(""))
+
+ // Add comment to a column that didn't have one.
+ sql("COMMENT ON COLUMN t.v IS 'comment for v'")
+ assert(columnComment("t", "v") === Some("comment for v"))
+ }
+
+ // V2 non-session catalog table.
+ withTable("testcat.ns1.ns2.t") {
+ sql("CREATE TABLE testcat.ns1.ns2.t(k int COMMENT 'original', v string)
USING foo")
+ assert(columnComment("testcat.ns1.ns2.t", "k") === Some("original"))
+
+ sql("COMMENT ON COLUMN testcat.ns1.ns2.t.k IS 'updated comment'")
+ assert(columnComment("testcat.ns1.ns2.t", "k") === Some("updated
comment"))
+
+ // IS NULL removes the comment entirely.
+ sql("COMMENT ON COLUMN testcat.ns1.ns2.t.k IS NULL")
+ assert(columnComment("testcat.ns1.ns2.t", "k").isEmpty)
+
+ // IS '' sets an empty comment, which remains present.
+ sql("COMMENT ON COLUMN testcat.ns1.ns2.t.k IS ''")
+ assert(columnComment("testcat.ns1.ns2.t", "k") === Some(""))
+ }
+
+ // COMMENT ON COLUMN with only one identifier part is rejected.
+ val sql1 = "COMMENT ON COLUMN onlyone IS 'x'"
+ checkError(
+ exception = intercept[ParseException](sql(sql1)),
+ condition = "INVALID_SQL_SYNTAX.COMMENT_ON_COLUMN_INCORRECT_IDENTIFIER",
+ parameters = Map.empty,
+ context = ExpectedContext(fragment = sql1, start = 0, stop = sql1.length
- 1))
+
+ // Unknown column is reported as an UNRESOLVED_COLUMN error.
+ withTable("t2") {
+ sql("CREATE TABLE t2(k int) USING json")
+ val sql2 = "COMMENT ON COLUMN t2.nonexistent IS 'test'"
+ checkError(
+ exception = analysisException(sql2),
+ condition = "UNRESOLVED_COLUMN.WITH_SUGGESTION",
+ parameters = Map("objectName" -> "`nonexistent`", "proposal" -> "`k`"),
+ context = ExpectedContext(fragment = sql2, start = 0, stop =
sql2.length - 1))
+ }
+
+ // Different qualification levels for the session catalog: table.column and
+ // database.table.column (matching the examples in the
+ // COMMENT_ON_COLUMN_INCORRECT_IDENTIFIER error message).
+ withDatabase("comment_test_db") {
+ sql("CREATE DATABASE comment_test_db")
+ sql("USE comment_test_db")
+ withTable("comment_test_db.qualified_test") {
+ sql("CREATE TABLE qualified_test(x int, y string, z int) USING json")
+
+ // table.column
+ sql("COMMENT ON COLUMN qualified_test.x IS 'with table.column'")
+ assert(columnComment("qualified_test", "x") === Some("with
table.column"))
+
+ // database.table.column
+ sql("COMMENT ON COLUMN comment_test_db.qualified_test.y IS 'with
db.table.column'")
+ assert(columnComment("qualified_test", "y") === Some("with
db.table.column"))
+
+ // catalog.database.table.column
+ sql(
+ "COMMENT ON COLUMN spark_catalog.comment_test_db.qualified_test.z IS
" +
+ "'with catalog.db.table.column'")
+ assert(columnComment("qualified_test", "z") === Some("with
catalog.db.table.column"))
+ }
+ }
+
+ // catalog.database.table.column against a V2 non-session catalog.
+ withTable("testcat.ns1.ns2.qualified") {
+ sql("CREATE TABLE testcat.ns1.ns2.qualified(c int) USING foo")
+ sql("COMMENT ON COLUMN testcat.ns1.ns2.qualified.c IS 'v2
catalog.db.table.column'")
+ assert(columnComment("testcat.ns1.ns2.qualified", "c") === Some("v2
catalog.db.table.column"))
+ }
+
+ // A view target is rejected: COMMENT ON COLUMN only supports tables.
+ withView("v") {
+ sql("CREATE VIEW v AS SELECT 1 AS c")
+ val sql3 = "COMMENT ON COLUMN v.c IS 'test'"
+ checkError(
+ exception = analysisException(sql3),
+ condition = "EXPECT_TABLE_NOT_VIEW.NO_ALTERNATIVE",
+ parameters = Map(
+ "viewName" -> "`spark_catalog`.`default`.`v`",
+ "operation" -> "COMMENT ON COLUMN"),
+ context = ExpectedContext(fragment = sql3, start = 0, stop =
sql3.length - 1))
+ }
+
+ // The single-column `COMMENT ON COLUMN` form cannot address a nested
field: the last
+ // identifier part is always taken as the (top-level) column name and
everything before it as
+ // the table path. So `t.point.x` is parsed as table
`testcat.ns1.ns2.t.point`, column `x`,
+ // and fails to find that table. Nested fields must use the `COMMENT ON
TABLE ... COLUMN (...)`
+ // form instead (covered below).
+ withTable("testcat.ns1.ns2.nested") {
+ sql("CREATE TABLE testcat.ns1.ns2.nested(point struct<x: double, y:
double>) USING foo")
+ val sql4 = "COMMENT ON COLUMN testcat.ns1.ns2.nested.point.x IS 'x
field'"
+ checkError(
+ exception = analysisException(sql4),
+ condition = "TABLE_OR_VIEW_NOT_FOUND",
+ parameters = Map("relationName" ->
"`testcat`.`ns1`.`ns2`.`nested`.`point`"),
+ context = ExpectedContext(fragment = sql4, start = 0, stop =
sql4.length - 1))
+ }
+ }
+
+ test("SPARK-54831: COMMENT ON TABLE ... COLUMN supports multiple columns and
nested fields") {
+ spark.conf.set(V2_SESSION_CATALOG_IMPLEMENTATION, "builtin")
+
+ withTable("testcat.ns1.ns2.t") {
+ sql(
+ "CREATE TABLE testcat.ns1.ns2.t(id int, data string, " +
+ "point struct<x: double, y: double>) USING foo")
+
+ sql(
+ """COMMENT ON TABLE testcat.ns1.ns2.t COLUMN (
+ | id IS 'id col',
+ | data IS 'data col',
+ | point IS 'point col')""".stripMargin)
+
+ assert(columnComment("testcat.ns1.ns2.t", "id") === Some("id col"))
+ assert(columnComment("testcat.ns1.ns2.t", "data") === Some("data col"))
+ assert(columnComment("testcat.ns1.ns2.t", "point") === Some("point col"))
+
+ // Nested fields must be commented in a separate statement because the
analyzer rejects
+ // commenting on a parent column and its nested fields in the same
command
+ // (NOT_SUPPORTED_CHANGE_SAME_COLUMN).
+ sql(
+ """COMMENT ON TABLE testcat.ns1.ns2.t COLUMN (
+ | point.x IS 'x field',
+ | point.y IS 'y field')""".stripMargin)
+
+ val pointField = spark.table("testcat.ns1.ns2.t").schema("point")
+ val struct = pointField.dataType.asInstanceOf[StructType]
+ assert(struct("x").getComment().contains("x field"))
+ assert(struct("y").getComment().contains("y field"))
+
+ // IS NULL removes a column comment, making it genuinely absent.
+ sql("COMMENT ON TABLE testcat.ns1.ns2.t COLUMN (id IS NULL)")
+ assert(columnComment("testcat.ns1.ns2.t", "id").isEmpty)
+
+ // A nested field comment can likewise be removed with IS NULL.
+ sql("COMMENT ON TABLE testcat.ns1.ns2.t COLUMN (point.x IS NULL)")
+ val pointAfter = spark.table("testcat.ns1.ns2.t").schema("point")
+ .dataType.asInstanceOf[StructType]
+ assert(pointAfter("x").getComment().isEmpty)
+ assert(pointAfter("y").getComment().contains("y field"))
+ }
+
+ // Deeply nested fields (struct within struct) can be commented via the
COLUMN (...) form.
+ // A parent field and its descendant cannot be changed in the same command
+ // (NOT_SUPPORTED_CHANGE_SAME_COLUMN), so comment them in separate
statements.
+ withTable("testcat.ns1.ns2.deep") {
+ sql(
+ "CREATE TABLE testcat.ns1.ns2.deep(" +
+ "outer struct<inner: struct<leaf: int>>) USING foo")
+
+ sql("COMMENT ON TABLE testcat.ns1.ns2.deep COLUMN (outer.inner IS 'inner
struct')")
+ sql("COMMENT ON TABLE testcat.ns1.ns2.deep COLUMN (outer.inner.leaf IS
'leaf field')")
+
+ val outerField = spark.table("testcat.ns1.ns2.deep").schema("outer")
+ val outerStruct = outerField.dataType.asInstanceOf[StructType]
+ assert(outerStruct("inner").getComment().contains("inner struct"))
+ val innerStruct = outerStruct("inner").dataType.asInstanceOf[StructType]
+ assert(innerStruct("leaf").getComment().contains("leaf field"))
+ }
+
+ // A nested field inside an array element (`points.element.y`) can be
commented and removed,
+ // matching the reach of `ALTER TABLE ... ALTER COLUMN points.element.y
COMMENT`.
+ withTable("testcat.ns1.ns2.arr") {
+ sql(
+ "CREATE TABLE testcat.ns1.ns2.arr(" +
+ "id int, points array<struct<x: double, y: double>>) USING foo")
+
+ def yField(): StructField = {
+ val elem = spark.table("testcat.ns1.ns2.arr").schema("points")
+
.dataType.asInstanceOf[ArrayType].elementType.asInstanceOf[StructType]
+ elem("y")
+ }
+
+ sql("COMMENT ON TABLE testcat.ns1.ns2.arr COLUMN (points.element.y IS 'y
in array')")
+ assert(yField().getComment().contains("y in array"))
+
+ // IS NULL removes the array-element field comment.
+ sql("COMMENT ON TABLE testcat.ns1.ns2.arr COLUMN (points.element.y IS
NULL)")
+ assert(yField().getComment().isEmpty)
+ }
+ }
+
+ test("SPARK-54831: COMMENT ON TABLE ... COLUMN works on the session catalog
(V1 table)") {
+ spark.conf.set(V2_SESSION_CATALOG_IMPLEMENTATION, "builtin")
+
+ // Multiple top-level columns and nested fields work on an ordinary
managed table, routed
+ // through the shared V2 execution path rather than the single-column V1
command.
+ withTable("t") {
+ sql("CREATE TABLE t(id int, data string, point struct<x: double, y:
double>) USING json")
+
+ // Multiple top-level columns at once.
+ sql(
+ """COMMENT ON TABLE t COLUMN (
+ | id IS 'id col',
+ | data IS 'data col')""".stripMargin)
+ assert(columnComment("t", "id") === Some("id col"))
+ assert(columnComment("t", "data") === Some("data col"))
+
+ // Nested fields.
+ sql(
+ """COMMENT ON TABLE t COLUMN (
+ | point.x IS 'x field',
+ | point.y IS 'y field')""".stripMargin)
+ val struct =
spark.table("t").schema("point").dataType.asInstanceOf[StructType]
+ assert(struct("x").getComment().contains("x field"))
+ assert(struct("y").getComment().contains("y field"))
+
+ // A single nested field via COMMENT ON TABLE ... COLUMN.
+ sql("COMMENT ON TABLE t COLUMN (point.x IS 'updated x')")
+ val struct2 =
spark.table("t").schema("point").dataType.asInstanceOf[StructType]
+ assert(struct2("x").getComment().contains("updated x"))
+
+ // IS NULL removes comments (top-level and nested) on the session
catalog too.
+ sql("COMMENT ON TABLE t COLUMN (id IS NULL, point.y IS NULL)")
+ assert(columnComment("t", "id").isEmpty)
+ val struct3 =
spark.table("t").schema("point").dataType.asInstanceOf[StructType]
+ assert(struct3("y").getComment().isEmpty)
+
+ // The new comment is visible immediately via DESCRIBE (the relation
cache is refreshed).
+ sql("COMMENT ON TABLE t COLUMN (data IS 'fresh comment')")
+ checkAnswer(
+ sql("DESCRIBE t data").filter("info_name =
'comment'").select("info_value"),
+ Row("fresh comment"))
+
+ // The table target accepts an IDENTIFIER(...) clause, like COMMENT ON
TABLE ... IS.
+ sql("COMMENT ON TABLE IDENTIFIER('t') COLUMN (id IS 'via identifier')")
+ assert(columnComment("t", "id") === Some("via identifier"))
+ }
+
+ // char/varchar columns keep their declared type while their comment is
updated.
+ withTable("t") {
+ sql("CREATE TABLE t(c char(5), v varchar(6)) USING parquet")
+ sql("COMMENT ON TABLE t COLUMN (c IS 'char comment', v IS 'varchar
comment')")
+ assert(columnComment("t", "c") === Some("char comment"))
+ assert(columnComment("t", "v") === Some("varchar comment"))
+ val raw = spark.sessionState.catalog.getTableRawMetadata(
+ TableIdentifier("t", Some("default")))
+ assert(raw.schema("c").dataType === CharType(5))
+ assert(raw.schema("v").dataType === VarcharType(6))
+ }
+
+ // A parent field and its nested field still cannot be changed in the same
command.
+ withTable("t") {
+ sql("CREATE TABLE t(point struct<x: double, y: double>) USING json")
+ val sqlText =
+ """COMMENT ON TABLE t COLUMN (
+ | point IS 'point col',
+ | point.x IS 'x field')""".stripMargin
+ checkError(
+ exception = intercept[AnalysisException](sql(sqlText)),
+ condition = "NOT_SUPPORTED_CHANGE_SAME_COLUMN",
+ parameters = Map(
+ "table" -> "`spark_catalog`.`default`.`t`",
+ "fieldName" -> "`point`"),
+ context = ExpectedContext(fragment = sqlText, start = 0, stop =
sqlText.length - 1))
+ }
+
+ // An unknown column is still reported as UNRESOLVED_COLUMN on the session
catalog.
+ withTable("t") {
+ sql("CREATE TABLE t(id int) USING json")
+ val sqlText = "COMMENT ON TABLE t COLUMN (id IS 'x', nope IS 'y')"
+ checkError(
+ exception = analysisException(sqlText),
+ condition = "UNRESOLVED_COLUMN.WITH_SUGGESTION",
+ parameters = Map("objectName" -> "`nope`", "proposal" -> "`id`"),
+ context = ExpectedContext(fragment = sqlText, start = 0, stop =
sqlText.length - 1))
+ }
+
+ // Commenting a partition column is rejected.
+ withTable("t") {
+ sql("CREATE TABLE t(id int, p string) USING json PARTITIONED BY (p)")
+ checkError(
+ exception = intercept[AnalysisException](
+ sql("COMMENT ON TABLE t COLUMN (id IS 'id col', p IS 'part col')")),
+ condition = "CANNOT_ALTER_PARTITION_COLUMN",
+ parameters = Map(
+ "tableName" -> "`spark_catalog`.`default`.`t`",
+ "columnName" -> "`p`"))
+ }
+ }
+
+ test("SPARK-54831: session-catalog V1 comment routing is scoped to COMMENT
ON ... COLUMN") {
+ spark.conf.set(V2_SESSION_CATALOG_IMPLEMENTATION, "builtin")
+
+ // The dedicated V1 comment command only handles the `COMMENT ON ...
COLUMN` syntax. The
+ // equivalent bulk / nested-field `ALTER TABLE ... ALTER COLUMN ...
COMMENT` forms build the
+ // same `AlterColumns` plan but keep their pre-existing
UNSUPPORTED_FEATURE.TABLE_OPERATION
+ // error on the session catalog, so this PR does not silently change ALTER
TABLE behavior.
+ withTable("t") {
+ sql("CREATE TABLE t(id int, data string, point struct<x: double, y:
double>) USING json")
+
+ // Bulk ALTER COLUMN comment change (multiple columns) is still
unsupported on V1.
+ checkError(
+ exception = intercept[AnalysisException](
+ sql("ALTER TABLE t ALTER COLUMN id COMMENT 'id col', data COMMENT
'data col'")),
+ condition = "UNSUPPORTED_FEATURE.TABLE_OPERATION",
+ sqlState = "0A000",
+ parameters = Map(
+ "tableName" -> "`spark_catalog`.`default`.`t`",
+ "operation" -> "ALTER COLUMN in bulk"))
+ assert(columnComment("t", "id").isEmpty)
+ assert(columnComment("t", "data").isEmpty)
+
+ // Nested-field ALTER COLUMN comment change is still unsupported on V1.
+ checkError(
+ exception = intercept[AnalysisException](
+ sql("ALTER TABLE t ALTER COLUMN point.x COMMENT 'x field'")),
+ condition = "UNSUPPORTED_FEATURE.TABLE_OPERATION",
+ sqlState = "0A000",
+ parameters = Map(
+ "tableName" -> "`spark_catalog`.`default`.`t`",
+ "operation" -> "ALTER COLUMN with qualified column"))
+ val struct =
spark.table("t").schema("point").dataType.asInstanceOf[StructType]
+ assert(struct("x").getComment().isEmpty)
+
+ // The single-column, top-level ALTER COLUMN comment change keeps
working on V1.
+ sql("ALTER TABLE t ALTER COLUMN id COMMENT 'id col'")
+ assert(columnComment("t", "id") === Some("id col"))
+ }
+ }
+
+ test("SPARK-54831: COMMENT ON TABLE ... COLUMN cannot change parent and
child fields in the " +
+ "same command") {
+ spark.conf.set(V2_SESSION_CATALOG_IMPLEMENTATION, "builtin")
+
+ withTable("testcat.ns1.ns2.t") {
+ sql(
+ "CREATE TABLE testcat.ns1.ns2.t(id int, " +
+ "point struct<x: double, y: double>) USING foo")
+
+ val sqlText =
+ """COMMENT ON TABLE testcat.ns1.ns2.t COLUMN (
+ | point IS 'point col',
+ | point.x IS 'x field')""".stripMargin
+ checkError(
+ exception = intercept[AnalysisException](sql(sqlText)),
+ condition = "NOT_SUPPORTED_CHANGE_SAME_COLUMN",
+ parameters = Map(
+ "table" -> "`testcat`.`ns1`.`ns2`.`t`",
+ "fieldName" -> "`point`"),
+ context = ExpectedContext(fragment = sqlText, start = 0, stop =
sqlText.length - 1))
+ }
+ }
+
+ // Returns the comment of a top-level column as stored in the table schema.
Reads it from the
+ // StructField metadata (not DESCRIBE, which renders both an absent and an
empty comment as the
+ // same empty string) so callers can distinguish a removed comment (None)
from an empty one
+ // (Some("")).
+ private def columnComment(tableName: String, columnName: String):
Option[String] = {
+ spark.table(tableName).schema(columnName).getComment()
+ }
+
test("SPARK-31015: star expression should work for qualified column names
for v2 tables") {
val t = "testcat.ns1.ns2.tbl"
withTable(t) {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/PlanResolutionSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/PlanResolutionSuite.scala
index 6968a75f5eee..cdba60d9960d 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/PlanResolutionSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/PlanResolutionSuite.scala
@@ -1513,7 +1513,9 @@ class PlanResolutionSuite extends SharedSparkSession with
AnalysisTest {
None,
None,
None,
- false))) =>
+ false,
+ false)),
+ _) =>
assert(column.name == Seq("i"))
case _ => fail("expect AlterColumns")
}
@@ -1528,7 +1530,9 @@ class PlanResolutionSuite extends SharedSparkSession with
AnalysisTest {
Some("new comment"),
None,
None,
- false))) =>
+ false,
+ false)),
+ _) =>
assert(column.name == Seq("i"))
case _ => fail("expect AlterColumns")
}
@@ -1545,6 +1549,7 @@ class PlanResolutionSuite extends SharedSparkSession with
AnalysisTest {
None,
None,
None,
+ false,
false),
AlterColumnSpec(
column2: ResolvedFieldName,
@@ -1553,7 +1558,9 @@ class PlanResolutionSuite extends SharedSparkSession with
AnalysisTest {
None,
None,
Some(DefaultValueExpression(_, _, _)),
- false))) =>
+ false,
+ false)),
+ _) =>
assert(column1.name == Seq("i"))
assert(column2.name == Seq("s"))
case _ => fail("expect AlterColumns")
@@ -1641,7 +1648,9 @@ class PlanResolutionSuite extends SharedSparkSession with
AnalysisTest {
Some(comment),
None,
None,
- false))) =>
+ false,
+ false)),
+ _) =>
assert(comment == "an index")
case _ => fail("expect AlterTableAlterColumn with comment change only")
}
@@ -1656,7 +1665,9 @@ class PlanResolutionSuite extends SharedSparkSession with
AnalysisTest {
Some(comment),
None,
None,
- false))) =>
+ false,
+ false)),
+ _) =>
assert(comment == "an index")
assert(dataType == LongType)
case _ => fail("expect AlterTableAlterColumn with type and comment
changes")
@@ -1691,7 +1702,7 @@ class PlanResolutionSuite extends SharedSparkSession with
AnalysisTest {
val catalog = if (isSessionCatalog) v2SessionCatalog else testCat
val tableIdent = if (isSessionCatalog) "v2Table" else "tab"
parsed match {
- case AlterColumns(r: ResolvedTable, _) =>
+ case AlterColumns(r: ResolvedTable, _, _) =>
assert(r.catalog == catalog)
assert(r.identifier.name() == tableIdent)
case Project(_, AsDataSourceV2Relation(r)) =>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]