dongjoon-hyun commented on code in PR #58153:
URL: https://github.com/apache/spark/pull/58153#discussion_r4208824864


##########
docs/sql-ref-syntax-ddl-create-table-datasource.md:
##########
@@ -37,9 +37,17 @@ CREATE [ EXTERNAL ] TABLE [ IF NOT EXISTS ] table_identifier
     [ LOCATION path ]
     [ COMMENT table_comment ]
     [ TBLPROPERTIES ( key1=val1, key2=val2, ... ) ]
+    [ DISTRIBUTED BY PARTITION ]
+    [ [ LOCALLY ] ORDERED BY ( write_order_field [ , ... ] ) | UNORDERED ]

Review Comment:
   Nit: the parentheses are optional, as L130-131 says and the `AstBuilder` 
scaladoc now shows with `[(] ... [)]`, but this syntax block still shows them 
as required. Could it be `[ [ LOCALLY ] ORDERED BY { ( write_order_field [ , 
... ] ) | write_order_field [ , ... ] } | UNORDERED ]`?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/WriteDistributionAndOrdering.scala:
##########
@@ -0,0 +1,190 @@
+/*
+ * 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 java.time.ZoneOffset
+
+import scala.util.Try
+import scala.util.control.NonFatal
+
+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.{Cast, Expression, 
GeneralScalarExpression, IdentityTransform, Literal, NamedReference, SortOrder, 
Transform}
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.types.{FloatType, StructType, TimestampType}
+import org.apache.spark.util.ArrayImplicits._
+
+/**
+ * Utility methods for the create-time write distribution and ordering 
requested by
+ * `CREATE`/`REPLACE TABLE ... DISTRIBUTED BY PARTITION / [LOCALLY] ORDERED 
BY`.
+ */
+object WriteDistributionAndOrdering {
+
+  /** Names the write clauses in an error's operation, as in `CREATE TABLE ... 
<CLAUSES>`. */
+  val CLAUSES = "DISTRIBUTED BY/ORDERED BY/UNORDERED"
+
+  /**
+   * 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
+  }
+
+  /**
+   * True when the transforms define a partitioning that `DISTRIBUTED BY 
PARTITION` can distribute
+   * by. Bucketing counts; a `cluster_by` transform, from CLUSTER BY or 
reported by a connector,
+   * does not. Matches by name only, since a connector's `cluster_by` may take 
any arguments.
+   */
+  def hasPartitioning(partitioning: Seq[Transform]): Boolean = {
+    partitioning.exists(_.name != "cluster_by")
+  }
+
+  /**
+   * 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 ... $CLAUSES")
+    }
+  }
+
+  /** Renders a requested sort key as SQL, for DESCRIBE. */
+  def describeSortOrder(sortOrder: SortOrder): String = 
sortOrderToSQL(sortOrder, quoteIfNeeded)
+
+  /**
+   * Renders the declared write distribution and ordering as `DISTRIBUTED BY 
PARTITION`,
+   * `[LOCALLY] ORDERED BY` or `UNORDERED` clauses for SHOW CREATE TABLE. 
Returns None when the
+   * pair has no clause form, such as `HASH` without partitioning, or when 
`replay`, which parses a
+   * statement carrying the clauses and returns the pair it declares, does not 
give back the same
+   * pair. Names are quoted only where needed, and on a second try all of 
them, which reserved
+   * keywords under `spark.sql.ansi.enforceReservedKeywords` need.
+   */
+  def writeClausesSQL(
+      writeDistributionMode: WriteDistributionMode,
+      writeOrdering: Seq[SortOrder],
+      partitioning: Seq[Transform],
+      replay: String => Option[(WriteDistributionMode, Seq[SortOrder])]): 
Option[String] = {
+    lazy val partitioned = hasPartitioning(partitioning)
+    Seq[String => String](quoteIfNeeded, quoteIdentifier).iterator.flatMap { 
quote =>
+      val orderBy = if (writeOrdering.nonEmpty) {
+        Some(writeOrdering.map(sortOrderToSQL(_, quote)).mkString("ORDERED BY 
(", ", ", ")"))
+      } else {
+        None
+      }
+      (writeDistributionMode, orderBy) match {
+        case (WriteDistributionMode.HASH, Some(o)) if partitioned =>
+          Some(s"DISTRIBUTED BY PARTITION $o")
+        case (WriteDistributionMode.HASH, None) if partitioned => 
Some("DISTRIBUTED BY PARTITION")
+        case (WriteDistributionMode.RANGE, Some(o)) => Some(o)
+        case (WriteDistributionMode.NONE, Some(o)) => Some(s"LOCALLY $o")
+        case (WriteDistributionMode.NONE, None) => Some("UNORDERED")
+        case _ => None
+      }
+    }.find { clauses =>
+      replay(clauses).exists { case (mode, ordering) =>
+        mode == writeDistributionMode && sameSortOrders(ordering, 
writeOrdering)
+      }
+    }
+  }
+
+  /**
+   * True when every column a sort key references is in `schema`, matched as 
`CheckAnalysis`
+   * matches the ordering of a CREATE/REPLACE TABLE statement.
+   */
+  def referencesExist(schema: StructType, writeOrdering: Seq[SortOrder]): 
Boolean = {
+    writeOrdering.flatMap(_.expression().references()).forall { ref =>

Review Comment:
   **`referencesExist` checks the declared key's `references()`, but 
`ApplyTransform.references` collects only top-level references, so a column 
inside a nested `IdentityTransform` is never checked.** For a table `t(id 
INT)`, a connector key `Expressions.apply("f", 
Expressions.identity("missing"))` passes this check vacuously. `toSQL` and 
`keyOf` both flatten the nested identity, so the parse-back comparison succeeds 
and SHOW CREATE TABLE prints `ORDERED BY (f(missing) ASC NULLS FIRST)`. 
Replaying it fails with `WRITE_ORDERING_WITH_UNKNOWN_COLUMN` 
(CheckAnalysis.scala:996-1003). `identity("ID")` for a column `id` fails the 
same way, since an `ApplyTransform` is not normalized (SPARK-59944). This is 
new in this commit: the previous `isSpellable` rejected an argument that was 
neither a reference nor a literal.
   
   Could we check the replayed ordering instead, which is what `CheckAnalysis` 
will see, e.g. `referencesExist(schema, ordering)` inside the `find` or in 
`ShowCreateTableExec.replay`?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/WriteDistributionAndOrdering.scala:
##########
@@ -0,0 +1,190 @@
+/*
+ * 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 java.time.ZoneOffset
+
+import scala.util.Try
+import scala.util.control.NonFatal
+
+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.{Cast, Expression, 
GeneralScalarExpression, IdentityTransform, Literal, NamedReference, SortOrder, 
Transform}
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.types.{FloatType, StructType, TimestampType}
+import org.apache.spark.util.ArrayImplicits._
+
+/**
+ * Utility methods for the create-time write distribution and ordering 
requested by
+ * `CREATE`/`REPLACE TABLE ... DISTRIBUTED BY PARTITION / [LOCALLY] ORDERED 
BY`.
+ */
+object WriteDistributionAndOrdering {
+
+  /** Names the write clauses in an error's operation, as in `CREATE TABLE ... 
<CLAUSES>`. */
+  val CLAUSES = "DISTRIBUTED BY/ORDERED BY/UNORDERED"
+
+  /**
+   * 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
+  }
+
+  /**
+   * True when the transforms define a partitioning that `DISTRIBUTED BY 
PARTITION` can distribute
+   * by. Bucketing counts; a `cluster_by` transform, from CLUSTER BY or 
reported by a connector,
+   * does not. Matches by name only, since a connector's `cluster_by` may take 
any arguments.
+   */
+  def hasPartitioning(partitioning: Seq[Transform]): Boolean = {
+    partitioning.exists(_.name != "cluster_by")
+  }
+
+  /**
+   * 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 ... $CLAUSES")
+    }
+  }
+
+  /** Renders a requested sort key as SQL, for DESCRIBE. */
+  def describeSortOrder(sortOrder: SortOrder): String = 
sortOrderToSQL(sortOrder, quoteIfNeeded)
+
+  /**
+   * Renders the declared write distribution and ordering as `DISTRIBUTED BY 
PARTITION`,
+   * `[LOCALLY] ORDERED BY` or `UNORDERED` clauses for SHOW CREATE TABLE. 
Returns None when the
+   * pair has no clause form, such as `HASH` without partitioning, or when 
`replay`, which parses a
+   * statement carrying the clauses and returns the pair it declares, does not 
give back the same
+   * pair. Names are quoted only where needed, and on a second try all of 
them, which reserved
+   * keywords under `spark.sql.ansi.enforceReservedKeywords` need.
+   */
+  def writeClausesSQL(
+      writeDistributionMode: WriteDistributionMode,
+      writeOrdering: Seq[SortOrder],
+      partitioning: Seq[Transform],
+      replay: String => Option[(WriteDistributionMode, Seq[SortOrder])]): 
Option[String] = {
+    lazy val partitioned = hasPartitioning(partitioning)
+    Seq[String => String](quoteIfNeeded, quoteIdentifier).iterator.flatMap { 
quote =>
+      val orderBy = if (writeOrdering.nonEmpty) {
+        Some(writeOrdering.map(sortOrderToSQL(_, quote)).mkString("ORDERED BY 
(", ", ", ")"))
+      } else {
+        None
+      }
+      (writeDistributionMode, orderBy) match {
+        case (WriteDistributionMode.HASH, Some(o)) if partitioned =>
+          Some(s"DISTRIBUTED BY PARTITION $o")
+        case (WriteDistributionMode.HASH, None) if partitioned => 
Some("DISTRIBUTED BY PARTITION")
+        case (WriteDistributionMode.RANGE, Some(o)) => Some(o)
+        case (WriteDistributionMode.NONE, Some(o)) => Some(s"LOCALLY $o")
+        case (WriteDistributionMode.NONE, None) => Some("UNORDERED")
+        case _ => None
+      }
+    }.find { clauses =>
+      replay(clauses).exists { case (mode, ordering) =>
+        mode == writeDistributionMode && sameSortOrders(ordering, 
writeOrdering)
+      }
+    }
+  }
+
+  /**
+   * True when every column a sort key references is in `schema`, matched as 
`CheckAnalysis`
+   * matches the ordering of a CREATE/REPLACE TABLE statement.
+   */
+  def referencesExist(schema: StructType, writeOrdering: Seq[SortOrder]): 
Boolean = {
+    writeOrdering.flatMap(_.expression().references()).forall { ref =>
+      ref.fieldNames().nonEmpty &&
+        
Try(schema.findNestedField(ref.fieldNames().toImmutableArraySeq)).toOption.flatten.isDefined
+    }
+  }
+
+  /**
+   * True when two orderings declare the same keys: the same column names, 
transform names and
+   * argument structure, literal values and types, directions and null 
orderings.
+   */
+  def sameSortOrders(left: Seq[SortOrder], right: Seq[SortOrder]): Boolean = {
+    left.length == right.length && left.zip(right).forall { case (l, r) =>
+      l.direction() == r.direction() && l.nullOrdering() == r.nullOrdering() &&
+        keyOf(l.expression()) == keyOf(r.expression())
+    }
+  }
+
+  private def keyOf(e: Expression): Any = e match {
+    case r: NamedReference => r.fieldNames().toSeq
+    case l: Literal[_] => Try(CatalystLiteral.create(l.value, 
l.dataType)).toOption
+    case t: IdentityTransform => keyOf(t.ref)
+    case t: Transform => (t.name, t.arguments().toSeq.map(keyOf))
+    case other => other
+  }
+
+  private def sortOrderToSQL(sortOrder: SortOrder, quote: String => String): 
String = {
+    s"${toSQL(sortOrder.expression(), quote)} ${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, and a TIMESTAMP
+  // in UTC with an explicit offset so that it does not depend on the session 
time zone or
+  // timestamp type. A literal Catalyst cannot represent falls back to 
`describe`. Other connector
+  // expressions are rendered from their children: `describe` on one that
+  // `V2ExpressionSQLBuilder` does not know recurses without end. 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, quote: String => String): String = e match {
+    case r: NamedReference => r.fieldNames.map(quote).mkString(".")
+    case l: Literal[_] =>
+      try literalToSQL(CatalystLiteral.create(l.value, l.dataType)) catch {
+        case NonFatal(_) => describeLiteral(l)
+      }
+    case t: IdentityTransform => toSQL(t.ref, quote)
+    case t: Transform =>
+      t.arguments().map(toSQL(_, quote)).mkString(s"${quote(t.name)}(", ", ", 
")")
+    case c: Cast => s"CAST(${toSQL(c.expression(), quote)} AS 
${c.dataType().sql})"
+    case g: GeneralScalarExpression =>
+      g.children().map(toSQL(_, quote)).mkString(s"${g.name}(", ", ", ")")
+    case other =>
+      other.children().map(toSQL(_, 
quote)).mkString(s"${other.getClass.getSimpleName}(", ", ", ")")
+  }
+
+  // `LiteralValue.describe` also rejects a value that does not match its type.
+  private def describeLiteral(l: Literal[_]): String = {
+    try l.describe catch {
+      case NonFatal(_) => String.valueOf(l.value)
+    }
+  }
+
+  private def literalToSQL(l: CatalystLiteral): String = (l.value, l.dataType) 
match {
+    case (f: Float, FloatType) if java.lang.Float.isFinite(f) => s"${f}F"
+    case (micros: Long, TimestampType) =>
+      val utc = 
TimestampFormatter.getFractionFormatter(ZoneOffset.UTC).format(micros)
+      s"TIMESTAMP_LTZ '${utc}Z'"
+    case _ => l.sql

Review Comment:
   Minor: point 15 is not complete for a `TIME(7-9)` value with trailing zeros. 
`TIME '12:00:00.100000000'` is a `TIME(9)` literal, but `Literal.sql` prints 
`TIME '12:00:00.1'` (literals.scala:691, whose formatter drops trailing zeros), 
which parses back as `TIME(6)`. The comparison fails and the whole clause is 
dropped, although `TIME '12:00:00.100000000'` would replay correctly. A value 
without trailing zeros, such as `TIME '12:00:00.123456789'`, does round-trip, 
so the reply's "a TIME with 7 to 9 fractional digits is omitted" holds only for 
these values.
   
   Could `literalToSQL` pad the fraction to the type's precision for 
`TimeType`, as `Literal.sql` already does with `padToNanosPrecision` for the 
nanosecond timestamp types (literals.scala:700-702)?



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableInfo.java:
##########
@@ -65,9 +72,45 @@ public Transform[] partitions() {
 
   public Constraint[] constraints() { return constraints; }
 
+  /**
+   * The requested write distribution, or null when the statement did not ask 
for one, which
+   * leaves the choice to the catalog.
+   * <p>
+   * Only catalogs that report
+   * {@link 
TableCatalogCapability#SUPPORTS_CREATE_TABLE_WITH_WRITE_DISTRIBUTION_AND_ORDERING}
 see a
+   * request; for any other catalog, Spark rejects the statement.
+   * <p>
+   * Spark passes the request only to the {@code TableInfo} overloads of
+   * {@link TableCatalog#createTable(Identifier, TableInfo)} and of the {@link 
StagingTableCatalog}
+   * methods {@code stageCreate}, {@code stageReplace} and {@code 
stageCreateOrReplace}. Their
+   * default implementations drop it, so a catalog that reports the capability 
must override each
+   * one it can be reached through. {@link TableCatalog#createTableLike} never 
gets a request: Spark
+   * does not copy the source table's write distribution and ordering into its 
{@code TableInfo}.
+   *
+   * @since 4.4.0
+   */
+  public WriteDistributionMode writeDistributionMode() { return 
writeDistributionMode; }
+
+  /**
+   * The requested write ordering; never null, and empty when none was 
requested. Gated on the
+   * same capability and delivered the same way as {@link 
#writeDistributionMode()}.
+   * <p>
+   * A plain column is a {@link 
org.apache.spark.sql.connector.expressions.NamedReference}; any
+   * other key is a {@link Transform}, such as {@code bucket(16, id)}. Spark 
checks that each
+   * referenced column exists in the table schema, and the parser checks the 
arguments of

Review Comment:
   **This new sentence promises more validation than the parser does.** 
`visitTransform` dispatches these names case-sensitively 
(AstBuilder.scala:5869), so `ORDERED BY BUCKET(id, 16)` or `ORDERED BY DAYS(1)` 
reaches the catalog as a generic transform with no argument check, and 
`DAYS(1)` references no column, so the column check does not apply either. For 
lowercase `bucket`, the parser checks only the argument kinds: `bucket(16)` 
without a column, a zero or negative count, and `bucket(4294967300, id)`, which 
`.toInt` truncates to 4 (AstBuilder.scala:5877), are all accepted. 
`WriteDistributionAndOrderingUtilsSuite` even lists "bucket without columns" as 
spellable. A catalog author reading this sentence may skip their own validation.
   
   Could we drop the parser clause, or say that only the lowercase names have 
their argument kinds checked and that bucket counts and column lists are not 
validated?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/rules.scala:
##########
@@ -323,10 +323,17 @@ case class PreprocessTableCreation(catalog: 
SessionCatalog) extends Rule[Logical
       SchemaUtils.checkTransformDuplication(
         partitioning, "in the partitioning", isCaseSensitive)
 
+      // The write ordering has no duplicate check: a repeated sort key is 
redundant, and
+      // `bucket(4, id), bucket(8, id)` are distinct keys.
+
       if (schema.isEmpty) {
         if (partitioning.nonEmpty) {
           throw 
QueryCompilationErrors.specifyPartitionNotAllowedWhenTableSchemaNotDefinedError()
         }
+        if (create.writeOrdering.nonEmpty) {
+          throw QueryCompilationErrors

Review Comment:
   Nit: `SPECIFY_WRITE_ORDERING_IS_NOT_ALLOWED` is the only one of the four new 
conditions without a query context. 
`specifyWriteOrderingNotAllowedWhenTableSchemaNotDefinedError()` creates the 
`AnalysisException` without an origin, while 
`WRITE_ORDERING_WITH_UNKNOWN_COLUMN` and the two parse errors point at the 
statement, and the converted `checkError` at 
CreateTableWriteOrderSuite.scala:329 now pins an empty context. The legacy 
`_LEGACY_ERROR_TEMP_1165` at L331 has the same gap, so this is optional, but 
changing it later means changing the test again.
   
   Could this error take `create.origin`?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/WriteDistributionAndOrdering.scala:
##########
@@ -0,0 +1,190 @@
+/*
+ * 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 java.time.ZoneOffset
+
+import scala.util.Try
+import scala.util.control.NonFatal
+
+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.{Cast, Expression, 
GeneralScalarExpression, IdentityTransform, Literal, NamedReference, SortOrder, 
Transform}
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.types.{FloatType, StructType, TimestampType}
+import org.apache.spark.util.ArrayImplicits._
+
+/**
+ * Utility methods for the create-time write distribution and ordering 
requested by
+ * `CREATE`/`REPLACE TABLE ... DISTRIBUTED BY PARTITION / [LOCALLY] ORDERED 
BY`.
+ */
+object WriteDistributionAndOrdering {
+
+  /** Names the write clauses in an error's operation, as in `CREATE TABLE ... 
<CLAUSES>`. */
+  val CLAUSES = "DISTRIBUTED BY/ORDERED BY/UNORDERED"
+
+  /**
+   * 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
+  }
+
+  /**
+   * True when the transforms define a partitioning that `DISTRIBUTED BY 
PARTITION` can distribute
+   * by. Bucketing counts; a `cluster_by` transform, from CLUSTER BY or 
reported by a connector,
+   * does not. Matches by name only, since a connector's `cluster_by` may take 
any arguments.
+   */
+  def hasPartitioning(partitioning: Seq[Transform]): Boolean = {
+    partitioning.exists(_.name != "cluster_by")
+  }
+
+  /**
+   * 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 ... $CLAUSES")
+    }
+  }
+
+  /** Renders a requested sort key as SQL, for DESCRIBE. */
+  def describeSortOrder(sortOrder: SortOrder): String = 
sortOrderToSQL(sortOrder, quoteIfNeeded)
+
+  /**
+   * Renders the declared write distribution and ordering as `DISTRIBUTED BY 
PARTITION`,
+   * `[LOCALLY] ORDERED BY` or `UNORDERED` clauses for SHOW CREATE TABLE. 
Returns None when the
+   * pair has no clause form, such as `HASH` without partitioning, or when 
`replay`, which parses a
+   * statement carrying the clauses and returns the pair it declares, does not 
give back the same
+   * pair. Names are quoted only where needed, and on a second try all of 
them, which reserved
+   * keywords under `spark.sql.ansi.enforceReservedKeywords` need.
+   */
+  def writeClausesSQL(
+      writeDistributionMode: WriteDistributionMode,
+      writeOrdering: Seq[SortOrder],
+      partitioning: Seq[Transform],
+      replay: String => Option[(WriteDistributionMode, Seq[SortOrder])]): 
Option[String] = {
+    lazy val partitioned = hasPartitioning(partitioning)
+    Seq[String => String](quoteIfNeeded, quoteIdentifier).iterator.flatMap { 
quote =>
+      val orderBy = if (writeOrdering.nonEmpty) {
+        Some(writeOrdering.map(sortOrderToSQL(_, quote)).mkString("ORDERED BY 
(", ", ", ")"))
+      } else {
+        None
+      }
+      (writeDistributionMode, orderBy) match {
+        case (WriteDistributionMode.HASH, Some(o)) if partitioned =>
+          Some(s"DISTRIBUTED BY PARTITION $o")
+        case (WriteDistributionMode.HASH, None) if partitioned => 
Some("DISTRIBUTED BY PARTITION")
+        case (WriteDistributionMode.RANGE, Some(o)) => Some(o)
+        case (WriteDistributionMode.NONE, Some(o)) => Some(s"LOCALLY $o")
+        case (WriteDistributionMode.NONE, None) => Some("UNORDERED")
+        case _ => None
+      }
+    }.find { clauses =>
+      replay(clauses).exists { case (mode, ordering) =>
+        mode == writeDistributionMode && sameSortOrders(ordering, 
writeOrdering)
+      }
+    }
+  }
+
+  /**
+   * True when every column a sort key references is in `schema`, matched as 
`CheckAnalysis`
+   * matches the ordering of a CREATE/REPLACE TABLE statement.
+   */
+  def referencesExist(schema: StructType, writeOrdering: Seq[SortOrder]): 
Boolean = {
+    writeOrdering.flatMap(_.expression().references()).forall { ref =>
+      ref.fieldNames().nonEmpty &&
+        
Try(schema.findNestedField(ref.fieldNames().toImmutableArraySeq)).toOption.flatten.isDefined
+    }
+  }
+
+  /**
+   * True when two orderings declare the same keys: the same column names, 
transform names and
+   * argument structure, literal values and types, directions and null 
orderings.
+   */
+  def sameSortOrders(left: Seq[SortOrder], right: Seq[SortOrder]): Boolean = {
+    left.length == right.length && left.zip(right).forall { case (l, r) =>
+      l.direction() == r.direction() && l.nullOrdering() == r.nullOrdering() &&
+        keyOf(l.expression()) == keyOf(r.expression())
+    }
+  }
+
+  private def keyOf(e: Expression): Any = e match {
+    case r: NamedReference => r.fieldNames().toSeq
+    case l: Literal[_] => Try(CatalystLiteral.create(l.value, 
l.dataType)).toOption
+    case t: IdentityTransform => keyOf(t.ref)
+    case t: Transform => (t.name, t.arguments().toSeq.map(keyOf))
+    case other => other
+  }
+
+  private def sortOrderToSQL(sortOrder: SortOrder, quote: String => String): 
String = {
+    s"${toSQL(sortOrder.expression(), quote)} ${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, and a TIMESTAMP
+  // in UTC with an explicit offset so that it does not depend on the session 
time zone or
+  // timestamp type. A literal Catalyst cannot represent falls back to 
`describe`. Other connector
+  // expressions are rendered from their children: `describe` on one that
+  // `V2ExpressionSQLBuilder` does not know recurses without end. 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, quote: String => String): String = e match {
+    case r: NamedReference => r.fieldNames.map(quote).mkString(".")
+    case l: Literal[_] =>
+      try literalToSQL(CatalystLiteral.create(l.value, l.dataType)) catch {
+        case NonFatal(_) => describeLiteral(l)
+      }
+    case t: IdentityTransform => toSQL(t.ref, quote)
+    case t: Transform =>
+      t.arguments().map(toSQL(_, quote)).mkString(s"${quote(t.name)}(", ", ", 
")")
+    case c: Cast => s"CAST(${toSQL(c.expression(), quote)} AS 
${c.dataType().sql})"
+    case g: GeneralScalarExpression =>
+      g.children().map(toSQL(_, quote)).mkString(s"${g.name}(", ", ", ")")
+    case other =>
+      other.children().map(toSQL(_, 
quote)).mkString(s"${other.getClass.getSimpleName}(", ", ", ")")

Review Comment:
   Minor: this fallback now applies to every connector expression, not only to 
the `GeneralScalarExpression` names that `V2ExpressionSQLBuilder` does not 
know. An `Extract` key prints as `Extract(ts)` instead of `EXTRACT(YEAR FROM 
ts)`, because `Extract.children()` is just the source (Extract.java:60), and a 
`UserDefinedScalarFunc` prints as `UserDefinedScalarFunc(id)` instead of 
`my_udf(id)`. So DESCRIBE TABLE EXTENDED loses the field and the function name 
that 5569461 printed with `describe`. The `GeneralScalarExpression` case at 
L170-171 has a similar effect on known names, e.g. `+(id, 1)` instead of `id + 
1`.
   
   Could `describe` stay the default here, with the children-based rendering 
only for a `GeneralScalarExpression` name that `V2ExpressionSQLBuilder` does 
not know? Once the pre-existing recursion is fixed separately, that special 
case could go away too.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/WriteDistributionAndOrdering.scala:
##########
@@ -0,0 +1,190 @@
+/*
+ * 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 java.time.ZoneOffset
+
+import scala.util.Try
+import scala.util.control.NonFatal
+
+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.{Cast, Expression, 
GeneralScalarExpression, IdentityTransform, Literal, NamedReference, SortOrder, 
Transform}
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.types.{FloatType, StructType, TimestampType}
+import org.apache.spark.util.ArrayImplicits._
+
+/**
+ * Utility methods for the create-time write distribution and ordering 
requested by
+ * `CREATE`/`REPLACE TABLE ... DISTRIBUTED BY PARTITION / [LOCALLY] ORDERED 
BY`.
+ */
+object WriteDistributionAndOrdering {
+
+  /** Names the write clauses in an error's operation, as in `CREATE TABLE ... 
<CLAUSES>`. */
+  val CLAUSES = "DISTRIBUTED BY/ORDERED BY/UNORDERED"
+
+  /**
+   * 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
+  }
+
+  /**
+   * True when the transforms define a partitioning that `DISTRIBUTED BY 
PARTITION` can distribute
+   * by. Bucketing counts; a `cluster_by` transform, from CLUSTER BY or 
reported by a connector,
+   * does not. Matches by name only, since a connector's `cluster_by` may take 
any arguments.
+   */
+  def hasPartitioning(partitioning: Seq[Transform]): Boolean = {
+    partitioning.exists(_.name != "cluster_by")
+  }
+
+  /**
+   * 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 ... $CLAUSES")
+    }
+  }
+
+  /** Renders a requested sort key as SQL, for DESCRIBE. */
+  def describeSortOrder(sortOrder: SortOrder): String = 
sortOrderToSQL(sortOrder, quoteIfNeeded)
+
+  /**
+   * Renders the declared write distribution and ordering as `DISTRIBUTED BY 
PARTITION`,
+   * `[LOCALLY] ORDERED BY` or `UNORDERED` clauses for SHOW CREATE TABLE. 
Returns None when the
+   * pair has no clause form, such as `HASH` without partitioning, or when 
`replay`, which parses a
+   * statement carrying the clauses and returns the pair it declares, does not 
give back the same
+   * pair. Names are quoted only where needed, and on a second try all of 
them, which reserved
+   * keywords under `spark.sql.ansi.enforceReservedKeywords` need.
+   */
+  def writeClausesSQL(
+      writeDistributionMode: WriteDistributionMode,
+      writeOrdering: Seq[SortOrder],
+      partitioning: Seq[Transform],
+      replay: String => Option[(WriteDistributionMode, Seq[SortOrder])]): 
Option[String] = {
+    lazy val partitioned = hasPartitioning(partitioning)
+    Seq[String => String](quoteIfNeeded, quoteIdentifier).iterator.flatMap { 
quote =>
+      val orderBy = if (writeOrdering.nonEmpty) {
+        Some(writeOrdering.map(sortOrderToSQL(_, quote)).mkString("ORDERED BY 
(", ", ", ")"))
+      } else {
+        None
+      }
+      (writeDistributionMode, orderBy) match {
+        case (WriteDistributionMode.HASH, Some(o)) if partitioned =>
+          Some(s"DISTRIBUTED BY PARTITION $o")
+        case (WriteDistributionMode.HASH, None) if partitioned => 
Some("DISTRIBUTED BY PARTITION")
+        case (WriteDistributionMode.RANGE, Some(o)) => Some(o)
+        case (WriteDistributionMode.NONE, Some(o)) => Some(s"LOCALLY $o")
+        case (WriteDistributionMode.NONE, None) => Some("UNORDERED")
+        case _ => None
+      }
+    }.find { clauses =>
+      replay(clauses).exists { case (mode, ordering) =>
+        mode == writeDistributionMode && sameSortOrders(ordering, 
writeOrdering)
+      }
+    }
+  }
+
+  /**
+   * True when every column a sort key references is in `schema`, matched as 
`CheckAnalysis`
+   * matches the ordering of a CREATE/REPLACE TABLE statement.
+   */
+  def referencesExist(schema: StructType, writeOrdering: Seq[SortOrder]): 
Boolean = {
+    writeOrdering.flatMap(_.expression().references()).forall { ref =>
+      ref.fieldNames().nonEmpty &&
+        
Try(schema.findNestedField(ref.fieldNames().toImmutableArraySeq)).toOption.flatten.isDefined
+    }
+  }
+
+  /**
+   * True when two orderings declare the same keys: the same column names, 
transform names and
+   * argument structure, literal values and types, directions and null 
orderings.
+   */
+  def sameSortOrders(left: Seq[SortOrder], right: Seq[SortOrder]): Boolean = {
+    left.length == right.length && left.zip(right).forall { case (l, r) =>
+      l.direction() == r.direction() && l.nullOrdering() == r.nullOrdering() &&
+        keyOf(l.expression()) == keyOf(r.expression())
+    }
+  }
+
+  private def keyOf(e: Expression): Any = e match {
+    case r: NamedReference => r.fieldNames().toSeq
+    case l: Literal[_] => Try(CatalystLiteral.create(l.value, 
l.dataType)).toOption
+    case t: IdentityTransform => keyOf(t.ref)
+    case t: Transform => (t.name, t.arguments().toSeq.map(keyOf))
+    case other => other
+  }
+
+  private def sortOrderToSQL(sortOrder: SortOrder, quote: String => String): 
String = {
+    s"${toSQL(sortOrder.expression(), quote)} ${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, and a TIMESTAMP
+  // in UTC with an explicit offset so that it does not depend on the session 
time zone or
+  // timestamp type. A literal Catalyst cannot represent falls back to 
`describe`. Other connector
+  // expressions are rendered from their children: `describe` on one that
+  // `V2ExpressionSQLBuilder` does not know recurses without end. 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, quote: String => String): String = e match {
+    case r: NamedReference => r.fieldNames.map(quote).mkString(".")
+    case l: Literal[_] =>
+      try literalToSQL(CatalystLiteral.create(l.value, l.dataType)) catch {
+        case NonFatal(_) => describeLiteral(l)
+      }
+    case t: IdentityTransform => toSQL(t.ref, quote)
+    case t: Transform =>
+      t.arguments().map(toSQL(_, quote)).mkString(s"${quote(t.name)}(", ", ", 
")")
+    case c: Cast => s"CAST(${toSQL(c.expression(), quote)} AS 
${c.dataType().sql})"
+    case g: GeneralScalarExpression =>
+      g.children().map(toSQL(_, quote)).mkString(s"${g.name}(", ", ", ")")
+    case other =>
+      other.children().map(toSQL(_, 
quote)).mkString(s"${other.getClass.getSimpleName}(", ", ", ")")
+  }
+
+  // `LiteralValue.describe` also rejects a value that does not match its type.
+  private def describeLiteral(l: Literal[_]): String = {
+    try l.describe catch {
+      case NonFatal(_) => String.valueOf(l.value)
+    }
+  }
+
+  private def literalToSQL(l: CatalystLiteral): String = (l.value, l.dataType) 
match {
+    case (f: Float, FloatType) if java.lang.Float.isFinite(f) => s"${f}F"
+    case (micros: Long, TimestampType) =>

Review Comment:
   Minor (preview): point 17 is not complete for the nanosecond timestamps. 
With `spark.sql.timestampNanosTypes.enabled`, a `TIMESTAMP` literal with 7-9 
fractional digits is a `TimestampLTZNanosType`, which this case does not match, 
so it falls to `l.sql` and prints `TIMESTAMP_LTZ '<session-local wall time>'` 
without an offset (literals.scala:702). In America/Los_Angeles, `ORDERED BY 
f(id, TIMESTAMP '2020-11-01 01:30:00.123456789-08:00')` then fails the 
comparison in the DST overlap and drops the clause, and a value outside the 
overlap replays shifted in another session time zone.
   
   Could `TimestampLTZNanosType` also be printed in UTC with a `Z` suffix, 
padded to its precision?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/WriteDistributionAndOrderingUtilsSuite.scala:
##########
@@ -0,0 +1,213 @@
+/*
+ * 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 java.math.BigDecimal
+import java.nio.ByteBuffer
+import java.time.{Duration, Instant, LocalDate, LocalDateTime, LocalTime, 
Period}
+
+import scala.util.Try
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.catalyst.parser.CatalystSqlParser
+import org.apache.spark.sql.catalyst.plans.SQLHelper
+import org.apache.spark.sql.catalyst.plans.logical.CreateTable
+import org.apache.spark.sql.connector.catalog.WriteDistributionMode
+import org.apache.spark.sql.connector.expressions.{Expression, Expressions, 
FieldReference, GeneralScalarExpression, IdentityTransform, NamedReference, 
NullOrdering, SortDirection, SortOrder, Transform}
+import org.apache.spark.sql.connector.expressions.LogicalExpressions.{apply => 
transform, literal, sort}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types._
+
+class WriteDistributionAndOrderingUtilsSuite extends SparkFunSuite with 
SQLHelper {
+  import WriteDistributionAndOrdering._
+
+  private class ConnectorReference(names: String*) extends NamedReference {
+    override def fieldNames(): Array[String] = names.toArray
+  }
+
+  private val id = FieldReference("id")
+
+  private def f(arg: Expression): Transform = transform("f", id, arg)
+
+  private def key(e: Expression): SortOrder =
+    sort(e, SortDirection.ASCENDING, NullOrdering.NULLS_FIRST)
+
+  private def replay(clauses: String): Option[(WriteDistributionMode, 
Seq[SortOrder])] = {
+    Try(CatalystSqlParser.parsePlan(s"CREATE TABLE t (id INT) USING foo 
$clauses")).toOption
+      .collect { case c: CreateTable => (c.writeDistributionMode, 
c.writeOrdering) }
+  }
+
+  private def emitted(e: Expression): Option[String] = {
+    writeClausesSQL(WriteDistributionMode.RANGE, Seq(key(e)), Seq.empty, 
replay)
+  }
+
+  private val spellable: Seq[(String, Expression)] = Seq(
+    "reference" -> id,
+    "connector reference" -> transform("f", new 
ConnectorReference("order-id")),
+    "identity" -> IdentityTransform(id),
+    "byte" -> f(literal(1.toByte)),
+    "short" -> f(literal(1.toShort)),
+    "int" -> f(literal(1)),
+    "long" -> f(literal(1L)),
+    "float" -> f(literal(1.5F)),
+    "double" -> f(literal(1.5D)),
+    "decimal" -> f(literal(new BigDecimal("1.5"))),
+    "string" -> f(literal("x")),
+    "string with escapes" -> f(literal("it's a \\")),
+    "binary" -> f(literal(Array[Byte](1, 2))),
+    "date" -> f(literal(LocalDate.of(1970, 1, 1))),
+    "timestamp" -> f(literal(Instant.parse("2020-11-01T09:30:00Z"))),
+    "timestamp_ntz" -> f(literal(LocalDateTime.of(2020, 1, 1, 10, 0))),
+    "time" -> f(literal(LocalTime.of(12, 0))),
+    "day-time interval" -> f(literal(Duration.ofHours(1))),
+    "year-month interval" -> f(literal(Period.ofMonths(14))),
+    "bucket" -> transform("bucket", literal(16), id),
+    "bucket without columns" -> transform("bucket", literal(16)),
+    "Spark's bucket" -> Expressions.bucket(16, "id"),
+    "days" -> transform("days", id),
+    "unparsed name" -> transform("Days", id, literal("UTC")))
+
+  private val unspellable: Seq[(String, Expression)] = Seq(
+    "nested transform" -> transform("f", transform("g", id)),
+    "true" -> f(literal(true)),
+    "false" -> f(literal(false)),
+    "null" -> f(literal(null)),
+    "typed null" -> f(literal(null, IntegerType)),
+    "decimal with trailing zeros" ->
+      f(literal(new BigDecimal("1.50"), DecimalType(10, 2))),
+    "decimal in exponent form" ->
+      f(literal(new BigDecimal("0.0000001"), DecimalType(10, 7))),
+    "char" -> f(literal("x", CharType(3))),
+    "varchar" -> f(literal("x", VarcharType(3))),
+    "collated string" -> f(literal("x", StringType("UTF8_LCASE"))),
+    "NaN" -> f(literal(Float.NaN)),
+    "infinity" -> f(literal(Double.PositiveInfinity)),
+    "maximal float" -> f(literal(Float.MaxValue)),
+    "time with nanoseconds" -> f(literal(LocalTime.of(12, 0, 0, 100000000), 
TimeType(7))),
+    "time(6) holding a nanosecond" -> f(literal(LocalTime.ofNanoOfDay(1))),
+    "year interval not in whole years" ->
+      f(literal(13, YearMonthIntervalType(YearMonthIntervalType.YEAR))),
+    "hour interval not in whole hours" ->
+      f(literal(Duration.ofMinutes(90).toNanos / 1000, DayTimeIntervalType(
+        DayTimeIntervalType.HOUR))),
+    "negative-scale decimal" -> f(Expressions.literal(new BigDecimal("1E+3"))),
+    "int value with long type" -> f(literal(5, LongType)),
+    "byte buffer value" -> f(literal(ByteBuffer.wrap(Array[Byte](1, 2)), 
BinaryType)),
+    "bucket with columns first" -> transform("bucket", id, literal(16)),
+    "bucket with long count" -> transform("bucket", literal(16L), id),
+    "bucket with literal column" -> transform("bucket", literal(16), 
literal(1)),
+    "days with two arguments" -> transform("days", id, literal("UTC")),
+    "hours of a literal" -> transform("hours", literal(1)))
+
+  test("a sort key is emitted iff its rendering replays as the same key") {
+    assert(spellable.filter { case (_, e) =>
+      !emitted(e).contains(s"ORDERED BY (${describeSortOrder(key(e))})")
+    }.map(_._1) === Seq.empty)
+    assert(unspellable.filter { case (_, e) => emitted(e).isDefined 
}.map(_._1) === Seq.empty)
+  }
+
+  test("a sort key is emitted iff it replays as the same key under parser 
confs") {
+    val timestamp = f(literal(Instant.parse("2020-11-01T09:30:00Z")))
+    val wrong = Seq(
+      (SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles", timestamp, 
true),
+      (SQLConf.TIMESTAMP_TYPE.key -> "TIMESTAMP_NTZ", timestamp, true),
+      (SQLConf.LEGACY_INTERVAL_ENABLED.key -> "true", 
f(literal(Duration.ofHours(1))), false),
+      (SQLConf.LEGACY_INTERVAL_ENABLED.key -> "true", 
f(literal(Period.ofMonths(14))), false),
+      (SQLConf.ESCAPED_STRING_LITERALS.key -> "true", f(literal("it's")), 
false),
+      (SQLConf.ESCAPED_STRING_LITERALS.key -> "true", f(literal("a\\b")), 
false),
+      (SQLConf.ESCAPED_STRING_LITERALS.key -> "true", f(literal("x")), true),
+      (SQLConf.LEGACY_TIME_PARSER_POLICY.key -> "LEGACY",
+        f(literal(LocalDate.of(-1, 1, 1))), false),
+      (SQLConf.LEGACY_FROM_DAYTIME_STRING.key -> "true",
+        f(literal(Duration.ofHours(26), DayTimeIntervalType(
+          DayTimeIntervalType.DAY, DayTimeIntervalType.HOUR))), false)
+    ).filter { case (conf, e, spelled) => 
withSQLConf(conf)(emitted(e).isDefined != spelled) }
+    assert(wrong.map(_._2.describe) === Seq.empty)
+  }
+
+  test("a name that is a reserved keyword is emitted quoted only where the 
parser needs it") {
+    val order = FieldReference("order")
+    assert(emitted(order) === Some("ORDERED BY (order ASC NULLS FIRST)"))
+    withSQLConf(
+        SQLConf.ANSI_ENABLED.key -> "true",
+        SQLConf.ENFORCE_RESERVED_KEYWORDS.key -> "true") {
+      assert(emitted(order) === Some("ORDERED BY (`order` ASC NULLS FIRST)"))
+    }
+  }
+
+  test("the pairs with no clause form are not emitted") {
+    val keys = Seq(key(id))
+    Seq(
+      (WriteDistributionMode.HASH, keys, Seq.empty[Transform]),

Review Comment:
   Minor: these two HASH rows cannot fail. This suite's `replay` (L51) parses 
`CREATE TABLE t (id INT) USING foo $clauses`, which has no `PARTITIONED BY`, so 
the parser rejects every `DISTRIBUTED BY PARTITION ...` rendering and 
`writeClausesSQL` returns None whatever it emits. Production replays with 
`PARTITIONED BY (p)` (ShowCreateTableExec.scala:162). Removing the `if 
partitioned` guards at WriteDistributionAndOrdering.scala:102-104 would still 
pass here, and since `emitted` (L55-57) always uses RANGE, no test in this 
suite emits a HASH or NONE pair.
   
   Could this test use a replay that accepts any clauses, e.g. `_ => 
Some((mode, ordering))`, and could the suite add a positive HASH row such as 
`(HASH, Seq(key(id)), Seq(Expressions.identity("p")))` expecting `DISTRIBUTED 
BY PARTITION ORDERED BY (id ASC NULLS FIRST)`?



-- 
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]

Reply via email to