dongjoon-hyun commented on code in PR #58153:
URL: https://github.com/apache/spark/pull/58153#discussion_r4164448216
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateTableExec.scala:
##########
@@ -125,6 +127,72 @@ case class ShowCreateTableExec(
}
}
+ /**
+ * True for a sort key expression the `transform` grammar rule can
represent: a column
+ * reference, or a transform with at least one argument, each a column
reference or a literal
+ * the `constant` rule can spell with the same type.
+ */
+ private def isSpellable(e: V2Expression): Boolean = e match {
+ case _: NamedReference => true
+ case t: Transform =>
+ t.arguments().nonEmpty && t.arguments().forall {
+ case _: NamedReference => true
+ case l: Literal[_] => isSpellableLiteral(l)
+ case _ => false
+ }
+ case _ => false
+ }
+
+ // Lists only the types whose rendering parses back as a `constant` of the
same type. A typed
+ // NULL, a collated string, NaN, and infinity have none, nor does a FLOAT of
maximal magnitude,
+ // whose shortest rendering exceeds the parser's range.
+ private def isSpellableLiteral(l: Literal[_]): Boolean = (l.value,
l.dataType) match {
+ case (null, dataType) => dataType == NullType
+ case (f: Float, FloatType) => java.lang.Float.isFinite(f) && math.abs(f) <
Float.MaxValue
+ case (d: Double, DoubleType) => java.lang.Double.isFinite(d)
+ case (_, s: StringType) =>
DataTypeUtils.isDefaultStringCharOrVarcharType(s)
+ case (_, BooleanType | ByteType | ShortType | IntegerType | LongType | _:
DecimalType |
+ BinaryType | DateType | TimestampType | TimestampNTZType | _:
DayTimeIntervalType |
Review Comment:
**`TIMESTAMP` literals are printed as session-local wall time without an
offset, so they can replay as a different value or type.** `Literal.sql` prints
`TIMESTAMP '<wall time in spark.sql.session.timeZone>'`. With the default confs
and `America/Los_Angeles`, `ORDERED BY f(id, TIMESTAMP '2020-11-01
01:30:00-08:00')` stores 09:30Z, but SHOW CREATE TABLE prints `TIMESTAMP
'2020-11-01 01:30:00'`. Replaying that in the same session resolves the
ambiguous fall-back time to the earlier offset (PDT), so the key silently
becomes 08:30Z. Replaying in another session time zone shifts it by the offset
difference. With `spark.sql.timestampType=TIMESTAMP_NTZ`, a key written as
`TIMESTAMP_LTZ '2020-01-01 10:00:00'` is printed as `TIMESTAMP '...'` and
re-parsed as `TIMESTAMP_NTZ`. The round-trip test passes because it replays in
the same session, outside a DST overlap, with the default timestamp type.
Could we render `TimestampType` with an explicit offset in `toSQL`
(WriteDistributionAndOrdering.scala:72), e.g. `TIMESTAMP_LTZ '<UTC wall
time>Z'`, like the FLOAT special case? That re-parses as `TimestampType` in
both timestamp modes.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateTableExec.scala:
##########
@@ -125,6 +127,72 @@ case class ShowCreateTableExec(
}
}
+ /**
+ * True for a sort key expression the `transform` grammar rule can
represent: a column
+ * reference, or a transform with at least one argument, each a column
reference or a literal
+ * the `constant` rule can spell with the same type.
+ */
+ private def isSpellable(e: V2Expression): Boolean = e match {
+ case _: NamedReference => true
+ case t: Transform =>
+ t.arguments().nonEmpty && t.arguments().forall {
+ case _: NamedReference => true
+ case l: Literal[_] => isSpellableLiteral(l)
+ case _ => false
+ }
+ case _ => false
+ }
+
+ // Lists only the types whose rendering parses back as a `constant` of the
same type. A typed
+ // NULL, a collated string, NaN, and infinity have none, nor does a FLOAT of
maximal magnitude,
+ // whose shortest rendering exceeds the parser's range.
+ private def isSpellableLiteral(l: Literal[_]): Boolean = (l.value,
l.dataType) match {
+ case (null, dataType) => dataType == NullType
+ case (f: Float, FloatType) => java.lang.Float.isFinite(f) && math.abs(f) <
Float.MaxValue
+ case (d: Double, DoubleType) => java.lang.Double.isFinite(d)
+ case (_, s: StringType) =>
DataTypeUtils.isDefaultStringCharOrVarcharType(s)
+ case (_, BooleanType | ByteType | ShortType | IntegerType | LongType | _:
DecimalType |
Review Comment:
**`true`, `false` and `NULL` do not re-parse as constants inside a
transform, so `BooleanType` and `NullType` do not belong in this list.** In
`transformArgument : qualifiedName | constant`, these non-reserved keywords
match both alternatives, and ANTLR resolves the ambiguity to the first one,
`qualifiedName`. (`primaryExpression` lists `constant` before `identifier`,
which is why `SELECT true` is a literal.) If a connector reports RANGE with the
key `f(id, literal(true))`, SHOW CREATE TABLE prints `ORDERED BY (f(id, true)
ASC NULLS FIRST)`, and replaying it reads `true` as a column: the statement
fails with `WRITE_ORDERING_WITH_UNKNOWN_COLUMN`, or binds to a column named
`true`. `TRUE` is also in `ansiNonReserved`, so this happens in every mode.
`FALSE` and `NULL` behave the same unless
`spark.sql.ansi.enforceReservedKeywords` is true, so a table created with
`ORDERED BY f(id, NULL)` in an enforcing session does not replay in a default
one.
Could we drop `BooleanType` here and change L150 to `case (null, _) =>
false`? Deleting L150 would let a typed NULL fall through to the type list. The
comment at L146 and the `constant` in the new docs could mention this too.
##########
common/utils/src/main/resources/error/error-conditions.json:
##########
@@ -7339,6 +7339,13 @@
],
"sqlState" : "42908"
},
+ "SPECIFY_DISTRIBUTED_BY_PARTITION_WITHOUT_PARTITIONING_IS_NOT_ALLOWED" : {
+ "message" : [
+ "Cannot specify DISTRIBUTED BY PARTITION for a table that has no
partitioning.",
+ "Please add PARTITIONED BY or CLUSTERED BY ... INTO ... BUCKETS, or drop
the clause. A statement that defines no schema (no column list, no typed
partition columns, and no AS SELECT) cannot declare partitioning, so it cannot
use DISTRIBUTED BY PARTITION."
Review Comment:
**For a `CLUSTER BY` table, this advice cannot be followed either.** `CREATE
TABLE t (id INT, c STRING) USING foo CLUSTER BY (c) DISTRIBUTED BY PARTITION`,
pinned at CreateTableWriteOrderSuite.scala:343, gets this message. Adding
`PARTITIONED BY (id)` as suggested then fails with
`SPECIFY_CLUSTER_BY_WITH_PARTITIONED_BY_IS_NOT_ALLOWED`, and adding `CLUSTERED
BY (id) INTO 4 BUCKETS` fails with
`SPECIFY_CLUSTER_BY_WITH_BUCKETING_IS_NOT_ALLOWED`, because those checks
(AstBuilder.scala:6290-6296) run before `writeSpecsFrom`. Only the SQL
reference explains that `CLUSTER BY` has to be replaced. This is the `CLUSTER
BY` counterpart of point 8.
Could `writeSpecsFrom` raise a dedicated condition when `ctx.clusterBySpec`
is present, e.g.
`SPECIFY_CLUSTER_BY_WITH_DISTRIBUTED_BY_PARTITION_IS_NOT_ALLOWED` next to the
existing `SPECIFY_CLUSTER_BY_WITH_*` conditions, or at least add a sentence
about `CLUSTER BY` here, as for the schemaless case? Either way the rejection
stays at parse time, so HASH still always comes with non-empty `partitions()`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateTableExec.scala:
##########
@@ -125,6 +127,72 @@ case class ShowCreateTableExec(
}
}
+ /**
+ * True for a sort key expression the `transform` grammar rule can
represent: a column
+ * reference, or a transform with at least one argument, each a column
reference or a literal
+ * the `constant` rule can spell with the same type.
+ */
+ private def isSpellable(e: V2Expression): Boolean = e match {
+ case _: NamedReference => true
+ case t: Transform =>
+ t.arguments().nonEmpty && t.arguments().forall {
+ case _: NamedReference => true
+ case l: Literal[_] => isSpellableLiteral(l)
+ case _ => false
+ }
+ case _ => false
+ }
+
+ // Lists only the types whose rendering parses back as a `constant` of the
same type. A typed
+ // NULL, a collated string, NaN, and infinity have none, nor does a FLOAT of
maximal magnitude,
+ // whose shortest rendering exceeds the parser's range.
+ private def isSpellableLiteral(l: Literal[_]): Boolean = (l.value,
l.dataType) match {
+ case (null, dataType) => dataType == NullType
+ case (f: Float, FloatType) => java.lang.Float.isFinite(f) && math.abs(f) <
Float.MaxValue
+ case (d: Double, DoubleType) => java.lang.Double.isFinite(d)
+ case (_, s: StringType) =>
DataTypeUtils.isDefaultStringCharOrVarcharType(s)
+ case (_, BooleanType | ByteType | ShortType | IntegerType | LongType | _:
DecimalType |
+ BinaryType | DateType | TimestampType | TimestampNTZType | _:
DayTimeIntervalType |
+ _: YearMonthIntervalType) => true
+ case _ => false
+ }
+
+ /**
+ * Emits the table's declared write distribution and ordering as clauses.
Pairs with no clause
+ * form, such as `HASH` without partitioning or an unspellable sort key, are
omitted.
+ */
+ private def showTableWriteDistributionAndOrdering(
+ table: Table,
+ builder: StringBuilder): Unit = {
+ if (table.writeOrdering().forall(o => isSpellable(o.expression()))) {
+ val orderBy = if (table.writeOrdering().nonEmpty) {
+ Some(table.writeOrdering()
+ .map(WriteDistributionAndOrdering.describeSortOrder)
+ .mkString("ORDERED BY (", ", ", ")"))
+ } else {
+ None
+ }
+ // Bucketing counts as partitioning here; CLUSTER BY does not.
+ val hasPartitioning = table.partitioning.exists {
+ case ClusterByTransform(_) => false
Review Comment:
**This extractor-based check introduced two problems in this commit.**
(a) `ClusterByTransform.unapply` casts every argument with
`arguments.map(_.asInstanceOf[NamedReference])` (expressions.scala:187), and
`hasPartitioning` is a strict `val` evaluated for every table whose ordering
passes L167, including tables that declare nothing, since an empty ordering
passes `forall`. So SHOW CREATE TABLE now throws a raw `ClassCastException` for
a table whose first partition transform is a `cluster_by` with a literal
argument, e.g. a connector-reported `Expressions.apply("cluster_by",
Expressions.literal(4))`. Master and d2aa8674fdc printed `PARTITIONED BY
(cluster_by(4))` there.
(b) The parser still counts such a transform as partitioning. `PARTITIONED
BY (cluster_by(a)) DISTRIBUTED BY PARTITION` becomes
`ApplyTransform("cluster_by", [a])`, passes the check at AstBuilder.scala:6320
and sends HASH, and a `DelegatingTable`-based catalog (like
`GenericClusterByTableCatalog` in the new suite) reports both back. SHOW CREATE
TABLE then prints `PARTITIONED BY (cluster_by(a))` but treats it as clustering
here and omits `DISTRIBUTED BY PARTITION`, so a replay silently loses HASH. The
notation is not exotic: v2 SHOW CREATE TABLE already prints every `CLUSTER BY`
table as `PARTITIONED BY (cluster_by(c))`.
Could we share one name-based predicate without a cast between
`writeSpecsFrom` and this method, e.g.
`WriteDistributionAndOrdering.hasPartitioning(p) = p.exists(_.name !=
"cluster_by")`, and evaluate it only in the HASH cases? That keeps szehon-ho's
request that a connector's generic `cluster_by` counts as clustering.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateTableExec.scala:
##########
@@ -125,6 +127,72 @@ case class ShowCreateTableExec(
}
}
+ /**
+ * True for a sort key expression the `transform` grammar rule can
represent: a column
+ * reference, or a transform with at least one argument, each a column
reference or a literal
+ * the `constant` rule can spell with the same type.
+ */
+ private def isSpellable(e: V2Expression): Boolean = e match {
+ case _: NamedReference => true
+ case t: Transform =>
+ t.arguments().nonEmpty && t.arguments().forall {
+ case _: NamedReference => true
+ case l: Literal[_] => isSpellableLiteral(l)
+ case _ => false
+ }
+ case _ => false
+ }
+
+ // Lists only the types whose rendering parses back as a `constant` of the
same type. A typed
+ // NULL, a collated string, NaN, and infinity have none, nor does a FLOAT of
maximal magnitude,
+ // whose shortest rendering exceeds the parser's range.
+ private def isSpellableLiteral(l: Literal[_]): Boolean = (l.value,
l.dataType) match {
+ case (null, dataType) => dataType == NullType
+ case (f: Float, FloatType) => java.lang.Float.isFinite(f) && math.abs(f) <
Float.MaxValue
+ case (d: Double, DoubleType) => java.lang.Double.isFinite(d)
+ case (_, s: StringType) =>
DataTypeUtils.isDefaultStringCharOrVarcharType(s)
+ case (_, BooleanType | ByteType | ShortType | IntegerType | LongType | _:
DecimalType |
+ BinaryType | DateType | TimestampType | TimestampNTZType | _:
DayTimeIntervalType |
+ _: YearMonthIntervalType) => true
Review Comment:
**`TimeType` is missing from this list, so a `TIME` literal in a sort key
drops every write clause from SHOW CREATE TABLE.** The parser accepts `ORDERED
BY f(id, TIME '12:00:00')` without any flag (`spark.sql.timeType.enabled` gates
schemas and casts, not literals), and `Literal.sql` spells it back as `TIME
'12:00:00'`, which re-parses as `TIME(6)`. But the key falls to `case _ =>
false` at L157, the `forall` at L167 fails, and `ORDERED BY` (or `LOCALLY
ORDERED BY`, or `DISTRIBUTED BY PARTITION ...`) disappears from the output.
Replaying it creates a table without the declared layout, which is the silent
drop from point 7. This is a regression in this commit: d2aa8674fdc accepted
every `Literal` and printed the clause. The nanosecond timestamp types (preview
flag) and the legacy `CalendarIntervalType` are dropped the same way.
Could we add `case (_, TimeType(TimeType.DEFAULT_PRECISION)) => true`, and
add `TIME '12:00:00'` to the round-trip test at
CreateTableWriteOrderSuite.scala:627? Precision 7-9 does not round-trip, since
`TIME '12:00:00.1000000'` prints as `TIME '12:00:00.1'` and re-parses as
`TIME(6)`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateTableExec.scala:
##########
@@ -125,6 +127,72 @@ case class ShowCreateTableExec(
}
}
+ /**
+ * True for a sort key expression the `transform` grammar rule can
represent: a column
+ * reference, or a transform with at least one argument, each a column
reference or a literal
+ * the `constant` rule can spell with the same type.
+ */
+ private def isSpellable(e: V2Expression): Boolean = e match {
+ case _: NamedReference => true
+ case t: Transform =>
+ t.arguments().nonEmpty && t.arguments().forall {
+ case _: NamedReference => true
+ case l: Literal[_] => isSpellableLiteral(l)
+ case _ => false
+ }
+ case _ => false
+ }
+
+ // Lists only the types whose rendering parses back as a `constant` of the
same type. A typed
Review Comment:
Minor: the "same type" claim also fails for some connector-reported
literals. A `DECIMAL(10,2)` value 1.50 prints as `1.50BD` and comes back as
`DECIMAL(3,2)`, and 0.0000001 at `DECIMAL(10,7)` prints as `1E-7BD` and comes
back as `DECIMAL(7,7)`. An uncollated `CHAR(n)` or `VARCHAR(n)` literal passes
`isDefaultStringCharOrVarcharType` at L153 and comes back as `STRING`. Under
`spark.sql.legacy.interval.enabled=true` the ANSI intervals come back as
`CalendarIntervalType`, and under `spark.sql.parser.escapedStringLiterals=true`
every backslash doubles on replay. Also, the `Float.MaxValue` exclusion at L151
exists only because `toSQL` renders `<v>F`
(WriteDistributionAndOrdering.scala:72), and both are private methods in
different modules, so neither can be unit-tested.
Could we narrow `_: DecimalType` to `case (d: Decimal, t: DecimalType) =>
Literal(d.toJavaBigDecimal).dataType == t` (Catalyst `Literal`) and the string
case to `s eq StringType`? Moving this allow-list next to `toSQL` (which fits
SPARK-59946) would also let a table-driven test over connector-reported keys
assert "emitted iff the replayed statement declares the same keys". Comparing
through Catalyst `Literal` handles `Array[Byte]` for BINARY.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/WriteDistributionAndOrdering.scala:
##########
@@ -0,0 +1,80 @@
+/*
+ * 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.expressions.{Literal => CatalystLiteral}
+import org.apache.spark.sql.connector.catalog.{Identifier, TableCatalog,
TableCatalogCapability, WriteDistributionMode}
+import org.apache.spark.sql.connector.expressions.{Expression,
IdentityTransform, Literal, SortOrder, Transform}
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.types.FloatType
+
+/**
+ * Utility methods for the create-time write distribution and ordering
requested by
+ * `CREATE`/`REPLACE TABLE ... DISTRIBUTED BY PARTITION / [LOCALLY] ORDERED
BY`.
+ */
+object WriteDistributionAndOrdering {
+
+ /**
+ * True when a CREATE/REPLACE TABLE statement asked for a write distribution
or ordering.
+ * `UNORDERED` counts as a request.
+ */
+ def isRequested(
+ writeDistributionMode: WriteDistributionMode,
+ writeOrdering: Seq[SortOrder]): Boolean = {
+ writeDistributionMode != null || writeOrdering.nonEmpty
+ }
+
+ /**
+ * Rejects a create-time write distribution or ordering that the catalog has
not advertised
+ * support for, before anything is created or dropped.
+ */
+ def validateCatalogForWriteDistributionAndOrdering(
+ catalog: TableCatalog,
+ ident: Identifier,
+ operation: String,
+ writeDistributionMode: WriteDistributionMode,
+ writeOrdering: Seq[SortOrder]): Unit = {
+ if (isRequested(writeDistributionMode, writeOrdering) &&
+ !catalog.capabilities().contains(
+
TableCatalogCapability.SUPPORTS_CREATE_TABLE_WITH_WRITE_DISTRIBUTION_AND_ORDERING))
{
+ throw QueryCompilationErrors.unsupportedTableOperationError(
+ catalog, ident, s"$operation ... DISTRIBUTED BY/ORDERED BY/UNORDERED")
+ }
+ }
+
+ /** Renders a requested sort key as SQL, for SHOW CREATE TABLE and DESCRIBE.
*/
+ def describeSortOrder(sortOrder: SortOrder): String = {
+ s"${toSQL(sortOrder.expression())} ${sortOrder.direction()}
${sortOrder.nullOrdering()}"
+ }
+
+ // `describe` prints a literal's internal value, e.g. `0` for DATE
'1970-01-01', so literals are
+ // rendered through Catalyst to keep their type. Catalyst renders a FLOAT as
a CAST, which the
+ // parser does not accept here, so a finite FLOAT is rendered as `<v>F`
instead. PARTITIONED BY
+ // in SHOW CREATE TABLE and the `Part N` rows of DESCRIBE render transforms
with `describe`, so
+ // the same transform can print differently there.
+ private def toSQL(e: Expression): String = e match {
+ case l: Literal[_] => (l.value, l.dataType) match {
+ case (f: Float, FloatType) if java.lang.Float.isFinite(f) => s"${f}F"
+ case (value, dataType) => CatalystLiteral.create(value, dataType).sql
Review Comment:
**`toSQL` still trusts connector-reported keys in two ways, so DESCRIBE
TABLE EXTENDED can fail entirely.**
(a) `CatalystLiteral.create` throws for some values that point 2's fix does
not cover. The public factory `Expressions.literal(new
java.math.BigDecimal("1E+3"))` creates `LiteralValue(1E+3, DecimalType(4,0))`,
since `Decimal.set` normalizes the negative scale, but `create` builds
`DecimalType(1, -3)` from the raw value and throws `NEGATIVE_SCALE_DISALLOWED`.
A value whose class does not match its type (`Integer` with `LongType`,
`ByteBuffer` with `BinaryType`) throws `IllegalArgumentException` from
`validateLiteralValue`.
`DescribeTableExec.addTableWriteDistributionAndOrdering` has no guard, and
`isSpellableLiteral` checks only the type tag, so SHOW CREATE TABLE throws
instead of omitting the pair.
(b) References are rendered with `describe()` (L75 and L78), whose default
is `toString()`, and only Spark's `FieldReference` quotes there. A connector's
own `NamedReference`, like the `MyNamedReference` classes in Spark's test
connectors, prints `...$MyNamedReference@1a2b` or an unquoted `order-id`, and
the DDL does not parse. PARTITIONED BY has the same pre-existing issue.
Could we wrap the conversion in `NonFatal`, falling back to `l.describe` for
DESCRIBE and treating the key as unspellable for SHOW CREATE TABLE, and render
references as `r.fieldNames.map(quoteIfNeeded).mkString(".")`? The latter
prints exactly what `FieldReference` prints and follows the same principle as
point 7.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/PlanResolutionSuite.scala:
##########
@@ -3692,6 +3693,236 @@ class PlanResolutionSuite extends SharedSparkSession
with AnalysisTest {
}
}
+ test("SPARK-34586: v2 table creation (global writeOrdering)") {
Review Comment:
Nit: these six tests (about 230 lines) copy assertions from "Test v2
CreateTable with default catalog" (L620) and "Test v2 CTAS with known catalog
in identifier" (L683) that are unrelated to this feature: catalog name, schema,
properties and `ignoreIfExists`. `parseAndResolve` (L291) runs neither
`PreprocessTableCreation` nor `checkAnalysis` by default, so these tests only
re-pin parser output that the parse tests at
CreateTableWriteOrderSuite.scala:80-190 already pin, and they can break for
reasons unrelated to the write clauses.
Could we drop them, or fold them into one table-driven test over `(sql,
mode, ordering)` that asserts only the write spec?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateTableLikeExec.scala:
##########
@@ -46,6 +46,10 @@ import org.apache.spark.sql.errors.QueryCompilationErrors
* ROW FORMAT / STORED AS converted to hive properties (if given), and
* [[TableCatalog.PROP_OWNER]] set to the current user. Source table
properties are intentionally
* excluded so that connectors can decide which custom properties to clone via
[[sourceTable]].
+ *
+ * The source's declared write distribution and ordering are likewise not
copied onto the
Review Comment:
Minor: this caveat is only in the exec's scaladoc, while connector authors
read the public `TableCatalog.createTableLike` Javadoc. That Javadoc still says
`tableInfo` contains "columns and partitioning copied from the source, any
constraints copied from the source, ..." (TableCatalog.java:368-371) and calls
it the "complete description of the new table: columns, partitioning,
constraints, ..." (TableCatalog.java:379-381). A connector written against that
contract expects `tableInfo.writeOrdering()` to carry the source's layout like
the constraints, but it is always empty, so `CREATE TABLE ... LIKE` silently
creates the target without it unless the connector reads `sourceTable`. The
`TableInfo` class Javadoc (TableInfo.java:28) also lists only "columns,
properties, partitioning and constraints".
Could we add the same sentence to those Javadocs? That would complete
peter-toth's finding 9.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala:
##########
@@ -6292,6 +6300,85 @@ class AstBuilder extends DataTypeAstBuilder
collation, serdeInfo, clusterBySpec)
}
+ /**
+ * Parse the optional write distribution and ordering clauses of a
CREATE/REPLACE TABLE statement:
+ * `DISTRIBUTED BY PARTITION` and `[LOCALLY] ORDERED BY ... | UNORDERED`.
+ *
+ * Returns the distribution mode and the ordering. The mode is null when the
statement did not ask
+ * for one, which leaves the choice to the catalog; `NONE` is an explicit
request.
+ * `DISTRIBUTED BY PARTITION` is rejected when `partitionTransforms` is
empty.
+ */
+ private def writeSpecsFrom(
+ ctx: CreateTableClausesContext,
+ partitionTransforms: Seq[Transform]): (WriteDistributionMode,
Seq[V2SortOrder]) = {
+ checkDuplicateClauses(ctx.writeDistributionSpec, "DISTRIBUTED BY
PARTITION", ctx)
+ checkDuplicateClauses(ctx.writeOrderingSpec, "ORDERED BY/UNORDERED", ctx)
+
+ val distributionSpec = ctx.writeDistributionSpec.asScala.headOption.orNull
+ val orderingSpec = ctx.writeOrderingSpec.asScala.headOption.orNull
+ // Bucketing counts as partitioning here; CLUSTER BY does not.
+ if (distributionSpec != null && partitionTransforms.isEmpty) {
+ throw
QueryParsingErrors.distributedByPartitionWithoutPartitioningError(ctx)
+ }
+ if (distributionSpec == null && orderingSpec == null) {
+ (null, Seq.empty)
+ } else {
+ (toDistributionMode(distributionSpec, orderingSpec),
toOrdering(orderingSpec))
+ }
+ }
+
+ /**
+ * Derive the write distribution mode from how the ordering was asked for.
An explicit
+ * `DISTRIBUTED BY PARTITION` wins, which leaves `LOCALLY` with no effect
beside it.
+ */
+ private def toDistributionMode(
+ distributionSpec: WriteDistributionSpecContext,
+ orderingSpec: WriteOrderingSpecContext): WriteDistributionMode = {
+ // Only called when at least one of the two clauses is present, so
orderingSpec is non-null
+ // here whenever distributionSpec is null.
+ if (distributionSpec != null) {
+ WriteDistributionMode.HASH
+ } else if (orderingSpec.UNORDERED != null || orderingSpec.LOCALLY != null)
{
+ WriteDistributionMode.NONE
+ } else {
+ WriteDistributionMode.RANGE
+ }
+ }
+
+ private def toOrdering(orderingSpec: WriteOrderingSpecContext):
Seq[V2SortOrder] = {
+ if (orderingSpec != null && orderingSpec.writeOrder != null) {
+ orderingSpec.writeOrder.fields.asScala.map(visitWriteOrderField).toSeq
+ } else {
+ Seq.empty
+ }
+ }
+
+ /**
+ * Create a v2 sort order for one field of a write ordering.
+ */
+ override def visitWriteOrderField(ctx: WriteOrderFieldContext): V2SortOrder
= withOrigin(ctx) {
+ val direction = if (ctx.DESC != null) {
+ SortDirection.DESCENDING
+ } else {
+ SortDirection.ASCENDING
+ }
+
+ val nullOrdering = if (ctx.FIRST != null) {
+ NullOrdering.NULLS_FIRST
+ } else if (ctx.LAST != null) {
+ NullOrdering.NULLS_LAST
+ } else {
+ direction.defaultNullOrdering
+ }
+
+ // A plain column is passed as a bare reference rather than as
`identity(col)`.
+ val key = visitTransform(ctx.transform) match {
Review Comment:
Minor, pre-existing but now reachable from ORDERED BY: `constant` in
`transformArgument` also matches a parameter marker, but
`visitTransformArgument` (L5913-5915) assumes a `Literal` and calls
`lit.value`. Without args, `spark.sql("CREATE TABLE cat.t (id INT) USING foo
ORDERED BY bucket(?, id)")` skips parameter substitution,
`visitPosParameterLiteral` returns a `PosParameter`, and parsing fails with a
raw `ClassCastException` (`PosParameter cannot be cast to Literal`) instead of
`UNBOUND_SQL_PARAMETER`. `PARTITIONED BY (bucket(?, id))` already fails the
same way, so I'm fine with a follow-up next to the `bucket(4294967300, id)` and
`IDENTIFIER('id')` items from my previous comment.
Could `visitTransformArgument` raise a parse error when the visited constant
is not a `Literal`?
##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableInfo.java:
##########
@@ -82,6 +120,27 @@ public Builder withConstraints(Constraint[] constraints) {
return this;
}
+ /**
+ * Sets the requested write distribution. See {@link
TableInfo#writeDistributionMode()}.
+ *
+ * @since 4.4.0
+ */
+ public Builder withWriteDistributionMode(WriteDistributionMode
writeDistributionMode) {
+ this.writeDistributionMode = writeDistributionMode;
+ return this;
+ }
+
+ /**
+ * Sets the requested write ordering, which must not be null. See
+ * {@link TableInfo#writeOrdering()}.
+ *
+ * @since 4.4.0
+ */
+ public Builder withWriteOrdering(SortOrder[] writeOrdering) {
Review Comment:
Nit: the Javadoc says this must not be null and that `writeOrdering()` is
never null, but neither this setter nor `build()` (L156) enforces it. A
connector that passes null and returns `new DelegatingTable(info, name)` gets
an NPE in DESCRIBE TABLE EXTENDED (DescribeTableExec.scala:232) or SHOW CREATE
TABLE (ShowCreateTableExec.scala:167) instead of at `build()`. `withPartitions`
and `withConstraints` are unchecked too, but only this field documents "never
null".
Could `build()` add `Objects.requireNonNull(writeOrdering, ...)` next to the
`columns` check?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateTableExec.scala:
##########
@@ -125,6 +127,72 @@ case class ShowCreateTableExec(
}
}
+ /**
+ * True for a sort key expression the `transform` grammar rule can
represent: a column
+ * reference, or a transform with at least one argument, each a column
reference or a literal
+ * the `constant` rule can spell with the same type.
+ */
+ private def isSpellable(e: V2Expression): Boolean = e match {
+ case _: NamedReference => true
+ case t: Transform =>
Review Comment:
Minor: `isSpellable` checks only the argument kinds, but `visitTransform`
dispatches the exact names `bucket`, `years`, `months`, `days` and `hours`
(AstBuilder.scala:5869-5899) and rejects any other argument shape for them. The
parser's `PostProcessor` strips backticks before that, so quoting cannot escape
the dispatch. A connector key such as `Expressions.apply("bucket",
column("id"), literal(16))` (Trino-style argument order) prints `ORDERED BY
(bucket(id, 16) ASC NULLS FIRST)`, and replaying it fails for the whole
statement with `_LEGACY_ERROR_TEMP_0031`; `apply("days", ts, tz)` fails with
`TRANSFORM_WRONG_NUM_ARGS`. This is the unrunnable-DDL case from peter-toth's
finding 2.
Could we require the parser's shape for these five names here (a Short, Int
or Long literal followed by references for `bucket`, a single reference for the
others), and add such keys to `ORDERING_OVERRIDE`?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/CreateTableWriteOrderSuite.scala:
##########
@@ -0,0 +1,1044 @@
+/*
+ * 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.connector
+
+import java.util
+import java.util.Locale
+
+import scala.collection.mutable.ArrayBuffer
+import scala.jdk.CollectionConverters._
+
+import org.apache.spark.sql.{AnalysisException, QueryTest, Row}
+import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException
+import org.apache.spark.sql.catalyst.parser.ParseException
+import org.apache.spark.sql.catalyst.plans.logical.{CreateTable,
CreateTableAsSelect, LogicalPlan, ReplaceTable, ReplaceTableAsSelect,
V2CreateTablePlan}
+import org.apache.spark.sql.catalyst.plans.physical.{HashPartitioning,
RangePartitioning}
+import org.apache.spark.sql.connector.catalog.{Column, DelegatingTable,
Identifier, InMemoryTable, InMemoryTableCatalog, StagedTable,
StagingInMemoryTableCatalog, Table, TableCatalogCapability, TableInfo,
WriteDistributionMode}
+import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
+import org.apache.spark.sql.connector.catalog.WriteDistributionMode.{HASH,
NONE, RANGE}
+import org.apache.spark.sql.connector.distributions.Distributions
+import org.apache.spark.sql.connector.expressions.{ClusterByTransform,
Expression, FieldReference, LogicalExpressions, NullOrdering, SortDirection,
SortOrder, Transform}
+import org.apache.spark.sql.connector.expressions.LogicalExpressions.literal
+import org.apache.spark.sql.execution.{SortExec, SparkPlan}
+import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeExec
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{IntegerType, StringType}
+
+/**
+ * Tests the create-time write distribution and ordering clauses:
CREATE/REPLACE TABLE ...
+ * DISTRIBUTED BY PARTITION ... [LOCALLY] ORDERED BY ... | UNORDERED. Covers
what the parser
+ * produces, what reaches the catalog, and how a catalog without the
capability is rejected.
+ */
+class CreateTableWriteOrderSuite extends QueryTest with SharedSparkSession {
+
+ // The catalog manager caches catalogs for the session; reset it so tests
don't share tables.
+ override def afterEach(): Unit = {
+ spark.sessionState.catalogManager.reset()
+ super.afterEach()
+ }
+
+ private def parse(sql: String): LogicalPlan =
spark.sessionState.sqlParser.parsePlan(sql)
+
+ private def analyze(sql: String): LogicalPlan =
+ spark.sessionState.executePlan(parse(sql)).analyzed
+
+ private def orderingOf(plan: LogicalPlan): Seq[String] = plan match {
+ case create: V2CreateTablePlan =>
WriteSpecCall.render(create.writeOrdering)
+ case other => fail(s"unexpected plan: $other")
+ }
+
+ private def calls(catalogName: String): Seq[WriteSpecCall] =
+ spark.sessionState.catalogManager.catalog(catalogName)
+ .asInstanceOf[RecordsWriteSpecs].recordedCalls
+
+ private def loadTable(catalogName: String, name: String): Table =
+ spark.sessionState.catalogManager.catalog(catalogName).asTableCatalog
+ .loadTable(Identifier.of(Array.empty, name))
+
+ private def clusteringOf(table: Table): Seq[Seq[String]] =
+ table.partitioning().toSeq.collect {
+ case ClusterByTransform(columns) => columns.map(_.describe)
+ }
+
+ test("parse DISTRIBUTED BY PARTITION / ORDERED BY on CREATE TABLE") {
+ parse("CREATE TABLE t (id INT, c STRING) USING foo PARTITIONED BY (c) " +
+ "DISTRIBUTED BY PARTITION ORDERED BY id ASC NULLS FIRST") match {
+ case c: CreateTable =>
+ assert(c.writeDistributionMode === HASH)
+ assert(c.writeOrdering.length === 1)
+ val order = c.writeOrdering.head
+ assert(order.expression() === FieldReference(Seq("id")))
+ assert(order.direction() === SortDirection.ASCENDING)
+ assert(order.nullOrdering() === NullOrdering.NULLS_FIRST)
+ case other => fail(s"unexpected plan: $other")
+ }
+ }
+
+ test("bare ORDERED BY implies range, LOCALLY implies none, UNORDERED implies
none") {
+ Seq(
+ ("ORDERED BY id", RANGE, 1),
+ ("LOCALLY ORDERED BY id", NONE, 1),
+ ("UNORDERED", NONE, 0)
+ ).foreach { case (clause, expectedMode, expectedOrderingSize) =>
+ parse(s"CREATE TABLE t (id INT) USING foo $clause") match {
+ case c: CreateTable =>
+ assert(c.writeDistributionMode === expectedMode, s"for $clause")
+ assert(c.writeOrdering.length === expectedOrderingSize, s"for
$clause")
+ case other => fail(s"unexpected plan for $clause: $other")
+ }
+ }
+ }
+
+ test("an explicit DISTRIBUTED BY PARTITION decides the distribution on its
own") {
+ Seq(
+ ("ORDERED BY id", 1),
+ ("LOCALLY ORDERED BY id", 1),
+ ("UNORDERED", 0)
+ ).foreach { case (clause, expectedOrderingSize) =>
+ parse(s"CREATE TABLE t (id INT, c STRING) USING foo PARTITIONED BY (c) "
+
+ s"DISTRIBUTED BY PARTITION $clause") match {
+ case c: CreateTable =>
+ assert(c.writeDistributionMode === HASH, s"for $clause")
+ assert(c.writeOrdering.length === expectedOrderingSize, s"for
$clause")
+ case other => fail(s"unexpected plan for $clause: $other")
+ }
+ }
+ }
+
+ test("no clause leaves the mode unset (null), distinct from an explicit
none") {
+ parse("CREATE TABLE t (id INT) USING foo") match {
+ case c: CreateTable =>
+ assert(c.writeDistributionMode === null)
+ assert(c.writeOrdering.isEmpty)
+ case other => fail(s"unexpected plan: $other")
+ }
+ }
+
+ test("CTAS / REPLACE / RTAS carry the clauses too") {
+ parse("CREATE TABLE t USING foo ORDERED BY id AS SELECT 1 AS id") match {
+ case c: CreateTableAsSelect =>
+ assert(c.writeDistributionMode === RANGE && c.writeOrdering.length ==
1)
+ case other => fail(s"unexpected plan: $other")
+ }
+ parse("REPLACE TABLE t (id INT) USING foo ORDERED BY id DESC NULLS LAST")
match {
+ case r: ReplaceTable =>
+ assert(r.writeDistributionMode === RANGE)
+ assert(r.writeOrdering.head.direction() === SortDirection.DESCENDING)
+ assert(r.writeOrdering.head.nullOrdering() === NullOrdering.NULLS_LAST)
+ case other => fail(s"unexpected plan: $other")
+ }
+ parse("REPLACE TABLE t USING foo UNORDERED AS SELECT 1 AS id") match {
+ case r: ReplaceTableAsSelect =>
+ assert(r.writeDistributionMode === NONE && r.writeOrdering.isEmpty)
+ case other => fail(s"unexpected plan: $other")
+ }
+ }
+
+ test("transforms are allowed in the ordering") {
+ val stmt = "CREATE TABLE t (id INT, ts TIMESTAMP) USING foo " +
+ "ORDERED BY days(ts), bucket(16, id)"
+ parse(stmt) match {
+ case c: CreateTable =>
+ assert(c.writeOrdering.length === 2)
+ assert(c.writeOrdering.map(_.expression().describe()) ===
+ Seq("days(ts)", "bucket(16, id)"))
+ case other => fail(s"unexpected plan: $other")
+ }
+ }
+
+ test("the parenthesised ORDERED BY form parses the same") {
+ Seq("ORDERED BY id DESC, c", "ORDERED BY (id DESC, c)").foreach { clause =>
+ parse(s"CREATE TABLE t (id INT, c STRING) USING foo $clause") match {
+ case c: CreateTable =>
+ assert(WriteSpecCall.render(c.writeOrdering) ===
+ Seq("id DESC NULLS LAST", "c ASC NULLS FIRST"), s"for $clause")
+ case other => fail(s"unexpected plan for $clause: $other")
+ }
+ }
+ }
+
+ test("the write clauses may appear in any position among the other
create-table clauses") {
+ Seq(
+ "ORDERED BY id PARTITIONED BY (c) DISTRIBUTED BY PARTITION",
+ "DISTRIBUTED BY PARTITION PARTITIONED BY (c) LOCALLY ORDERED BY id",
+ "PARTITIONED BY (c) ORDERED BY id COMMENT 'a table' DISTRIBUTED BY
PARTITION"
+ ).foreach { clauses =>
+ parse(s"CREATE TABLE t (id INT, c STRING) USING foo $clauses") match {
+ case c: CreateTable =>
+ assert(c.writeDistributionMode === HASH, s"for $clauses")
+ assert(c.writeOrdering.length === 1, s"for $clauses")
+ case other => fail(s"unexpected plan for $clauses: $other")
+ }
+ }
+ }
+
+ test("a repeated write clause is rejected") {
+ Seq(
+ ("DISTRIBUTED BY PARTITION DISTRIBUTED BY PARTITION", "DISTRIBUTED BY
PARTITION"),
+ ("ORDERED BY id UNORDERED", "ORDERED BY/UNORDERED"),
+ ("UNORDERED LOCALLY ORDERED BY id", "ORDERED BY/UNORDERED")
+ ).foreach { case (clauses, clauseName) =>
+ val e = intercept[ParseException] {
+ parse(s"CREATE TABLE t (id INT, c STRING) USING foo PARTITIONED BY (c)
$clauses")
+ }
+ assert(e.getCondition === "DUPLICATE_CLAUSES", s"for $clauses")
+ assert(e.getMessageParameters.get("clauseName") === clauseName, s"for
$clauses")
+ }
+ }
+
+ test("DISTRIBUTED BY PARTITION requires a partitioned table") {
+ Seq(
+ "CREATE TABLE t (id INT) USING foo DISTRIBUTED BY PARTITION",
+ "CREATE TABLE t USING foo DISTRIBUTED BY PARTITION AS SELECT 1 AS id",
+ "REPLACE TABLE t (id INT) USING foo DISTRIBUTED BY PARTITION",
+ "REPLACE TABLE t USING foo DISTRIBUTED BY PARTITION AS SELECT 1 AS id"
+ ).foreach { stmt =>
+ val e = intercept[ParseException](parse(stmt))
+ assert(e.getCondition ===
+
"SPECIFY_DISTRIBUTED_BY_PARTITION_WITHOUT_PARTITIONING_IS_NOT_ALLOWED", s"for
$stmt")
+ }
+ }
+
+ test("ordering references are normalized to the schema's spelling") {
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[RecordingInMemoryTableCatalog].getName) {
+ assert(orderingOf(analyze("CREATE TABLE testcat.t (ID INT, TS TIMESTAMP)
USING foo " +
+ "ORDERED BY id, days(ts) DESC")) === Seq("ID ASC NULLS FIRST",
"days(TS) DESC NULLS LAST"))
+
+ assert(orderingOf(analyze(
+ "CREATE TABLE testcat.t USING foo ORDERED BY id AS SELECT 1 AS ID"))
===
+ Seq("ID ASC NULLS FIRST"))
+
+ assert(orderingOf(analyze(
+ "CREATE TABLE testcat.t (P STRUCT<X: INT>) USING foo ORDERED BY p.x"))
===
+ Seq("P.X ASC NULLS FIRST"))
+
+ assert(orderingOf(analyze("CREATE TABLE testcat.t (ID INT) USING foo " +
+ "ORDERED BY truncate(4, ID)")) === Seq("truncate(4, ID) ASC NULLS
FIRST"))
+ val e = intercept[AnalysisException] {
+ analyze("CREATE TABLE testcat.t (ID INT) USING foo ORDERED BY
truncate(4, id)")
+ }
+ assert(e.getCondition === "WRITE_ORDERING_WITH_UNKNOWN_COLUMN")
+ assert(e.getMessageParameters.get("cols") === "`id`")
+
+ // references are normalized independently, so only the unresolvable one
is reported
+ val multi = intercept[AnalysisException] {
+ analyze("CREATE TABLE testcat.t (ID INT) USING foo ORDERED BY
bucket(4, id, nope)")
+ }
+ assert(multi.getMessageParameters.get("cols") === "`nope`")
Review Comment:
Minor: this assertion checks only `cols`, not the condition. About 13 error
checks in this suite are hand-rolled (`getCondition`,
`getMessageParameters.get`), while L516, L530, L558 and L570 already use
`checkError`. Some check only the condition (L214, L286, L307, L347, L550), and
none pins the SQLSTATE or the query context. No test pins the SQLSTATEs of the
three new conditions (`WRITE_ORDERING_WITH_UNKNOWN_COLUMN` with 42703,
`SPECIFY_WRITE_ORDERING_IS_NOT_ALLOWED`,
`SPECIFY_DISTRIBUTED_BY_PARTITION_WITHOUT_PARTITIONING_IS_NOT_ALLOWED`), and
`SparkThrowableSuite` does not pin per-condition values, so a change to the
JSON alone would pass. We settled 42703 in my first comment because error
classes are hard to change after a release.
Could we convert these to `checkError(..., sqlState = ..., parameters = ...,
context = ExpectedContext(...))`? The parse errors and the `failAnalysis` error
carry a query context, and `checkError` verifies the SQLSTATE only when
`sqlState` is passed.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableCatalogCapability.java:
##########
@@ -92,5 +92,33 @@ public enum TableCatalogCapability {
* {@link TableCatalog#createTable}.
* See {@link Column#identityColumnSpec()}.
*/
- SUPPORTS_CREATE_TABLE_WITH_IDENTITY_COLUMNS
+ SUPPORTS_CREATE_TABLE_WITH_IDENTITY_COLUMNS,
+
+ /**
+ * Signals that the TableCatalog supports a write distribution and ordering
requested upon table
+ * creation in SQL, with {@code DISTRIBUTED BY PARTITION} and {@code
[LOCALLY] ORDERED BY ...} /
+ * {@code UNORDERED}. The request reaches the catalog only through the
{@link TableInfo}
+ * overloads:
+ * <ul>
+ * <li>{@link TableCatalog#createTable(Identifier, TableInfo)}</li>
+ * <li>{@link StagingTableCatalog#stageCreate(Identifier, TableInfo)}</li>
+ * <li>{@link StagingTableCatalog#stageReplace(Identifier, TableInfo)}</li>
+ * <li>{@link StagingTableCatalog#stageCreateOrReplace(Identifier,
TableInfo)}</li>
+ * </ul>
+ * Their default implementations drop the request, so a catalog that reports
this capability must
Review Comment:
Minor: Spark's own `DelegatingCatalogExtension` cannot keep this promise. It
forwards the delegate's `capabilities()`
(DelegatingCatalogExtension.java:57-58) but does not override
`createTable(Identifier, TableInfo)`, so it uses the default at
TableCatalog.java:359-361, which drops the request. Today the only in-tree
delegate, `V2SessionCatalog`, does not report the capability, so the statement
is rejected. Once a delegate reports it, e.g. if `V2SessionCatalog` gains
support later, subclasses that inherit `super.capabilities()`, such as Delta's
`AbstractDeltaCatalog` and Hudi's `HoodieCatalog`, pass the check and silently
lose the layout. That is the case the PR description says the capability
prevents, and `SUPPORT_TABLE_CONSTRAINT` has had the same gap since 4.1.0.
Forwarding `createTable(Identifier, TableInfo)` to the delegate is not a fix,
because it would bypass those subclasses' column-based overrides.
Could `DelegatingCatalogExtension.capabilities()` remove this new capability
from the forwarded set, so that only a subclass overriding all four `TableInfo`
overloads adds it back, or could this Javadoc at least mention it?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala:
##########
@@ -991,6 +992,25 @@ trait CheckAnalysis extends LookupCatalog with
QueryErrorsBase with PlanToString
"cols" -> badReferences.map(r => toSQLId(r)).mkString(", ")))
}
+ // PreprocessTableCreation only normalizes column and
RewritableTransform references,
Review Comment:
Nit: this comment gives a reason that no longer holds.
`PreprocessTableCreation` deliberately keeps an unresolvable ordering reference
for this check (rules.scala:354-355), so this is the only check rather than an
additional one, and analyzers without that sql/core rule need it as well. The
block also copies the partitioning check above with an explicit `(ref:
NamedReference) =>` ascription (the import at L33 exists only for it), a
trailing `.toSeq`, and a quote-then-reparse through `column.quoted` and
`toSQLId(String)`. With five or more references, `cols` follows the hash set
order rather than the declaration order.
Could we write it as
`create.writeOrdering.flatMap(_.expression().references().map(_.fieldNames().toImmutableArraySeq)).distinct.filter(create.tableSchema.findNestedField(_).isEmpty)`
with `toSQLId(parts: Seq[String])`, drop the import, and say why the check is
here?
--
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]