This is an automated email from the ASF dual-hosted git repository.

szehon-ho pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 53a86256e2 Spark 4.2: Support CREATE TABLE ... LIKE ... (#17954)
53a86256e2 is described below

commit 53a86256e24dda81733630b37e561787551cf81f
Author: Rahul Shivu Mahadev <[email protected]>
AuthorDate: Wed Oct 14 15:27:45 2026 -0700

    Spark 4.2: Support CREATE TABLE ... LIKE ... (#17954)
---
 docs/docs/spark-ddl.md                             |  15 +-
 .../org/apache/iceberg/spark/SparkCatalog.java     | 139 ++++++++-
 .../apache/iceberg/spark/SparkSessionCatalog.java  |  28 ++
 .../apache/iceberg/spark/source/SparkTable.java    |   5 +
 .../iceberg/spark/TestSparkSessionCatalog.java     | 129 ++++++++
 .../apache/iceberg/spark/sql/TestCreateTable.java  | 324 +++++++++++++++++++++
 6 files changed, 636 insertions(+), 4 deletions(-)

diff --git a/docs/docs/spark-ddl.md b/docs/docs/spark-ddl.md
index 9fa6c0e7d3..09c97bffdb 100644
--- a/docs/docs/spark-ddl.md
+++ b/docs/docs/spark-ddl.md
@@ -44,7 +44,20 @@ Table create commands, including CTAS and RTAS, support the 
full range of Spark
 
 Create commands may also set the default format with the `USING` clause. This 
is only supported for `SparkCatalog` because Spark handles the `USING` clause 
differently for the built-in catalog.
 
-`CREATE TABLE ... LIKE ...` syntax is not supported.
+Spark 4.2 and later can create an Iceberg table from an existing table:
+
+```sql
+CREATE TABLE prod.db.sample_copy LIKE prod.db.sample;
+```
+
+`CREATE TABLE ... LIKE ...` copies the source schema, partitioning, sort 
order, and
+table properties. The new table does not copy snapshots, data, metadata 
history,
+or the source table location. Unless `LOCATION` is specified for the new table,
+the target catalog assigns its default location.
+
+Copying column default values from a source that is not an Iceberg table is not
+supported yet, so `CREATE TABLE ... LIKE ...` fails if such a source has a 
column
+with a default value.
 
 ### `PARTITIONED BY`
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java
index 9e2e7138e0..a0bdfea817 100644
--- a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java
+++ b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java
@@ -35,8 +35,14 @@ import org.apache.iceberg.CatalogUtil;
 import org.apache.iceberg.EnvironmentContext;
 import org.apache.iceberg.HasTableOperations;
 import org.apache.iceberg.MetadataTableType;
+import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
+import org.apache.iceberg.SortField;
+import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.TableUtil;
 import org.apache.iceberg.Transaction;
+import org.apache.iceberg.UnboundPartitionSpec;
 import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.SupportsNamespaces;
@@ -44,6 +50,7 @@ import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.catalog.ViewCatalog;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.expressions.Expressions;
 import org.apache.iceberg.hadoop.HadoopCatalog;
 import org.apache.iceberg.hadoop.HadoopTables;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
@@ -60,6 +67,7 @@ import org.apache.iceberg.spark.source.SparkChangelogTable;
 import org.apache.iceberg.spark.source.SparkTable;
 import org.apache.iceberg.spark.source.SparkView;
 import org.apache.iceberg.spark.source.StagedSparkTable;
+import org.apache.iceberg.types.TypeUtil;
 import org.apache.iceberg.util.Pair;
 import org.apache.iceberg.util.PropertyUtil;
 import org.apache.iceberg.view.UpdateViewProperties;
@@ -133,6 +141,14 @@ public class SparkCatalog extends BaseCatalog {
           ViewProperties.METADATA_COMPRESSION,
           ViewProperties.WRITE_METADATA_LOCATION,
           ViewProperties.REPLACE_DROP_DIALECT_ALLOWED);
+  private static final Set<String> COLUMN_PROPERTY_PREFIXES =
+      ImmutableSet.of(
+          TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX,
+          TableProperties.PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX,
+          TableProperties.PARQUET_BLOOM_FILTER_COLUMN_FPP_PREFIX,
+          TableProperties.PARQUET_BLOOM_FILTER_COLUMN_NDV_PREFIX,
+          TableProperties.PARQUET_COLUMN_STATS_ENABLED_PREFIX,
+          TableProperties.PARQUET_DICT_ENCODING_ENABLED_COLUMN_PREFIX);
 
   private enum ViewCommit {
     CREATE,
@@ -204,13 +220,74 @@ public class SparkCatalog extends BaseCatalog {
   public Table createTable(Identifier ident, TableInfo tableInfo)
       throws TableAlreadyExistsException, NoSuchNamespaceException {
     Schema icebergSchema = SparkSchemaUtil.convertWithDefaults(tableInfo);
+    return createTable(
+        ident,
+        icebergSchema,
+        Spark3Util.toPartitionSpec(icebergSchema, tableInfo.partitions()),
+        tableInfo.properties(),
+        SortOrder.unsorted());
+  }
+
+  @Override
+  public Table createTableLike(Identifier ident, TableInfo tableInfo, Table 
sourceTable)
+      throws TableAlreadyExistsException, NoSuchNamespaceException {
+    // Spark intentionally excludes the source table's properties from 
tableInfo and leaves it to
+    // the connector to decide which to clone via sourceTable. Clone the 
source Iceberg table's
+    // schema, partition spec, properties and sort order, then let 
user-specified LIKE options (in
+    // tableInfo) take precedence.
+    Schema icebergSchema;
+    PartitionSpec spec;
+    Map<String, String> properties = Maps.newHashMap();
+    SortOrder sortOrder = SortOrder.unsorted();
+
+    if (sourceTable instanceof SparkTable sparkTable) {
+      org.apache.iceberg.Table sourceIcebergTable = sparkTable.table();
+      icebergSchema = sparkTable.icebergSchema();
+      spec = copyPartitionSpec(icebergSchema, sourceIcebergTable.spec());
+      properties.putAll(
+          copyColumnProperties(
+              sourceIcebergTable.properties(), sourceIcebergTable.schema(), 
icebergSchema));
+      properties.remove(TableProperties.WRITE_METADATA_LOCATION);
+      properties.remove(TableProperties.WRITE_DATA_LOCATION);
+      properties.remove(TableProperties.OBJECT_STORE_PATH);
+      properties.remove(TableProperties.WRITE_FOLDER_STORAGE_LOCATION);
+      properties.remove(TableProperties.DEFAULT_NAME_MAPPING);
+      properties.put(
+          TableProperties.FORMAT_VERSION,
+          String.valueOf(TableUtil.formatVersion(sourceIcebergTable)));
+      sortOrder = copySortOrder(icebergSchema, sourceIcebergTable.sortOrder());
+    } else {
+      // Spark passes the column defaults of a non-Iceberg source as SQL text 
only, which
+      // convertWithDefaults rejects, so cloning a source column with a 
default fails for now
+      icebergSchema = SparkSchemaUtil.convertWithDefaults(tableInfo);
+      spec = Spark3Util.toPartitionSpec(icebergSchema, tableInfo.partitions());
+    }
+
+    String provider = tableInfo.properties().get(TableCatalog.PROP_PROVIDER);
+    if (provider != null && !"iceberg".equalsIgnoreCase(provider)) {
+      properties.remove(TableProperties.DEFAULT_FILE_FORMAT);
+    }
+
+    properties.putAll(tableInfo.properties());
+
+    return createTable(ident, icebergSchema, spec, properties, sortOrder);
+  }
+
+  private Table createTable(
+      Identifier ident,
+      Schema icebergSchema,
+      PartitionSpec spec,
+      Map<String, String> properties,
+      SortOrder sortOrder)
+      throws TableAlreadyExistsException {
     try {
       Catalog.TableBuilder builder = newBuilder(ident, icebergSchema);
       org.apache.iceberg.Table icebergTable =
           builder
-              .withPartitionSpec(Spark3Util.toPartitionSpec(icebergSchema, 
tableInfo.partitions()))
-              .withLocation(tableInfo.properties().get("location"))
-              
.withProperties(Spark3Util.rebuildCreateProperties(tableInfo.properties()))
+              .withPartitionSpec(spec)
+              .withSortOrder(sortOrder)
+              .withLocation(properties.get("location"))
+              .withProperties(Spark3Util.rebuildCreateProperties(properties))
               .create();
       return new SparkTable(icebergTable);
     } catch (AlreadyExistsException e) {
@@ -218,6 +295,62 @@ public class SparkCatalog extends BaseCatalog {
     }
   }
 
+  /**
+   * Returns the source properties with column-specific properties renamed or 
dropped to match the
+   * given schema, since those properties name columns of the current schema, 
which can differ from
+   * the schema of a pinned snapshot.
+   */
+  private static Map<String, String> copyColumnProperties(
+      Map<String, String> properties, Schema currentSchema, Schema schema) {
+    List<String> deletedColumns = Lists.newArrayList();
+    Map<String, String> renamedColumns = Maps.newHashMap();
+    TypeUtil.indexByName(currentSchema.asStruct())
+        .forEach(
+            (name, id) -> {
+              String nameInSchema = schema.findColumnName(id);
+              if (nameInSchema == null) {
+                deletedColumns.add(name);
+              } else if (!nameInSchema.equals(name)) {
+                renamedColumns.put(name, nameInSchema);
+              }
+            });
+
+    return PropertyUtil.applySchemaChanges(
+        properties, deletedColumns, renamedColumns, COLUMN_PROPERTY_PREFIXES);
+  }
+
+  private static PartitionSpec copyPartitionSpec(Schema schema, PartitionSpec 
sourceSpec) {
+    UnboundPartitionSpec spec = sourceSpec.toUnbound();
+    for (int index = sourceSpec.fields().size() - 1; index >= 0; index -= 1) {
+      if (schema.findField(sourceSpec.fields().get(index).sourceId()) == null) 
{
+        spec.fields().remove(index);
+      }
+    }
+
+    return spec.bind(schema);
+  }
+
+  private static SortOrder copySortOrder(Schema schema, SortOrder 
sourceSortOrder) {
+    if (sourceSortOrder.isUnsorted()) {
+      return SortOrder.unsorted();
+    }
+
+    SortOrder.Builder builder = SortOrder.builderFor(schema);
+    for (SortField field : sourceSortOrder.fields()) {
+      String sourceName = schema.findColumnName(field.sourceId());
+      if (sourceName == null) {
+        continue;
+      }
+
+      builder.sortBy(
+          Expressions.transform(sourceName, field.transform()),
+          field.direction(),
+          field.nullOrder());
+    }
+
+    return builder.build();
+  }
+
   @Override
   public StagedTable stageCreate(Identifier ident, TableInfo tableInfo)
       throws TableAlreadyExistsException {
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java
index d8a42eec97..457c46edb6 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java
@@ -30,6 +30,7 @@ import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.apache.iceberg.relocated.com.google.common.collect.Sets;
 import org.apache.iceberg.spark.source.HasIcebergCatalog;
 import org.apache.spark.sql.SparkSession;
@@ -250,6 +251,33 @@ public class SparkSessionCatalog<
     }
   }
 
+  @Override
+  public Table createTableLike(Identifier ident, TableInfo tableInfo, Table 
sourceTable)
+      throws TableAlreadyExistsException, NoSuchNamespaceException {
+    checkViewNotExists(ident);
+
+    String provider = tableInfo.properties().get("provider");
+    if (provider == null) {
+      provider = sourceTable.properties().get("provider");
+    }
+
+    if (useIceberg(provider)) {
+      return icebergCatalog.createTableLike(ident, tableInfo, sourceTable);
+    } else {
+      // like Spark's CREATE TABLE LIKE, copy only the schema and partitioning 
of the source. Its
+      // properties, such as location and external, describe the source and 
are not carried over.
+      Map<String, String> properties = Maps.newHashMap(tableInfo.properties());
+      properties.put(TableCatalog.PROP_PROVIDER, provider);
+      TableInfo targetInfo =
+          new TableInfo.Builder()
+              .withColumns(sourceTable.columns())
+              .withPartitions(sourceTable.partitioning())
+              .withProperties(properties)
+              .build();
+      return getSessionCatalog().createTable(ident, targetInfo);
+    }
+  }
+
   @Override
   public StagedTable stageCreate(Identifier ident, TableInfo info)
       throws TableAlreadyExistsException, NoSuchNamespaceException {
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
index 9cd170f352..1e767f2e8c 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
@@ -135,6 +135,11 @@ public class SparkTable extends BaseSparkTable
     return new SparkTable(table(), newBranch);
   }
 
+  /** Returns the Iceberg schema pinned when this table was loaded. */
+  public Schema icebergSchema() {
+    return schema;
+  }
+
   public Long snapshotId() {
     return snapshot != null ? snapshot.snapshotId() : null;
   }
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java
index 4438e22cf5..61b0eaa15d 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java
@@ -22,6 +22,7 @@ import static 
org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
@@ -29,7 +30,9 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.withSettings;
 
+import java.nio.file.Path;
 import java.util.Collections;
+import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException;
 import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
@@ -54,12 +57,18 @@ import org.apache.spark.sql.util.CaseInsensitiveStringMap;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
 
 public class TestSparkSessionCatalog extends TestBase {
   private final String envHmsUriKey = "spark.hadoop." + METASTOREURIS.varname;
   private final String catalogHmsUriKey = 
"spark.sql.catalog.spark_catalog.uri";
   private final String hmsUri = hiveConf.get(METASTOREURIS.varname);
 
+  @TempDir private Path temp;
+
   @BeforeAll
   public static void setUpCatalog() {
     spark
@@ -75,6 +84,126 @@ public class TestSparkSessionCatalog extends TestBase {
     spark.conf().set(catalogHmsUriKey, hmsUri);
   }
 
+  @ParameterizedTest
+  @ValueSource(strings = {"parquet", "avro", "orc"})
+  void createTableLikeDelegatesUsingSourceProvider(String provider) throws 
Exception {
+    Identifier ident = Identifier.of(new String[] {"default"}, "target");
+    TableInfo tableInfo = new TableInfo.Builder().build();
+    TableInfo sourceInfo =
+        new TableInfo.Builder().withSchema(new StructType().add("id", 
"long")).build();
+    Table source = mock(Table.class);
+    when(source.columns()).thenReturn(sourceInfo.columns());
+    when(source.partitioning()).thenReturn(sourceInfo.partitions());
+    when(source.properties()).thenReturn(Collections.singletonMap("provider", 
provider));
+    Table target = mock(Table.class);
+    TableCatalog sessionCatalog = sessionCatalogWithViews();
+    when(sessionCatalog.createTable(eq(ident), 
any(TableInfo.class))).thenReturn(target);
+    TableCatalog icebergCatalog = icebergCatalogWithViews();
+
+    SparkSessionCatalog<?> catalog = new 
CatalogWithIcebergViews<>(icebergCatalog);
+    catalog.initialize(
+        "spark_catalog",
+        new CaseInsensitiveStringMap(Collections.singletonMap(provider + 
"-enabled", "false")));
+    catalog.setDelegateCatalog(sessionCatalog);
+
+    assertThat(catalog.createTableLike(ident, tableInfo, 
source)).isSameAs(target);
+    ArgumentCaptor<TableInfo> targetInfo = 
ArgumentCaptor.forClass(TableInfo.class);
+    verify(sessionCatalog).createTable(eq(ident), targetInfo.capture());
+    assertThat(targetInfo.getValue().schema()).isEqualTo(sourceInfo.schema());
+    assertThat(targetInfo.getValue().properties()).containsEntry("provider", 
provider);
+    verify(icebergCatalog, never()).createTableLike(ident, tableInfo, source);
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"parquet", "orc"})
+  void createTableLikeWithoutUsingPreservesSourceProvider(String provider) {
+    String sourceName = "spark_catalog.default.like_source";
+    String targetName = "spark_catalog.default.like_target";
+    String conversionOption = "spark.sql.catalog.spark_catalog." + provider + 
"-enabled";
+
+    withSQLConf(
+        Collections.singletonMap(conversionOption, "false"),
+        () -> {
+          spark.sessionState().catalogManager().reset();
+          try {
+            sql("CREATE TABLE %s (id BIGINT) USING %s", sourceName, provider);
+            sql("CREATE TABLE %s LIKE %s", targetName, sourceName);
+
+            assertThat(sql("DESCRIBE TABLE EXTENDED %s", targetName))
+                .anySatisfy(row -> assertThat(row).startsWith("Provider", 
provider));
+          } finally {
+            sql("DROP TABLE IF EXISTS %s", targetName);
+            sql("DROP TABLE IF EXISTS %s", sourceName);
+            spark.sessionState().catalogManager().reset();
+          }
+        });
+  }
+
+  @Test
+  void createTableLikeUsingParquetOverridesIcebergProvider() {
+    String sourceName = "spark_catalog.default.like_iceberg_source";
+    String targetName = "spark_catalog.default.like_parquet_target";
+
+    withSQLConf(
+        
Collections.singletonMap("spark.sql.catalog.spark_catalog.parquet-enabled", 
"false"),
+        () -> {
+          spark.sessionState().catalogManager().reset();
+          try {
+            sql("CREATE TABLE %s (id BIGINT, data STRING) USING iceberg", 
sourceName);
+            sql("INSERT INTO %s VALUES (1, 'source')", sourceName);
+
+            sql("CREATE TABLE %s LIKE %s USING parquet", targetName, 
sourceName);
+
+            assertThat(sql("DESCRIBE TABLE EXTENDED %s", targetName))
+                .anySatisfy(row -> assertThat(row).startsWith("Provider", 
"parquet"));
+            assertThat(spark.table(targetName).schema())
+                .isEqualTo(spark.table(sourceName).schema());
+            assertThat(sql("SELECT * FROM %s", targetName)).isEmpty();
+          } finally {
+            sql("DROP TABLE IF EXISTS %s", targetName);
+            sql("DROP TABLE IF EXISTS %s", sourceName);
+            spark.sessionState().catalogManager().reset();
+          }
+        });
+  }
+
+  @Test
+  void createTableLikeUsingParquetFromExternalIcebergSource() {
+    String sourceName = "spark_catalog.default.like_external_iceberg_source";
+    String targetName = "spark_catalog.default.like_managed_parquet_target";
+    String sourceLocation = 
temp.resolve("like_external_iceberg_source").toUri().toString();
+
+    withSQLConf(
+        
Collections.singletonMap("spark.sql.catalog.spark_catalog.parquet-enabled", 
"false"),
+        () -> {
+          spark.sessionState().catalogManager().reset();
+          try {
+            sql(
+                "CREATE EXTERNAL TABLE %s (id BIGINT, data STRING) USING 
iceberg LOCATION '%s'",
+                sourceName, sourceLocation);
+            assertThat(
+                    catalog
+                        .loadTable(TableIdentifier.of("default", 
"like_external_iceberg_source"))
+                        .properties())
+                .containsEntry(TableCatalog.PROP_EXTERNAL, "true");
+
+            // the target has no LOCATION, so it must be created as a managed 
table rather than
+            // inheriting external=true from the source, which Spark rejects 
without a location
+            sql("CREATE TABLE %s LIKE %s USING parquet", targetName, 
sourceName);
+
+            assertThat(sql("DESCRIBE TABLE EXTENDED %s", targetName))
+                .anySatisfy(row -> assertThat(row).startsWith("Provider", 
"parquet"))
+                .anySatisfy(row -> assertThat(row).startsWith("Type", 
"MANAGED"));
+            assertThat(spark.table(targetName).schema())
+                .isEqualTo(spark.table(sourceName).schema());
+          } finally {
+            sql("DROP TABLE IF EXISTS %s", targetName);
+            sql("DROP TABLE IF EXISTS %s", sourceName);
+            spark.sessionState().catalogManager().reset();
+          }
+        });
+  }
+
   @Test
   public void testValidateHmsUri() {
     // HMS uris match
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java
index afb4e99648..10ac453c84 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java
@@ -31,12 +31,19 @@ import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.ParameterizedTestExtension;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
+import org.apache.iceberg.SortOrder;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.TableOperations;
 import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.expressions.Expressions;
 import org.apache.iceberg.hadoop.HadoopCatalog;
+import org.apache.iceberg.mapping.MappingUtil;
+import org.apache.iceberg.mapping.NameMappingParser;
 import org.apache.iceberg.spark.CatalogTestBase;
+import org.apache.iceberg.types.TypeUtil;
 import org.apache.iceberg.types.Types;
 import org.apache.iceberg.types.Types.NestedField;
 import org.apache.iceberg.types.Types.StructType;
@@ -69,6 +76,323 @@ public class TestCreateTable extends CatalogTestBase {
     assertThat(validationCatalog.tableExists(tableIdent)).as("Table should 
already exist").isTrue();
   }
 
+  @TestTemplate
+  public void testCreateTableLike() {
+    String sourceName = tableName("source");
+    TableIdentifier sourceIdent = TableIdentifier.of(Namespace.of("default"), 
"source");
+    Schema schema =
+        new Schema(
+            NestedField.required(1, "id", Types.LongType.get()),
+            NestedField.optional(2, "category", Types.StringType.get()),
+            NestedField.optional(3, "data", Types.StringType.get()));
+    PartitionSpec spec =
+        PartitionSpec.builderFor(schema).identity("category").bucket("id", 16, 
"shard").build();
+    SortOrder order = 
SortOrder.builderFor(schema).desc("id").asc("data").build();
+
+    try {
+      validationCatalog
+          .buildTable(sourceIdent, schema)
+          .withPartitionSpec(spec)
+          .withSortOrder(order)
+          .withProperty("custom-property", "custom-value")
+          .create();
+
+      Table source = validationCatalog.loadTable(sourceIdent);
+      sql("CREATE TABLE %s LIKE %s", tableName, sourceName);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      
assertThat(target.schema().asStruct()).isEqualTo(source.schema().asStruct());
+      assertThat(target.spec()).isEqualTo(source.spec());
+      assertThat(target.sortOrder().sameOrder(source.sortOrder())).isTrue();
+      assertThat(target.properties()).containsEntry("custom-property", 
"custom-value");
+      assertThat(target.location()).isNotEqualTo(source.location());
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  void createTableLikeUsesSnapshotSchema() {
+    assumeThat(catalogName)
+        .as("Spark session catalog does not support extended table names")
+        .isNotEqualTo("spark_catalog");
+
+    String sourceName = tableName("source");
+    TableIdentifier sourceIdent = TableIdentifier.of(Namespace.of("default"), 
"source");
+    Schema schema =
+        new Schema(
+            NestedField.required(1, "id", Types.LongType.get()),
+            NestedField.optional(2, "uuid", Types.UUIDType.get()));
+
+    try {
+      Table source = validationCatalog.createTable(sourceIdent, schema);
+      source.newAppend().commit();
+      long snapshotId = source.currentSnapshot().snapshotId();
+      Schema snapshotSchema = source.schema();
+      sql("ALTER TABLE %s ADD COLUMN data STRING", sourceName);
+
+      sql("CREATE TABLE %s LIKE %s.snapshot_id_%s", tableName, sourceName, 
snapshotId);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      
assertThat(target.schema().asStruct()).isEqualTo(snapshotSchema.asStruct());
+      assertThat(target.schema().asStruct())
+          
.isNotEqualTo(validationCatalog.loadTable(sourceIdent).schema().asStruct());
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  void createTableLikeUsesSnapshotSortColumnNames() {
+    assumeThat(catalogName)
+        .as("Spark session catalog does not support extended table names")
+        .isNotEqualTo("spark_catalog");
+
+    String sourceName = tableName("source");
+    TableIdentifier sourceIdent = TableIdentifier.of(Namespace.of("default"), 
"source");
+    Schema schema =
+        new Schema(
+            NestedField.required(1, "id", Types.LongType.get()),
+            NestedField.optional(2, "data", Types.StringType.get()));
+    SortOrder order = SortOrder.builderFor(schema).desc("data").build();
+
+    try {
+      Table source =
+          validationCatalog.buildTable(sourceIdent, 
schema).withSortOrder(order).create();
+      source.newAppend().commit();
+      long snapshotId = source.currentSnapshot().snapshotId();
+      sql("ALTER TABLE %s RENAME COLUMN data TO payload", sourceName);
+
+      sql("CREATE TABLE %s LIKE %s.snapshot_id_%s", tableName, sourceName, 
snapshotId);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      assertThat(target.schema().findField("data")).isNotNull();
+      assertThat(target.sortOrder().sameOrder(order)).isTrue();
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  void createTableLikeUsesSnapshotColumnPropertyNames() {
+    assumeThat(catalogName)
+        .as("Spark session catalog does not support extended table names")
+        .isNotEqualTo("spark_catalog");
+
+    String sourceName = tableName("source");
+    TableIdentifier sourceIdent = TableIdentifier.of(Namespace.of("default"), 
"source");
+    Schema schema =
+        new Schema(
+            NestedField.required(1, "id", Types.LongType.get()),
+            NestedField.optional(2, "data", Types.StringType.get()));
+    String dataMetrics = TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + 
"data";
+    String payloadMetrics = TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + 
"payload";
+    String extraMetrics = TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + 
"extra";
+
+    try {
+      Table source =
+          validationCatalog
+              .buildTable(sourceIdent, schema)
+              .withProperty(dataMetrics, "full")
+              .create();
+      source.newAppend().commit();
+      long snapshotId = source.currentSnapshot().snapshotId();
+      sql("ALTER TABLE %s RENAME COLUMN data TO payload", sourceName);
+      sql("ALTER TABLE %s ADD COLUMN extra STRING", sourceName);
+      sql("ALTER TABLE %s SET TBLPROPERTIES ('%s'='counts')", sourceName, 
extraMetrics);
+
+      sql("CREATE TABLE %s LIKE %s.snapshot_id_%s", tableName, sourceName, 
snapshotId);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      assertThat(target.properties())
+          .containsEntry(dataMetrics, "full")
+          .doesNotContainKeys(payloadMetrics, extraMetrics);
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  void createTableLikeOmitsSortFieldsAddedAfterSnapshot() {
+    assumeThat(catalogName)
+        .as("Spark session catalog does not support extended table names")
+        .isNotEqualTo("spark_catalog");
+
+    String sourceName = tableName("source");
+    TableIdentifier sourceIdent = TableIdentifier.of(Namespace.of("default"), 
"source");
+    Schema schema = new Schema(NestedField.required(1, "id", 
Types.LongType.get()));
+    SortOrder order = SortOrder.builderFor(schema).desc("id").build();
+
+    try {
+      Table source =
+          validationCatalog.buildTable(sourceIdent, 
schema).withSortOrder(order).create();
+      source.newAppend().commit();
+      long snapshotId = source.currentSnapshot().snapshotId();
+      source.updateSchema().addColumn("data", Types.StringType.get()).commit();
+      source.replaceSortOrder().desc("id").asc("data").commit();
+
+      sql("CREATE TABLE %s LIKE %s.snapshot_id_%s", tableName, sourceName, 
snapshotId);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      assertThat(target.schema().asStruct()).isEqualTo(schema.asStruct());
+      assertThat(target.sortOrder().sameOrder(order)).isTrue();
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  void createTableLikeOmitsPartitionFieldsWithDeletedSources() {
+    String sourceName = tableName("source");
+    TableIdentifier sourceIdent = TableIdentifier.of(Namespace.of("default"), 
"source");
+    Schema schema =
+        new Schema(
+            NestedField.required(1, "id", Types.LongType.get()),
+            NestedField.optional(2, "data", Types.StringType.get()));
+
+    try {
+      Table source =
+          validationCatalog
+              .buildTable(sourceIdent, schema)
+              .withProperty(TableProperties.FORMAT_VERSION, "1")
+              .create();
+      source.updateSpec().addField("data").commit();
+      source.updateSpec().removeField("data").commit();
+      source.updateSchema().deleteColumn("data").commit();
+      source.updateSpec().addField("shard", Expressions.bucket("id", 
16)).commit();
+
+      sql("CREATE TABLE %s LIKE %s", tableName, sourceName);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      PartitionSpec expectedSpec =
+          PartitionSpec.builderFor(target.schema()).bucket("id", 16, 
"shard").build();
+      assertThat(target.spec()).isEqualTo(expectedSpec);
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  void createTableLikeOmitsSourceNameMapping() {
+    String sourceName = tableName("source");
+    TableIdentifier sourceIdent = TableIdentifier.of(Namespace.of("default"), 
"source");
+    Schema schema =
+        new Schema(
+            NestedField.required(1, "id", Types.LongType.get()),
+            NestedField.optional(2, "data", Types.StringType.get()));
+
+    try {
+      Table source = validationCatalog.createTable(sourceIdent, schema);
+      source.newAppend().commit();
+      source.updateSchema().deleteColumn("data").commit();
+      source.updateSchema().addColumn("data", Types.UUIDType.get()).commit();
+      source
+          .updateProperties()
+          .set(
+              TableProperties.DEFAULT_NAME_MAPPING,
+              NameMappingParser.toJson(MappingUtil.create(source.schema())))
+          .commit();
+
+      sql("CREATE TABLE %s LIKE %s", tableName, sourceName);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      
assertThat(target.properties()).doesNotContainKey(TableProperties.DEFAULT_NAME_MAPPING);
+      assertThat(target.schema().findField("data").fieldId())
+          .isNotEqualTo(source.schema().findField("data").fieldId());
+      assertThat(target.schema().asStruct())
+          
.isEqualTo(TypeUtil.assignIncreasingFreshIds(source.schema()).asStruct());
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  public void testCreateTableLikeClonesAndOverridesProperties() {
+    String sourceName = tableName("source");
+    TableIdentifier sourceIdent = TableIdentifier.of(Namespace.of("default"), 
"source");
+    Schema schema = new Schema(NestedField.required(1, "id", 
Types.LongType.get()));
+
+    try {
+      validationCatalog
+          .buildTable(sourceIdent, schema)
+          .withProperty("clone-me", "from-source")
+          .withProperty("override-me", "from-source")
+          .create();
+
+      sql(
+          "CREATE TABLE %s LIKE %s TBLPROPERTIES 
('override-me'='from-target')",
+          tableName, sourceName);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      assertThat(target.properties())
+          .containsEntry("clone-me", "from-source")
+          .containsEntry("override-me", "from-target");
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  void createTableLikeUsingOverridesSourceFormat() {
+    assumeThat(catalogName)
+        .as("Spark session catalog delegates ORC tables to its session 
catalog")
+        .isNotEqualTo("spark_catalog");
+
+    String sourceName = tableName("source");
+    TableIdentifier sourceIdent = TableIdentifier.of(Namespace.of("default"), 
"source");
+    Schema schema = new Schema(NestedField.required(1, "id", 
Types.LongType.get()));
+
+    try {
+      validationCatalog
+          .buildTable(sourceIdent, schema)
+          .withProperty(TableProperties.DEFAULT_FILE_FORMAT, "parquet")
+          .create();
+
+      sql("CREATE TABLE %s LIKE %s USING orc", tableName, sourceName);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      
assertThat(target.properties()).containsEntry(TableProperties.DEFAULT_FILE_FORMAT,
 "orc");
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  public void testCreateTableLikeIfNotExists() {
+    String sourceName = tableName("source");
+
+    try {
+      sql(
+          "CREATE TABLE %s (id BIGINT, data STRING) "
+              + "USING iceberg TBLPROPERTIES ('source-property'='source')",
+          sourceName);
+      sql(
+          "CREATE TABLE %s (id BIGINT) "
+              + "USING iceberg TBLPROPERTIES ('target-property'='target')",
+          tableName);
+
+      sql("CREATE TABLE IF NOT EXISTS %s LIKE %s", tableName, sourceName);
+
+      Table target = validationCatalog.loadTable(tableIdent);
+      assertThat(target.schema().columns()).hasSize(1);
+      assertThat(target.properties())
+          .containsEntry("target-property", "target")
+          .doesNotContainKey("source-property");
+    } finally {
+      sql("DROP TABLE IF EXISTS %s", sourceName);
+    }
+  }
+
+  @TestTemplate
+  public void testCreateTableLikeMissingSource() {
+    String missingSource = tableName("missing_source");
+
+    assertThatThrownBy(() -> sql("CREATE TABLE %s LIKE %s", tableName, 
missingSource))
+        .isInstanceOf(org.apache.spark.sql.AnalysisException.class)
+        .hasMessageContaining("missing_source");
+    assertThat(validationCatalog.tableExists(tableIdent)).isFalse();
+  }
+
   @TestTemplate
   public void testTransformSingularForm() {
     assertThat(validationCatalog.tableExists(tableIdent))

Reply via email to