peter-toth commented on code in PR #58153:
URL: https://github.com/apache/spark/pull/58153#discussion_r3861017115
##########
docs/sql-ref-syntax-ddl-create-table-datasource.md:
##########
@@ -98,6 +106,61 @@ as any order. For example, you can write COMMENT
table_comment after TBLPROPERTI
A list of key-value pairs that is used to tag the table definition.
+* **DISTRIBUTED BY PARTITION**
+
+ Requests that every write to the table be clustered by the table's
partitioning, so each
+ partition is written by a single task rather than by every task that holds
rows for it.
+
+ Requires the table to actually be partitioned, with `PARTITIONED BY` or
with
+ `CLUSTERED BY ... INTO ... BUCKETS`. Note that `CLUSTER BY` -- a different
clause from
+ `CLUSTERED BY ... INTO ... BUCKETS` -- does **not** qualify: it lists
clustering columns for the
+ data source to interpret rather than defining a partitioning, and it
cannot be combined with
+ `PARTITIONED BY` or `CLUSTERED BY ... INTO ... BUCKETS`, so a table using
it has no partitioning
+ to distribute by.
+
+* **ORDERED BY**
+
+ Requests a sort order for every write to the table, recorded on the table
so that later writes
+ honor it too. `UNORDERED` asks for no ordering at all, which is different
from omitting the
+ clause -- omitting it leaves the choice to the data source. The
parentheses are optional:
+ `ORDERED BY (a, b)` and `ORDERED BY a, b` are the same. The sort keys must
resolve against the
+ table's columns, so a `CREATE TABLE` with neither a column list nor `AS
SELECT` cannot use this
+ clause.
+
+ The distribution decides how far the order reaches, and this clause picks
one when
+ `DISTRIBUTED BY PARTITION` is absent: a bare `ORDERED BY` range-partitions
each write, so the
Review Comment:
**Finding 8.** A range distribution orders one write's output across that
write's tasks. It says nothing about the table over time: a later `INSERT INTO`
range-partitions its own rows, so its files overlap the ranges already there
and the table is not sorted end to end.
This round narrowed `TableInfo.DISTRIBUTION_MODE_RANGE`'s javadoc from
"across files, not only within one" to "across write tasks, not only within
one", so the two now disagree and the javadoc is the accurate one. The same
claim is in the SQL comment at `:140`.
The paragraph is about what the clause requests for *every* write ("recorded
on the table so that later writes honor it too"), which is exactly the scope
where the stronger claim fails. "so the order holds across the tasks of a
write, not only within one" matches the javadoc and is what the mechanism
delivers.
I raised the file-vs-task drift on this bullet last round and missed this
one, which is the bigger of the two: it promises a table-level layout guarantee.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateTableExec.scala:
##########
@@ -125,6 +127,56 @@ case class ShowCreateTableExec(
}
}
+ /**
+ * True for a sort key expression the `transformArgument` grammar rule can
represent: a plain
+ * column reference, or a transform whose own arguments are references or
constants.
+ */
+ private def isSpellable(e: V2Expression): Boolean = e match {
+ case _: NamedReference => true
+ case t: Transform =>
+ t.arguments().forall(a => a.isInstanceOf[NamedReference] ||
a.isInstanceOf[Literal[_]])
Review Comment:
**Finding 2.** This closes the case on the parent thread
(`GeneralScalarExpression` is neither a `NamedReference` nor a `Transform`),
but it validates a `Transform`'s *arguments* and never the transform itself.
Two ways through:
- **The name.** `Expressions.apply(String name, Expression... args)` is
public and takes any name, and `ApplyTransform.describe()` renders it unquoted.
`applyTransform : transformName=identifier LEFT_PAREN ...` needs an
`identifier` there.
- **No arguments.** `forall` on an empty `arguments()` is `true`, and the
same rule requires at least one `transformArgument`.
Measured on `92cc5395424`, with a fixture reporting mode `range` and
`Expressions.sort(Expressions.apply("+", Expressions.column("id"),
Expressions.literal(1)), ASCENDING, NULLS_FIRST)`:
CREATE TABLE reportcat.t (
id INT)
USING foo
ORDERED BY (+(id, 1) ASC NULLS FIRST)
Replaying that gives `[PARSE_SYNTAX_ERROR] Syntax error at or near '+'.
SQLSTATE: 42601 (line 4, pos 12)` - the same whole-statement failure as before,
through a narrower door.
```suggestion
t.name().matches("[a-zA-Z_][a-zA-Z0-9_]*") && t.arguments().nonEmpty &&
t.arguments().forall(a => a.isInstanceOf[NamedReference] ||
a.isInstanceOf[Literal[_]])
```
The regex approximates `identifier` rather than matching it: a name that is
a reserved word (`select`) passes and still fails to parse under ANSI. That
hole is far smaller than the current one and I would not chase it, but it is
worth a word in the comment so the next reader knows this is an approximation
rather than a claim.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/CreateTableWriteOrderSuite.scala:
##########
@@ -0,0 +1,786 @@
+/*
+ * 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 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.connector.catalog.{Column, DelegatingTable,
Identifier, InMemoryTable, InMemoryTableCatalog, StagedTable,
StagingInMemoryTableCatalog, Table, TableCatalogCapability, TableInfo}
+import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
+import org.apache.spark.sql.connector.expressions.{FieldReference,
IdentityTransform, LogicalExpressions, NullOrdering, SortDirection, SortOrder}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.IntegerType
+
+/**
+ * Tests the create-time write distribution and ordering clauses:
CREATE/REPLACE TABLE ...
+ * DISTRIBUTED BY PARTITION ... [LOCALLY] ORDERED BY ... | UNORDERED.
+ *
+ * These cover the Spark side only -- what the parser produces, what reaches
the catalog, and how a
+ * catalog that has not advertised support for the clauses is rejected. The
end-to-end coverage
+ * against a catalog that does implement them lives in Iceberg's
TestDistributedAndOrderedTables.
+ */
+class CreateTableWriteOrderSuite extends QueryTest with SharedSparkSession {
+
+ // The catalog manager caches a catalog instance under its name for the
whole session, so without
+ // this a later test would get an earlier test's catalog -- with its tables
and its recordings.
+ override def afterEach(): Unit = {
+ spark.sessionState.catalogManager.reset()
+ super.afterEach()
+ }
+
+ private def parse(sql: String): LogicalPlan =
spark.sessionState.sqlParser.parsePlan(sql)
+
+ /** Analyzes without running the command, so the normalization rules can be
inspected. */
+ 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
+
+ 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().isInstanceOf[IdentityTransform])
+ 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") {
+ // The ordering clause only implies a distribution when DISTRIBUTED BY
PARTITION is absent, so
+ // beside it LOCALLY has no effect and UNORDERED contributes only "no sort
order" -- which is
+ // the whole point of allowing that combination: cluster each write by
partition, unsorted.
+ 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") {
+ // Iceberg's own ALTER TABLE ... WRITE ORDERED BY accepts both, so accept
both here too.
+ 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") {
+ // createTableClauses is an order-insensitive loop, so the two write
clauses are independent
+ // members of it rather than one combined clause -- otherwise anything
between them would count
+ // as a duplicate.
+ 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") {
+ // The check sits in the parser, so it has to be wired up on all four
statement forms.
+ 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") {
+ // The parser cannot know the schema, so ORDERED BY id on a column `ID`
arrives as `id`.
+ // PreprocessTableCreation rewrites it, exactly as it does for the
partitioning -- a connector
+ // that stores the ordering by name would otherwise record a name its own
schema does not have.
+ 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"))
+
+ // and on the CTAS path, where the schema comes from the query
+ assert(orderingOf(analyze(
+ "CREATE TABLE testcat.t USING foo ORDERED BY id AS SELECT 1 AS ID"))
===
+ Seq("ID ASC NULLS FIRST"))
+
+ // A transform Spark does not model is an ApplyTransform, which is not
rewritable, so nothing
+ // can fix its case. Rather than hand the connector a name its schema
does not have, the
+ // reference has to match exactly -- the same rule the partitioning
check applies.
+ 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 ===
"UNSUPPORTED_FEATURE.WRITE_ORDERING_WITH_UNKNOWN_COLUMN")
+ assert(e.getMessageParameters.get("cols") === "`id`")
+
+ // and normalization is per reference, not per transform: one
unresolvable reference must not
+ // stop its siblings being rewritten, or they would be reported as
missing along with it
+ 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`")
+ }
+ }
+
+ test("REPLACE TABLE and RTAS normalize the ordering too") {
+ // Each of the four plans carries its own copy of the ordering and its own
withWriteOrdering, so
+ // covering the CREATE pair above says nothing about the REPLACE pair.
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[RecordingInMemoryTableCatalog].getName) {
+ assert(orderingOf(analyze("REPLACE TABLE testcat.t (ID INT) USING foo
ORDERED BY id")) ===
+ Seq("ID ASC NULLS FIRST"))
+ assert(orderingOf(analyze(
+ "REPLACE TABLE testcat.t USING foo ORDERED BY id AS SELECT 1 AS ID"))
===
+ Seq("ID ASC NULLS FIRST"))
+ }
+ }
+
+ test("a case-sensitive session resolves the ordering case-sensitively") {
+ // Both the normalization guard and the CheckAnalysis check consult the
conf's resolver, so the
+ // strict mode needs its own case: what normalization fixes up above has
to be rejected here.
+ withSQLConf(
+ "spark.sql.catalog.testcat" ->
classOf[RecordingInMemoryTableCatalog].getName,
+ SQLConf.CASE_SENSITIVE.key -> "true") {
+ assert(orderingOf(analyze("CREATE TABLE testcat.t (ID INT) USING foo
ORDERED BY ID")) ===
+ Seq("ID ASC NULLS FIRST"))
+ val e = intercept[AnalysisException] {
+ analyze("CREATE TABLE testcat.t (ID INT) USING foo ORDERED BY id")
+ }
+ assert(e.getCondition ===
"UNSUPPORTED_FEATURE.WRITE_ORDERING_WITH_UNKNOWN_COLUMN")
+ assert(e.getMessageParameters.get("cols") === "`id`")
+ }
+ }
+
+ test("ORDERED BY needs a schema to resolve against") {
+ // With no column list and no query there is nothing to resolve the sort
keys against, so say
+ // that rather than blaming each key for being absent from a schema that
does not exist. Same
+ // position PARTITIONED BY takes on a schemaless CREATE TABLE.
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[RecordingInMemoryTableCatalog].getName) {
+ Seq("ORDERED BY id", "LOCALLY ORDERED BY id").foreach { clause =>
+ val e = intercept[AnalysisException] {
+ analyze(s"CREATE TABLE testcat.t USING foo $clause")
+ }
+ assert(e.getCondition === "SPECIFY_WRITE_ORDERING_IS_NOT_ALLOWED",
s"for $clause")
+ }
+
+ // UNORDERED asks for no ordering at all, so it has nothing to resolve
and stays allowed
+ analyze("CREATE TABLE testcat.t USING foo UNORDERED")
+ }
+ }
+
+ test("an ordering on an unknown column is rejected during analysis") {
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[RecordingInMemoryTableCatalog].getName) {
+ // CheckAnalysis validates the ordering's references the same way it
validates the
+ // partitioning's, so this holds for every transform, not just the
rewritable ones that
+ // PreprocessTableCreation normalizes, and regardless of whether the
schema is defined.
+ Seq(
+ "CREATE TABLE testcat.t (id INT) USING foo ORDERED BY nope",
+ "CREATE TABLE testcat.t (id INT) USING foo ORDERED BY days(nope)",
+ // truncate() is an ApplyTransform, i.e. NOT a RewritableTransform:
this is the case that
+ // PreprocessTableCreation's normalization cannot see at all
+ "CREATE TABLE testcat.t (id INT) USING foo ORDERED BY truncate(4,
nope)",
+ // and on the CTAS path, where the schema comes from the query
+ "CREATE TABLE testcat.t USING foo ORDERED BY nope AS SELECT 1 AS id"
+ ).foreach { stmt =>
+ val e = intercept[AnalysisException](analyze(stmt))
+ assert(e.getCondition ===
"UNSUPPORTED_FEATURE.WRITE_ORDERING_WITH_UNKNOWN_COLUMN", stmt)
+ assert(e.getMessageParameters.get("cols") === "`nope`", stmt)
+ }
+
+ // a nested struct field is fine, though -- a reference through a map or
array key fails
+ // its own way, with INVALID_FIELD_NAME, rather than reaching this
condition
+ analyze("CREATE TABLE testcat.t (p STRUCT<x: INT>) USING foo ORDERED BY
p.x")
+ }
+ }
+
+ test("a repeated ordering column is accepted, unlike a repeated partition
column") {
+ // Reusing SchemaUtils.checkTransformDuplication here would have rejected
two bucket transforms
+ // of different widths, which are legitimately different sort keys. And a
repeated sort key is
+ // redundant rather than contradictory, so nothing needs rejecting.
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[RecordingInMemoryTableCatalog].getName) {
+ assert(orderingOf(analyze("CREATE TABLE testcat.t (id INT) USING foo " +
+ "ORDERED BY bucket(4, id), bucket(8, id)")) ===
+ Seq("bucket(4, id) ASC NULLS FIRST", "bucket(8, id) ASC NULLS FIRST"))
+ analyze("CREATE TABLE testcat.t (id INT) USING foo ORDERED BY id, id
DESC")
+ analyze("CREATE TABLE testcat.t (ts TIMESTAMP) USING foo ORDERED BY
days(ts), hours(ts)")
+ }
+ }
+
+ test("DISTRIBUTED BY PARTITION is not satisfied by CLUSTER BY") {
+ // CLUSTER BY lands in `partitioning` as a ClusterByTransform, but it
carries clustering columns
+ // for the connector to interpret rather than a partition spec, so there
is nothing to
+ // distribute by. Bucketing does count -- a bucket transform is a real
partition transform.
+ val e = intercept[ParseException] {
+ parse("CREATE TABLE t (id INT, c STRING) USING foo CLUSTER BY (c)
DISTRIBUTED BY PARTITION")
+ }
+ assert(e.getCondition ===
+ "SPECIFY_DISTRIBUTED_BY_PARTITION_WITHOUT_PARTITIONING_IS_NOT_ALLOWED")
+
+ parse("CREATE TABLE t (id INT, c STRING) USING foo CLUSTERED BY (c) INTO 4
BUCKETS " +
+ "DISTRIBUTED BY PARTITION") match {
+ case c: CreateTable => assert(c.writeDistributionMode === "hash")
+ case other => fail(s"unexpected plan: $other")
+ }
+ }
+
+ test("CREATE TABLE / CTAS / REPLACE TABLE / RTAS hand the distribution and
ordering " +
+ "to the catalog") {
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[RecordingInMemoryTableCatalog].getName) {
+ sql("CREATE TABLE testcat.t (id INT, c STRING) USING foo PARTITIONED BY
(c) " +
+ "DISTRIBUTED BY PARTITION ORDERED BY id DESC")
+ sql("CREATE TABLE testcat.ctas USING foo ORDERED BY id AS SELECT 1 AS
id")
+ // a non-staging catalog replaces by dropping and re-creating, so this
is createTable as well
+ sql("REPLACE TABLE testcat.t (id INT) USING foo LOCALLY ORDERED BY id")
+ // RTAS builds its TableInfo in an exec of its own, so it needs its own
case
+ sql("REPLACE TABLE testcat.t USING foo PARTITIONED BY (c) DISTRIBUTED BY
PARTITION " +
+ "ORDERED BY id AS SELECT 1 AS id, 'a' AS c")
+
+ assert(calls("testcat") === Seq(
+ WriteSpecCall("createTable", "t", "hash", Seq("id DESC NULLS LAST")),
+ WriteSpecCall("createTable", "ctas", "range", Seq("id ASC NULLS
FIRST")),
+ WriteSpecCall("createTable", "t", "none", Seq("id ASC NULLS FIRST")),
+ WriteSpecCall("createTable", "t", "hash", Seq("id ASC NULLS FIRST"))))
+ }
+ }
+
+ test("a statement with no write clause carries neither a distribution nor an
ordering") {
+ // The TableInfo a plain statement builds leaves both unset, so a catalog
can tell "the user
+ // said nothing" from "the user asked for none" without Spark guessing.
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[RecordingInMemoryTableCatalog].getName) {
+ sql("CREATE TABLE testcat.plain (id INT) USING foo")
+ sql("CREATE TABLE testcat.plain_ctas USING foo AS SELECT 1 AS id")
+ sql("REPLACE TABLE testcat.plain (id INT) USING foo")
+
+ assert(calls("testcat") === Seq(
+ WriteSpecCall("createTable", "plain", null, Seq.empty),
+ WriteSpecCall("createTable", "plain_ctas", null, Seq.empty),
+ WriteSpecCall("createTable", "plain", null, Seq.empty)))
+ }
+ }
+
+ test("the staging catalog gets them on stageCreate / stageReplace /
stageCreateOrReplace") {
+ // Iceberg's SparkCatalog is a StagingTableCatalog, so an atomic CTAS goes
through stageCreate
+ // rather than createTable. That is a separate set of default methods,
each with its own
+ // argument list to get right.
+ val catalogClass = classOf[RecordingStagingInMemoryTableCatalog].getName
+ withSQLConf("spark.sql.catalog.stagingcat" -> catalogClass) {
+ sql("CREATE TABLE stagingcat.t USING foo ORDERED BY id AS SELECT 1 AS
id")
+ sql("REPLACE TABLE stagingcat.t USING foo LOCALLY ORDERED BY id AS
SELECT 2 AS id")
+ sql("CREATE OR REPLACE TABLE stagingcat.t USING foo UNORDERED AS SELECT
3 AS id")
+ sql("REPLACE TABLE stagingcat.t (id INT) USING foo ORDERED BY id DESC
NULLS FIRST")
+ sql("CREATE OR REPLACE TABLE stagingcat.t (id INT) USING foo LOCALLY
ORDERED BY id")
+ // a plain statement reaches the same method, carrying neither value
+ sql("CREATE TABLE stagingcat.plain USING foo AS SELECT 1 AS id")
+
+ assert(calls("stagingcat") === Seq(
+ WriteSpecCall("stageCreate", "t", "range", Seq("id ASC NULLS FIRST")),
+ WriteSpecCall("stageReplace", "t", "none", Seq("id ASC NULLS FIRST")),
+ WriteSpecCall("stageCreateOrReplace", "t", "none", Seq.empty),
+ WriteSpecCall("stageReplace", "t", "range", Seq("id DESC NULLS
FIRST")),
+ WriteSpecCall("stageCreateOrReplace", "t", "none", Seq("id ASC NULLS
FIRST")),
+ WriteSpecCall("stageCreate", "plain", null, Seq.empty)))
+ }
+ }
+
+ test("a staging catalog sees an unstaged CREATE TABLE through createTable") {
+ // A CREATE TABLE with an explicit column list is not staged: it goes
through CreateTableExec,
+ // so a staging catalog has to read the request from createTable as well
as the three stage*
+ // methods. StagingTableCatalog's javadoc says so; this pins it.
+ val catalogClass = classOf[RecordingStagingInMemoryTableCatalog].getName
+ withSQLConf("spark.sql.catalog.stagingcat" -> catalogClass) {
+ sql("CREATE TABLE stagingcat.t (id INT) USING foo ORDERED BY id")
+
+ assert(calls("stagingcat") ===
+ Seq(WriteSpecCall("createTable", "t", "range", Seq("id ASC NULLS
FIRST"))))
+ }
+ }
+
+ test("a catalog that does not advertise the capability fails loudly") {
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[InMemoryTableCatalog].getName) {
+ // plain CREATE TABLE keeps working -- no regression for catalogs that
ignore the new args
+ sql("CREATE TABLE testcat.plain (id INT) USING foo")
+ assert(sql("SHOW TABLES IN testcat").count() === 1)
+
+ Seq(
+ ("CREATE TABLE testcat.ordered (id INT) USING foo LOCALLY ORDERED BY
id",
+ "ordered", "CREATE TABLE"),
+ ("CREATE TABLE testcat.dist (id INT, c STRING) USING foo PARTITIONED
BY (c) " +
+ "DISTRIBUTED BY PARTITION", "dist", "CREATE TABLE"),
+ ("CREATE TABLE testcat.ctas USING foo ORDERED BY id AS SELECT 1 AS id",
+ "ctas", "CREATE TABLE AS SELECT"),
+ ("REPLACE TABLE testcat.plain (id INT) USING foo ORDERED BY id",
+ "plain", "REPLACE TABLE"),
+ ("REPLACE TABLE testcat.plain USING foo ORDERED BY id AS SELECT 1 AS
id",
+ "plain", "REPLACE TABLE AS SELECT")
+ ).foreach { case (stmt, table, operation) =>
+ checkError(
+ exception = intercept[AnalysisException](sql(stmt)),
+ condition = "UNSUPPORTED_FEATURE.TABLE_OPERATION",
+ parameters = Map(
+ "tableName" -> s"`testcat`.`$table`",
+ "operation" -> s"$operation ... DISTRIBUTED BY/ORDERED BY"))
+ }
+
+ // nothing was created, and the pre-existing table is untouched
+ assert(sql("SHOW TABLES IN testcat").count() === 1)
+ }
+ }
+
+ test("UNORDERED is a request too, not a no-op") {
+ // A catalog's own default may well be a distribution; only the catalog
knows. So asking for
+ // none is asking for something, and a catalog that cannot record it must
say so rather than
+ // hand back a table that still uses its default.
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[InMemoryTableCatalog].getName) {
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql("CREATE TABLE testcat.unordered (id INT) USING foo UNORDERED")
+ },
+ condition = "UNSUPPORTED_FEATURE.TABLE_OPERATION",
+ parameters = Map(
+ "tableName" -> "`testcat`.`unordered`",
+ "operation" -> "CREATE TABLE ... DISTRIBUTED BY/ORDERED BY"))
+ assert(sql("SHOW TABLES IN testcat").count() === 0)
+ }
+ }
+
+ test("REPLACE TABLE is rejected before the existing table is dropped") {
+ // ReplaceTableExec drops and re-creates, so a check inside createTable
would come too late:
+ // the table would already be gone. The capability check runs while
planning instead.
+ withSQLConf("spark.sql.catalog.testcat" ->
classOf[InMemoryTableCatalog].getName) {
+ sql("CREATE TABLE testcat.t (id INT) USING foo")
+ sql("INSERT INTO testcat.t VALUES (1)")
+
+ val e = intercept[AnalysisException] {
+ sql("REPLACE TABLE testcat.t (id INT) USING foo ORDERED BY id")
+ }
+ assert(e.getCondition === "UNSUPPORTED_FEATURE.TABLE_OPERATION")
+ checkAnswer(sql("SELECT * FROM testcat.t"), Row(1))
+ }
+ }
+
+ test("the v1 session-catalog path also fails loudly instead of dropping the
clause") {
+ Seq("ORDERED BY id", "LOCALLY ORDERED BY id", "UNORDERED").foreach {
clause =>
+ withTable("v1_ordered") {
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql(s"CREATE TABLE v1_ordered (id INT) USING parquet $clause")
+ },
+ condition = "UNSUPPORTED_FEATURE.TABLE_OPERATION",
+ parameters = Map(
+ "tableName" -> "`spark_catalog`.`default`.`v1_ordered`",
+ "operation" -> "CREATE TABLE ... DISTRIBUTED BY/ORDERED BY"))
+ }
+ }
+
+ // the CTAS branch of the v1 conversion is a separate call site
+ withTable("v1_ctas") {
+ checkError(
+ exception = intercept[AnalysisException] {
+ sql("CREATE TABLE v1_ctas USING parquet ORDERED BY id AS SELECT 1 AS
id")
+ },
+ condition = "UNSUPPORTED_FEATURE.TABLE_OPERATION",
+ parameters = Map(
+ "tableName" -> "`spark_catalog`.`default`.`v1_ctas`",
+ "operation" -> "CREATE TABLE AS SELECT ... DISTRIBUTED BY/ORDERED
BY"))
+ }
+ }
+
+ test("CREATE TEMPORARY TABLE ... USING cannot carry the clauses") {
+ // This builds a temp view, which has nowhere to record either, so it must
not silently drop
+ // the clause.
+ Seq("ORDERED BY id", "UNORDERED").foreach { clause =>
+ val e = intercept[ParseException] {
+ parse(s"CREATE TEMPORARY TABLE t (id INT) USING parquet $clause")
+ }
+ assert(e.getCondition === "INVALID_STATEMENT_OR_CLAUSE", s"for $clause")
+ assert(e.getMessageParameters.get("operation") ===
+ "CREATE TEMPORARY TABLE ... DISTRIBUTED BY/ORDERED BY", s"for $clause")
+ }
+ }
+
+ test("a Table realized from a TableInfo reports the declared default") {
+ // The accessors are a declared default for later writes -- not a claim
about existing data.
+ // DelegatingTable is how Spark realizes a TableInfo whose catalog has no
Table of its own, so
+ // it has to surface them the same way it surfaces columns, partitioning
and constraints.
+ val writeOrdering = Array(LogicalExpressions.sort(
+ FieldReference("id"), SortDirection.DESCENDING, NullOrdering.NULLS_LAST))
+ val info = new TableInfo.Builder()
+ .withColumns(Array(Column.create("id", IntegerType)))
+ .withWriteDistributionMode(TableInfo.DISTRIBUTION_MODE_HASH)
+ .withWriteOrdering(writeOrdering)
+ .build()
+
+ val table: Table = new DelegatingTable(info, "t")
+ assert(table.writeDistributionMode() === TableInfo.DISTRIBUTION_MODE_HASH)
+ assert(WriteSpecCall.render(table.writeOrdering().toSeq) === Seq("id DESC
NULLS LAST"))
+
+ // and a table that declares nothing keeps the defaults, so an unaware
catalog is unaffected
+ val plain: Table = new DelegatingTable(
+ new TableInfo.Builder().withColumns(Array(Column.create("id",
IntegerType))).build(), "t")
+ assert(plain.writeDistributionMode() === null)
+ assert(plain.writeOrdering().isEmpty)
+ }
+
+ test("SHOW CREATE TABLE reproduces the clauses and DESCRIBE EXTENDED reports
them") {
+ // A table the clauses cannot be recovered from is a table you cannot
recreate, so the accessors
+ // have to reach both display paths -- the same two `constraints()`
already reaches.
+ withSQLConf("spark.sql.catalog.reportcat" ->
classOf[ReportingInMemoryTableCatalog].getName) {
+ Seq(
+ ("ORDERED BY (id DESC)", "ORDERED BY (id DESC NULLS LAST)"),
+ ("LOCALLY ORDERED BY (id)", "LOCALLY ORDERED BY (id ASC NULLS FIRST)"),
+ ("UNORDERED", "UNORDERED"),
+ ("PARTITIONED BY (c) DISTRIBUTED BY PARTITION", "DISTRIBUTED BY
PARTITION"),
+ ("PARTITIONED BY (c) DISTRIBUTED BY PARTITION ORDERED BY (id)",
+ "DISTRIBUTED BY PARTITION ORDERED BY (id ASC NULLS FIRST)")
+ ).foreach { case (clauses, expected) =>
+ withTable("reportcat.t") {
+ sql(s"CREATE TABLE reportcat.t (id INT, c STRING) USING foo
$clauses")
+ val ddl = sql("SHOW CREATE TABLE reportcat.t").head().getString(0)
+ assert(ddl.contains(expected), s"for $clauses, got:\n$ddl")
+ }
+ }
+
+ // and a table that declares nothing gains no clause
+ withTable("reportcat.plain") {
+ sql("CREATE TABLE reportcat.plain (id INT) USING foo")
+ val ddl = sql("SHOW CREATE TABLE reportcat.plain").head().getString(0)
+ assert(!ddl.contains("ORDERED BY") && !ddl.contains("DISTRIBUTED BY"),
ddl)
+ }
+
+ // DESCRIBE reports the values verbatim, so it covers the pairs SHOW
CREATE TABLE cannot spell
+ withTable("reportcat.t") {
+ sql("CREATE TABLE reportcat.t (id INT, c STRING) USING foo " +
+ "PARTITIONED BY (c) DISTRIBUTED BY PARTITION ORDERED BY (id DESC)")
+ val described = sql("DESCRIBE TABLE EXTENDED reportcat.t").collect()
+ .map(r => r.getString(0) -> r.getString(1)).toMap
+ assert(described.get("Distribution") === Some("hash"))
+ assert(described.get("Ordering") === Some("id DESC NULLS LAST"))
+ }
+ withTable("reportcat.plain") {
+ sql("CREATE TABLE reportcat.plain (id INT) USING foo")
+ val described = sql("DESCRIBE TABLE EXTENDED
reportcat.plain").collect().map(_.getString(0))
+ assert(!described.contains("# Write Distribution and Ordering"))
+ }
+ }
+ }
+
+ test("SHOW CREATE TABLE omits a pair the syntax cannot spell, and stays
runnable") {
+ // A connector can report combinations no statement could have asked for
-- `hash` without any
+ // partitioning is the dangerous one, because DISTRIBUTED BY PARTITION
does not parse there, so
+ // emitting it would break the whole statement rather than just lose a
clause.
+ withSQLConf("spark.sql.catalog.reportcat" ->
classOf[ReportingInMemoryTableCatalog].getName) {
+ Seq(
+ // (fabricated mode, clauses, what must not appear)
+ ("hash", "", "DISTRIBUTED BY"),
+ ("hash", "ORDERED BY (id)", "DISTRIBUTED BY"),
+ ("range", "", "ORDERED BY"),
+ ("zigzag", "", "ORDERED BY")
+ ).foreach { case (mode, clauses, absent) =>
+ withTable("reportcat.t") {
+ sql(s"CREATE TABLE reportcat.t (id INT) USING foo $clauses " +
+ s"TBLPROPERTIES ('${ReportingInMemoryTable.MODE_OVERRIDE}' =
'$mode')")
+ val ddl = sql("SHOW CREATE TABLE reportcat.t").head().getString(0)
+ assert(!ddl.contains(absent), s"for mode=$mode clauses=[$clauses],
got:\n$ddl")
+
+ // and what it does emit has to parse -- otherwise the table cannot
be recreated at all
+ sql(s"DROP TABLE reportcat.t")
+ sql(ddl)
+ assert(sql("SHOW CREATE TABLE reportcat.t").head().getString(0) ===
ddl)
+ }
+ }
+
+ // DESCRIBE still reports the value, so an omitted clause is not a
hidden one
+ withTable("reportcat.t") {
+ sql("CREATE TABLE reportcat.t (id INT) USING foo " +
+ s"TBLPROPERTIES ('${ReportingInMemoryTable.MODE_OVERRIDE}' =
'range')")
+ val described = sql("DESCRIBE TABLE EXTENDED reportcat.t").collect()
+ .map(r => r.getString(0) -> r.getString(1)).toMap
+ assert(described.get("Distribution") === Some("range"))
+ }
+ }
+ }
+
+ test("the new keywords stay usable as identifiers") {
+ // The four keywords added for these clauses (DISTRIBUTED, LOCALLY,
ORDERED, UNORDERED) are
+ // non-reserved, so tables and columns may still be named after them. This
is the actual risk of
+ // adding keywords to the shared lexer, so pin it down -- in ANSI mode
too, since that is where
+ // the reserved/non-reserved split actually bites and the four are added
to `ansiNonReserved`.
+ Seq(false, true).foreach { ansi =>
+ withSQLConf(SQLConf.ANSI_ENABLED.key -> ansi.toString) {
+ withTable("ordered", "unordered") {
+ sql("CREATE TABLE ordered (distributed INT, locally INT, ordered
INT, unordered INT) " +
+ "USING parquet")
+ sql("INSERT INTO ordered VALUES (1, 2, 3, 4)")
+ checkAnswer(
+ sql("SELECT distributed, locally, ordered, unordered FROM
ordered"),
+ Row(1, 2, 3, 4))
+ // also as a table name, an alias and a qualified reference
+ checkAnswer(sql("SELECT unordered.ordered FROM ordered AS
unordered"), Row(3))
+ sql("CREATE TABLE unordered (ordered INT) USING parquet")
+ checkAnswer(sql("SELECT count(*) FROM unordered"), Row(0))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * One catalog call, with the requested write distribution and ordering
rendered as text. An
+ * in-memory table has nowhere to store either, so recording the calls is the
only way to check
+ * that the values arrive at the connector at all, and in the right argument
positions.
+ */
+case class WriteSpecCall(
+ method: String,
+ table: String,
+ writeDistributionMode: String,
+ writeOrdering: Seq[String])
+
+object WriteSpecCall {
+ def render(writeOrdering: Seq[SortOrder]): Seq[String] = {
+ writeOrdering.map(o => s"${o.expression().describe()} ${o.direction()}
${o.nullOrdering()}")
+ }
+
+ def apply(
+ method: String,
+ ident: Identifier,
+ writeDistributionMode: String,
+ writeOrdering: Array[SortOrder]): WriteSpecCall = {
+ WriteSpecCall(method, ident.name, writeDistributionMode,
render(writeOrdering.toSeq))
+ }
+}
+
+/** Adds the create-time write distribution and ordering capability to a
catalog's own set. */
+object WriteSpecCapability {
+ def add(
+ capabilities: util.Set[TableCatalogCapability]):
util.Set[TableCatalogCapability] = {
+ (capabilities.asScala.toSet +
+
TableCatalogCapability.SUPPORTS_CREATE_TABLE_WITH_WRITE_DISTRIBUTION_AND_ORDERING).asJava
+ }
+}
+
+trait RecordsWriteSpecs {
+ private val calls = new ArrayBuffer[WriteSpecCall]
+
+ def recordedCalls: Seq[WriteSpecCall] = calls.toSeq
+
+ protected def record(method: String, ident: Identifier, tableInfo:
TableInfo): Unit = {
+ calls += WriteSpecCall(
+ method, ident, tableInfo.writeDistributionMode(),
tableInfo.writeOrdering())
+ }
+}
+
+/** A catalog that supports the create-time write distribution and ordering
and records both. */
+class RecordingInMemoryTableCatalog extends InMemoryTableCatalog with
RecordsWriteSpecs {
+
+ override def capabilities: util.Set[TableCatalogCapability] =
+ WriteSpecCapability.add(super.capabilities)
+
+ override def createTable(ident: Identifier, tableInfo: TableInfo): Table = {
+ record("createTable", ident, tableInfo)
+ super.createTable(ident, tableInfo)
+ }
+}
+
+/** The same, for the staging path that an atomic CTAS/RTAS takes. */
+class RecordingStagingInMemoryTableCatalog
+ extends StagingInMemoryTableCatalog with RecordsWriteSpecs {
+
+ override def capabilities: util.Set[TableCatalogCapability] =
+ WriteSpecCapability.add(super.capabilities)
+
+ // A staging catalog needs this one too: a CREATE/REPLACE TABLE with an
explicit column list is
+ // not staged, so it arrives here rather than at any of the stage* methods.
+ override def createTable(ident: Identifier, tableInfo: TableInfo): Table = {
+ record("createTable", ident, tableInfo)
+ super.createTable(ident, tableInfo)
+ }
+
+ override def stageCreate(ident: Identifier, tableInfo: TableInfo):
StagedTable = {
+ record("stageCreate", ident, tableInfo)
+ super.stageCreate(ident, tableInfo)
+ }
+
+ override def stageReplace(ident: Identifier, tableInfo: TableInfo):
StagedTable = {
+ record("stageReplace", ident, tableInfo)
+ super.stageReplace(ident, tableInfo)
+ }
+
+ override def stageCreateOrReplace(ident: Identifier, tableInfo: TableInfo):
StagedTable = {
+ record("stageCreateOrReplace", ident, tableInfo)
+ super.stageCreateOrReplace(ident, tableInfo)
+ }
+}
+
+/**
+ * An in-memory table that reports the declared write distribution and
ordering back, the way a
+ * connector that stores them does. The plain InMemoryTable drops everything a
TableInfo carries
+ * beyond columns, partitioning and constraints, so nothing in-tree can
exercise the display paths.
+ */
+class ReportingInMemoryTable(tableName: String, tableInfo: TableInfo)
+ extends InMemoryTable(
+ tableName,
+ tableInfo.columns(),
+ tableInfo.partitions(),
+ tableInfo.properties(),
+ tableInfo.constraints()) {
+
+ // A (mode, ordering) pair the syntax cannot express -- `hash` with no
partitioning, say -- can
+ // still reach Spark from a connector, and this property is the only way a
test can build one,
+ // since every route through the parser is closed by design.
+ override def writeDistributionMode(): String = {
+ Option(tableInfo.properties().get(ReportingInMemoryTable.MODE_OVERRIDE))
+ .getOrElse(tableInfo.writeDistributionMode())
+ }
+
+ override def writeOrdering(): Array[SortOrder] = tableInfo.writeOrdering()
Review Comment:
**Finding 7.** Nothing pins the guard finding 2 asked for. Measured in a
review worktree on this head: replace the `if` at
`ShowCreateTableExec.scala:154` with `if (true)` and `sql/testOnly
*CreateTableWriteOrderSuite` still reports `Tests: succeeded 30, failed 0`.
This line is why. Every ordering the suite can build arrives through
`tableInfo.writeOrdering()`, so it comes from the parser, and every
parser-produced sort key is a `NamedReference` or a `Transform` over references
and literals - `isSpellable` is true in all 30 tests. `MODE_OVERRIDE` gave the
distribution side a way to fabricate a value no statement could ask for; the
ordering side has no equivalent, which is also why finding 2's remaining hole
went unnoticed.
Same shape as `MODE_OVERRIDE`:
```scala
override def writeOrdering(): Array[SortOrder] = {
if
(tableInfo.properties().containsKey(ReportingInMemoryTable.ORDERING_OVERRIDE)) {
// A sort key `writeOrderField : transform ...` cannot represent.
Array(Expressions.sort(
Expressions.apply("+", Expressions.column("id"),
Expressions.literal(1)),
SortDirection.ASCENDING,
NullOrdering.NULLS_FIRST))
} else {
tableInfo.writeOrdering()
}
}
```
Two assertions are worth having, one per trap named on the finding 2 thread:
- mode `range`: no `ORDERED BY` in the output, and the emitted DDL still
runs;
- mode `none`: no `UNORDERED` either. That is the case where bailing out of
the `orderBy` half alone would declare "no ordering" on a table that has one,
and it is the half a test written only against `range` would miss.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableInfo.java:
##########
@@ -82,6 +141,26 @@ public Builder withConstraints(Constraint[] constraints) {
return this;
}
+ /**
+ * Sets the requested write distribution. See {@link
TableInfo#writeDistributionMode()}.
+ *
+ * @since 4.4.0
+ */
+ public Builder withWriteDistributionMode(String writeDistributionMode) {
+ this.writeDistributionMode = writeDistributionMode;
+ return this;
+ }
+
+ /**
+ * Sets the requested write ordering. See {@link
TableInfo#writeOrdering()}.
+ *
+ * @since 4.4.0
+ */
+ public Builder withWriteOrdering(SortOrder[] writeOrdering) {
Review Comment:
**Finding 9.** `TableInfo` now carries two more fields that `CREATE TABLE
... LIKE` could copy from its source, and `CreateTableLikeExec` does not. That
is the right call - it passes `sourceTable` to `TableCatalog.createTableLike`,
so the connector has everything, and its own doc already names Iceberg sort
order as the example - but the exec's class doc enumerates what the `TableInfo`
carries:
columns and partitioning copied from the source, constraints copied from
the source, user-specified TBLPROPERTIES / LOCATION / USING provider ...
so the omission now reads as an oversight
(`sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateTableLikeExec.scala:43-48`).
A connector author comparing against `constraints`, which *is* copied, will
expect the same treatment. One sentence there - the declared write distribution
and ordering are deliberately not copied, read them from `sourceTable` - saves
the next reader deriving it from this PR.
--
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]