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

gianm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 1e510ab555e feat: catalog + clustered segments (#19711)
1e510ab555e is described below

commit 1e510ab555e1292530625ee02995bbd693d85ee5
Author: Clint Wylie <[email protected]>
AuthorDate: Fri Jul 24 15:42:20 2026 -0700

    feat: catalog + clustered segments (#19711)
---
 .../catalog/CatalogIngestAndQueryTest.java         | 137 ++++++++-
 .../testing/embedded/catalog/CatalogTestBase.java  |   5 +-
 .../druid/catalog/sql/CatalogInsertTest.java       |   4 +
 .../druid/catalog/sql/CatalogReplaceTest.java      |   4 +
 .../apache/druid/catalog/storage/CatalogTests.java |   6 +-
 .../sampler/InputSourceSamplerDiscoveryTest.java   |   2 +-
 .../apache/druid/msq/sql/MSQTaskQueryMaker.java    |  42 ++-
 .../org/apache/druid/msq/exec/MSQInsertTest.java   | 163 ++++++++++
 .../msq/indexing/MSQCompactionRunnerTest.java      |   2 +-
 .../org/apache/druid/msq/test/MSQTestBase.java     |  21 +-
 .../druid/msq/test/MSQTestWorkerContext.java       |   7 +-
 .../druid/data/input/impl/DimensionSchema.java     |   7 +-
 .../druid/segment/DefaultColumnFormatConfig.java   |   2 +-
 .../druid/segment/NestedDataColumnSchema.java      |  11 +-
 .../segment/nested/NestedDataComplexTypeSerde.java |   2 +-
 .../druid/data/input/impl/DimensionSchemaTest.java |  47 +++
 .../druid/segment/NestedDataColumnSchemaTest.java  |   2 +-
 .../ClusteredValueGroupsBaseTableMetadata.java     | 178 +++++++++++
 .../catalog/model/DatasourceBaseTableMetadata.java |  65 ++++
 .../catalog/model/facade/DatasourceFacade.java     |  80 ++++-
 .../druid/catalog/model/table/DatasourceDefn.java  |  47 ++-
 .../druid/catalog/model/table/TableBuilder.java    |   6 +
 .../ClusteredValueGroupsBaseTableMetadataTest.java | 340 +++++++++++++++++++++
 .../catalog/model/table/DatasourceTableTest.java   | 143 +++++++++
 .../sql/calcite/planner/DruidSqlValidator.java     |  13 +-
 .../calcite/CalciteCatalogIngestionDmlTest.java    | 187 +++++++++++-
 26 files changed, 1478 insertions(+), 45 deletions(-)

diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogIngestAndQueryTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogIngestAndQueryTest.java
index c9407a46472..1dcca2c4a8f 100644
--- 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogIngestAndQueryTest.java
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogIngestAndQueryTest.java
@@ -20,15 +20,23 @@
 package org.apache.druid.testing.embedded.catalog;
 
 import com.google.common.collect.ImmutableList;
+import org.apache.druid.catalog.model.ClusteredValueGroupsBaseTableMetadata;
 import org.apache.druid.catalog.model.Columns;
+import org.apache.druid.catalog.model.DatasourceProjectionMetadata;
 import org.apache.druid.catalog.model.TableMetadata;
 import org.apache.druid.catalog.model.table.ClusterKeySpec;
 import org.apache.druid.catalog.model.table.DatasourceDefn;
 import org.apache.druid.catalog.model.table.TableBuilder;
+import org.apache.druid.data.input.impl.AggregateProjectionSpec;
+import org.apache.druid.data.input.impl.LongDimensionSchema;
+import org.apache.druid.data.input.impl.StringDimensionSchema;
 import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.java.util.common.granularity.Granularities;
 import org.apache.druid.query.QueryContexts;
+import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
 import org.apache.druid.query.http.ClientSqlQuery;
 import org.apache.druid.query.http.SqlTaskStatus;
+import org.apache.druid.server.metrics.LatchableEmitter;
 import org.apache.druid.testing.embedded.msq.EmbeddedMSQApis;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
@@ -74,7 +82,7 @@ public abstract class CatalogIngestAndQueryTest extends 
CatalogTestBase
    * 2022-12-26T12:34:56,extra,8,"40",2.0,foq
    * 2022-12-26T12:34:56,extra,8,"50",2.0,fop
    * <p>
-   * When querying the table with query: 'SELECT * from ##tableName', the 
BIGINT type column should
+   * When querying the table with query: {@code SELECT * FROM table}, the 
BIGINT type column should
    * be implicitly coherced into type DOUBLE when inserted into the table, 
since the column being
    * written into is type DOUBLE.
    * <p>
@@ -141,7 +149,7 @@ public abstract class CatalogIngestAndQueryTest extends 
CatalogTestBase
    * 2022-12-26T12:34:56,extra,8,"40",2.0,foq
    * 2022-12-26T12:34:56,extra,8,"50",2.0,fop
    * <p>
-   * When querying the table with query: 'SELECT * from ##tableName', because 
of the clustering
+   * When querying the table with query: {@code SELECT * FROM table}, because 
of the clustering
    * defined on the table, the data should be reordered to:
    * <p>
    * __time, bigint_col1
@@ -211,7 +219,7 @@ public abstract class CatalogIngestAndQueryTest extends 
CatalogTestBase
    * 2022-12-26T12:34:56,extra,8,"40",2.0,foq
    * 2022-12-26T12:34:56,extra,8,"50",2.0,fop
    * <p>
-   * When querying the table with query: 'SELECT * from ##tableName', because 
of the clustering
+   * When querying the table with query: {@code SELECT * FROM table}, because 
of the clustering
    * defined on the table, the data should be reordered to:
    * <p>
    * __time, bigint_col1
@@ -281,7 +289,7 @@ public abstract class CatalogIngestAndQueryTest extends 
CatalogTestBase
    * 2022-12-26T12:34:56,extra,8,"40",2.0,foq
    * 2022-12-26T12:34:56,extra,8,"50",2.0,fop
    * <p>
-   * When querying the table with query: 'SELECT * from ##tableName', because 
of the clustering
+   * When querying the table with query: {@code SELECT * FROM table}, because 
of the clustering
    * defined on the table, the data should be reordered to:
    * <p>
    * __time, varchar_col1, bigint_col1, float_col1, varchar_col2
@@ -360,7 +368,7 @@ public abstract class CatalogIngestAndQueryTest extends 
CatalogTestBase
    * 2022-12-26T12:34:56,extra,8,"40",2.0,foq
    * 2022-12-26T12:34:56,extra,8,"50",2.0,fop
    * <p>
-   * When querying the table with query: 'SELECT * from ##tableName', because 
of the clustering
+   * When querying the table with query: {@code SELECT * FROM table}, because 
of the clustering
    * defined on the query, the data should be reordered to:
    * <p>
    * __time, varchar_col1, bigint_col1, float_col1, varchar_col2
@@ -415,6 +423,123 @@ public abstract class CatalogIngestAndQueryTest extends 
CatalogTestBase
     );
   }
 
+  /**
+   * Create a table with columns (in declared order, which is the physical 
segment order; the clustering column
+   * must be declared first):
+   * <p>
+   * varchar_col2 VARCHAR
+   * __time       LONG
+   * varchar_col1 VARCHAR
+   * bigint_col1  BIGINT
+   * <p>
+   * and a clustered base table layout with clustering column {@code 
varchar_col2}, plus an aggregate projection
+   * ({@code varchar_col2_daily}: group by varchar_col2, sum bigint_col1), so 
ingestion produces clustered (V10)
+   * segments that also carry a segment-wide projection.
+   * <p>
+   * Insert the following data:
+   * <p>
+   * __time, varchar_col1, bigint_col1, float_col1, varchar_col2
+   * 2022-12-26T12:34:56,extra,10,"20",2.0,foo
+   * 2022-12-26T12:34:56,extra,9,"30",2.0,foo
+   * 2022-12-26T12:34:56,extra,8,"40",2.0,foq
+   * 2022-12-26T12:34:56,extra,8,"50",2.0,fop
+   * <p>
+   * {@code SELECT * FROM table} returns columns in catalog order and rows in 
segment order: cluster groups in
+   * clustering-value order (foo, fop, foq), each group internally ordered by 
the declared column order (within
+   * 'foo', by bigint_col1).
+   */
+  @Test
+  public void testInsertClusteredBaseTableFromCatalog()
+  {
+    String tableName = dataSource;
+    TableMetadata table = TableBuilder.datasource(tableName, "P1D")
+        .column("varchar_col2", "VARCHAR")
+        .column(Columns.TIME_COLUMN, Columns.LONG)
+        .column("varchar_col1", "VARCHAR")
+        .column("bigint_col1", "BIGINT")
+        .sealed(true)
+        .property(
+            DatasourceDefn.BASE_TABLE_PROPERTY,
+            new 
ClusteredValueGroupsBaseTableMetadata(ImmutableList.of("varchar_col2"), null)
+        )
+        .property(
+            DatasourceDefn.PROJECTIONS_KEYS_PROPERTY,
+            ImmutableList.of(
+                new DatasourceProjectionMetadata(
+                    AggregateProjectionSpec.builder("varchar_col2_daily")
+                                           .virtualColumns(
+                                               Granularities.toVirtualColumn(
+                                                   Granularities.DAY,
+                                                   
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME
+                                               )
+                                           )
+                                           .groupingColumns(
+                                               new 
LongDimensionSchema(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME),
+                                               new 
StringDimensionSchema("varchar_col2")
+                                           )
+                                           .aggregators(new 
LongSumAggregatorFactory("sum_bigint_col1", "bigint_col1"))
+                                           .build()
+                )
+            )
+        )
+        .build();
+
+    client.createTable(table, true);
+    String queryInline =
+        StringUtils.format(dmlPrefixPattern, tableName) + "\n"
+        + "SELECT\n"
+        + "  TIME_PARSE(a) AS __time,\n"
+        + "  b AS varchar_col1,\n"
+        + "  c AS bigint_col1,\n"
+        + "  f AS varchar_col2\n"
+        + "FROM TABLE(\n"
+        + "  EXTERN(\n"
+        + "    
'{\"type\":\"inline\",\"data\":\"2022-12-26T12:34:56,extra,10,\\\"20\\\",2.0,foo\\n2022-12-26T12:34:56,extra,9,\\\"30\\\",2.0,foo\\n2022-12-26T12:34:56,extra,8,\\\"40\\\",2.0,foq\\n2022-12-26T12:34:56,extra,8,\\\"50\\\",2.0,fop\"}',\n"
+        + "    
'{\"type\":\"csv\",\"findColumnsFromHeader\":false,\"columns\":[\"a\",\"b\",\"c\",\"d\",\"e\",\"f\"]}'\n"
+        + "  )\n"
+        + ") "
+        + "  EXTEND (a VARCHAR, b VARCHAR, c BIGINT, d VARCHAR, e FLOAT, f 
VARCHAR)\n";
+
+    // Submit the task and wait for the datasource to get loaded
+    SqlTaskStatus sqlTaskStatus = msqApis.submitTaskSql(queryInline);
+    cluster.callApi().waitForTaskToSucceed(sqlTaskStatus.getTaskId(), 
overlord);
+    cluster.callApi().waitForAllSegmentsToBeAvailable(dataSource, coordinator, 
broker);
+
+    cluster.callApi().verifySqlQuery(
+        "SELECT * FROM %s",
+        dataSource,
+        "foo,2022-12-26T12:34:56.000Z,extra,9\n"
+        + "foo,2022-12-26T12:34:56.000Z,extra,10\n"
+        + "fop,2022-12-26T12:34:56.000Z,extra,8\n"
+        + "foq,2022-12-26T12:34:56.000Z,extra,8"
+    );
+
+    final LatchableEmitter emitter = historical.latchableEmitter();
+    emitter.flush();
+
+    // This aggregation matches the varchar_col2_daily projection (group by 
varchar_col2, sum bigint_col1), proving
+    // projections coexist with (and are chosen over) the clustered base table.
+    cluster.callApi().verifySqlQuery(
+        "SELECT varchar_col2, SUM(bigint_col1) FROM %s GROUP BY 1 ORDER BY 1",
+        dataSource,
+        "foo,19\nfop,8\nfoq,8"
+    );
+
+    // When the projection is used, the segment-scan query metrics carry the 
projection name as a dimension.
+    emitter.waitForEvent(
+        event -> event.hasMetricName("query/segment/time")
+                      .hasDimension("projection", "varchar_col2_daily")
+    );
+
+    // Grouping on a column the projection does not carry cannot be served by 
it: this cross-group aggregation
+    // exercises the clustered read path (per-group local dictionaries).
+    cluster.callApi().verifySqlQuery(
+        "SELECT varchar_col1, SUM(bigint_col1) FROM %s GROUP BY 1",
+        dataSource,
+        "extra,35"
+    );
+  }
+
   /**
    * Adding a new column during ingestion that is not defined in a sealed 
table, should fail with
    * proper validation error. Disabling catalog validation, through context 
parameter, and issuing ingest
@@ -487,7 +612,7 @@ public abstract class CatalogIngestAndQueryTest extends 
CatalogTestBase
    * <p>
    * 2022-12-26T12:34:56,extra
    * <p>
-   * When querying the table with query: 'SELECT * from ##tableName', the data 
is returned as:
+   * When querying the table with query: {@code SELECT * FROM table}, the data 
is returned as:
    * <p>
    * __time, double_col
    * 2022-12-26T12:34:56,0.0
diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogTestBase.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogTestBase.java
index 325859a0c7a..de034eb46e1 100644
--- 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogTestBase.java
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogTestBase.java
@@ -48,6 +48,7 @@ public abstract class CatalogTestBase extends 
EmbeddedClusterTestBase
   private final EmbeddedIndexer indexer = new EmbeddedIndexer()
       .setServerMemory(1_000_000_000)
       .addProperty("druid.worker.capacity", "2");
+  protected final EmbeddedHistorical historical = new EmbeddedHistorical();
 
   @Override
   protected EmbeddedDruidCluster createCluster()
@@ -58,11 +59,13 @@ public abstract class CatalogTestBase extends 
EmbeddedClusterTestBase
                                    CatalogClientModule.class,
                                    CatalogCoordinatorModule.class
                                )
+                               // clustered base table segments require the 
V10 format
+                               
.addCommonProperty("druid.indexer.task.buildV10", "true")
                                .addServer(overlord)
                                .addServer(coordinator)
                                .addServer(broker)
                                .addServer(indexer)
-                               .addServer(new EmbeddedHistorical());
+                               .addServer(historical);
   }
 
   void verifySubmitSqlTaskFailsWith400BadRequest(String sql, String 
expectedMessageSubstring)
diff --git 
a/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/sql/CatalogInsertTest.java
 
b/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/sql/CatalogInsertTest.java
index 82076c89604..c07af9db584 100644
--- 
a/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/sql/CatalogInsertTest.java
+++ 
b/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/sql/CatalogInsertTest.java
@@ -100,6 +100,10 @@ public class CatalogInsertTest extends 
CalciteCatalogInsertTest
           
tableBuilder.clusterColumns(catalogMetadata.clusterKeys().toArray(new 
ClusterKeySpec[0]));
         }
 
+        if (catalogMetadata.baseTableMetadata() != null) {
+          tableBuilder.baseTable(catalogMetadata.baseTableMetadata());
+        }
+
         createTableMetadata(tableBuilder.build());
       });
     }
diff --git 
a/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/sql/CatalogReplaceTest.java
 
b/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/sql/CatalogReplaceTest.java
index caff8a01f4a..55ab1a06d6b 100644
--- 
a/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/sql/CatalogReplaceTest.java
+++ 
b/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/sql/CatalogReplaceTest.java
@@ -99,6 +99,10 @@ public class CatalogReplaceTest extends 
CalciteCatalogReplaceTest
           
tableBuilder.clusterColumns(catalogMetadata.clusterKeys().toArray(new 
ClusterKeySpec[0]));
         }
 
+        if (catalogMetadata.baseTableMetadata() != null) {
+          tableBuilder.baseTable(catalogMetadata.baseTableMetadata());
+        }
+
         createTableMetadata(tableBuilder.build());
       });
       DatasourceFacade catalogMetadata =
diff --git 
a/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/storage/CatalogTests.java
 
b/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/storage/CatalogTests.java
index caab4a3dad8..0c30c073686 100644
--- 
a/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/storage/CatalogTests.java
+++ 
b/extensions-core/druid-catalog/src/test/java/org/apache/druid/catalog/storage/CatalogTests.java
@@ -19,11 +19,13 @@
 
 package org.apache.druid.catalog.storage;
 
+import com.fasterxml.jackson.databind.InjectableValues;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableMap;
 import org.apache.druid.catalog.storage.sql.CatalogManager;
 import org.apache.druid.catalog.storage.sql.SQLCatalogManager;
 import org.apache.druid.jackson.DefaultObjectMapper;
+import org.apache.druid.math.expr.ExprMacroTable;
 import org.apache.druid.metadata.TestDerbyConnector.DerbyConnectorRule;
 import org.apache.druid.server.security.Access;
 import org.apache.druid.server.security.Action;
@@ -70,7 +72,9 @@ public class CatalogTests
     }
   }
 
-  public static final ObjectMapper JSON_MAPPER = new DefaultObjectMapper();
+  public static final ObjectMapper JSON_MAPPER = new 
DefaultObjectMapper().setInjectableValues(
+      new InjectableValues.Std().addValue(ExprMacroTable.class, 
ExprMacroTable.nil())
+  );
 
   public static class DbFixture
   {
diff --git 
a/indexing-service/src/test/java/org/apache/druid/indexing/overlord/sampler/InputSourceSamplerDiscoveryTest.java
 
b/indexing-service/src/test/java/org/apache/druid/indexing/overlord/sampler/InputSourceSamplerDiscoveryTest.java
index ffc20ee2052..70f58e795cc 100644
--- 
a/indexing-service/src/test/java/org/apache/druid/indexing/overlord/sampler/InputSourceSamplerDiscoveryTest.java
+++ 
b/indexing-service/src/test/java/org/apache/druid/indexing/overlord/sampler/InputSourceSamplerDiscoveryTest.java
@@ -80,7 +80,7 @@ public class InputSourceSamplerDiscoveryTest extends 
InitializedNullHandlingTest
             new DoubleDimensionSchema("double"),
             new LongDimensionSchema("bool"),
             new StringDimensionSchema("variant"),
-            AutoTypeColumnSchema.of("array"),
+            new AutoTypeColumnSchema("array", ColumnType.LONG_ARRAY, null),
             AutoTypeColumnSchema.of("nested")
         ),
         response.getLogicalDimensions()
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/sql/MSQTaskQueryMaker.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/sql/MSQTaskQueryMaker.java
index 517731a2089..6e01450ee0d 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/sql/MSQTaskQueryMaker.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/sql/MSQTaskQueryMaker.java
@@ -26,12 +26,14 @@ import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.runtime.Hook;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.druid.catalog.MetadataCatalog;
+import org.apache.druid.catalog.model.DatasourceBaseTableMetadata;
 import org.apache.druid.catalog.model.DatasourceProjectionMetadata;
 import org.apache.druid.catalog.model.ResolvedTable;
 import org.apache.druid.catalog.model.TableId;
 import org.apache.druid.catalog.model.table.DatasourceDefn;
 import org.apache.druid.common.guava.FutureUtils;
 import org.apache.druid.data.input.impl.AggregateProjectionSpec;
+import org.apache.druid.data.input.impl.BaseTableProjectionSpec;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.error.InvalidInput;
 import org.apache.druid.java.util.common.Intervals;
@@ -510,7 +512,9 @@ public class MSQTaskQueryMaker implements QueryMaker
     );
 
 
-    final List<AggregateProjectionSpec> projectionSpecs = 
getProjections(targetDataSource, plannerContext);
+    final ResolvedTable tableMetadata = getTableMetadata(targetDataSource, 
plannerContext);
+    final BaseTableProjectionSpec baseTable = getBaseTable(tableMetadata);
+    final List<AggregateProjectionSpec> projectionSpecs = 
getProjections(tableMetadata);
 
     final DataSourceMSQDestination dataSourceDestination = new 
DataSourceMSQDestination(
         targetDataSource.getDestinationName(),
@@ -518,6 +522,7 @@ public class MSQTaskQueryMaker implements QueryMaker
         segmentSortOrder,
         replaceTimeChunks,
         null,
+        baseTable,
         projectionSpecs,
         terminalStageSpecFactory.createTerminalStageSpec(
             plannerContext
@@ -550,16 +555,19 @@ public class MSQTaskQueryMaker implements QueryMaker
     return tuningConfig;
   }
 
-  private static List<AggregateProjectionSpec> getProjections(
-      IngestDestination targetDataSource,
-      PlannerContext plannerContext
-  )
+  @Nullable
+  private static ResolvedTable getTableMetadata(IngestDestination 
targetDataSource, PlannerContext plannerContext)
   {
-    final List<AggregateProjectionSpec> projectionSpecs;
     final MetadataCatalog metadataCatalog = 
plannerContext.getPlannerToolbox().catalogResolver().getMetadataCatalog();
-    final ResolvedTable tableMetadata = metadataCatalog.resolveTable(
+    return metadataCatalog.resolveTable(
         TableId.datasource(targetDataSource.getDestinationName())
     );
+  }
+
+  @Nullable
+  private static List<AggregateProjectionSpec> getProjections(@Nullable 
ResolvedTable tableMetadata)
+  {
+    final List<AggregateProjectionSpec> projectionSpecs;
     if (tableMetadata != null) {
       final List<DatasourceProjectionMetadata> projectionMetadata = 
tableMetadata.decodeProperty(
           DatasourceDefn.PROJECTIONS_KEYS_PROPERTY
@@ -576,4 +584,24 @@ public class MSQTaskQueryMaker implements QueryMaker
     }
     return projectionSpecs;
   }
+
+  /**
+   * Derives the physical base table spec for the target datasource from the 
catalog, if the table declares a
+   * {@link DatasourceDefn#BASE_TABLE_PROPERTY} layout; combines the layout 
with the declared catalog columns via
+   * {@link DatasourceBaseTableMetadata#createSpec}.
+   */
+  @Nullable
+  private static BaseTableProjectionSpec getBaseTable(@Nullable ResolvedTable 
tableMetadata)
+  {
+    if (tableMetadata == null) {
+      return null;
+    }
+    final DatasourceBaseTableMetadata baseTableMetadata = 
tableMetadata.decodeProperty(
+        DatasourceDefn.BASE_TABLE_PROPERTY
+    );
+    if (baseTableMetadata == null) {
+      return null;
+    }
+    return baseTableMetadata.createSpec(tableMetadata.spec().columns());
+  }
 }
diff --git 
a/multi-stage-query/src/test/java/org/apache/druid/msq/exec/MSQInsertTest.java 
b/multi-stage-query/src/test/java/org/apache/druid/msq/exec/MSQInsertTest.java
index 5c83a850a3b..8e00d2780b9 100644
--- 
a/multi-stage-query/src/test/java/org/apache/druid/msq/exec/MSQInsertTest.java
+++ 
b/multi-stage-query/src/test/java/org/apache/druid/msq/exec/MSQInsertTest.java
@@ -26,6 +26,8 @@ import com.google.common.hash.HashFunction;
 import com.google.common.hash.Hashing;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.druid.catalog.MapMetadataCatalog;
+import org.apache.druid.catalog.model.ClusteredValueGroupsBaseTableMetadata;
+import org.apache.druid.catalog.model.Columns;
 import org.apache.druid.catalog.model.DatasourceProjectionMetadata;
 import org.apache.druid.catalog.model.TableId;
 import org.apache.druid.catalog.model.table.DatasourceDefn;
@@ -76,6 +78,7 @@ import org.apache.druid.sql.calcite.planner.CatalogResolver;
 import org.apache.druid.sql.calcite.planner.ColumnMapping;
 import org.apache.druid.sql.calcite.planner.ColumnMappings;
 import org.apache.druid.sql.calcite.util.CalciteTests;
+import org.apache.druid.timeline.ClusterGroupTuples;
 import org.apache.druid.timeline.SegmentId;
 import org.apache.druid.timeline.partition.NumberedShardSpec;
 import org.hamcrest.CoreMatchers;
@@ -232,6 +235,45 @@ public class MSQInsertTest extends MSQTestBase
                     )
                     .buildSpec()
     );
+    metadataCatalog.addSpec(
+        TableId.datasource("fooClustered"),
+        TableBuilder.datasource("fooClustered", Granularities.DAY.toString())
+                    // declared order is the physical segment order: the 
clustering column leads
+                    .column("channel", Columns.SQL_VARCHAR)
+                    .timeColumn()
+                    .column("page", Columns.SQL_VARCHAR)
+                    .column("user", Columns.SQL_VARCHAR)
+                    .column("added", Columns.SQL_BIGINT)
+                    .column("deleted", Columns.SQL_BIGINT)
+                    .column("delta", Columns.SQL_BIGINT)
+                    .sealed(true)
+                    .baseTable(
+                        new 
ClusteredValueGroupsBaseTableMetadata(ImmutableList.of("channel"), null)
+                    )
+                    .property(
+                        DatasourceDefn.PROJECTIONS_KEYS_PROPERTY,
+                        ImmutableList.of(
+                            new DatasourceProjectionMetadata(
+                                
AggregateProjectionSpec.builder("channel_added_hourly")
+                                                       .virtualColumns(
+                                                           
Granularities.toVirtualColumn(
+                                                               
Granularities.HOUR,
+                                                               
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME
+                                                           )
+                                                       )
+                                                       .groupingColumns(
+                                                           new 
LongDimensionSchema(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME),
+                                                           new 
StringDimensionSchema("channel")
+                                                       )
+                                                       .aggregators(
+                                                           new 
LongSumAggregatorFactory("sum_added", "added")
+                                                       )
+                                                       .build()
+                            )
+                        )
+                    )
+                    .buildSpec()
+    );
     return new LiveCatalogResolver(metadataCatalog);
   }
 
@@ -709,6 +751,127 @@ public class MSQInsertTest extends MSQTestBase
 
   }
 
+  @MethodSource("data")
+  @ParameterizedTest(name = "{index}:with context {0}")
+  public void testInsertOnExternalDataSourceWithCatalogClusteredBaseTable(
+      String contextName,
+      Map<String, Object> context
+  ) throws IOException
+  {
+    final File toRead = getResourceAsTemporaryFile("/wikipedia-sampled.json");
+    final String toReadFileNameAsJson = 
queryFramework().queryJsonMapper().writeValueAsString(toRead.getAbsolutePath());
+
+    // The physical spec uses the declared catalog columns verbatim: the 
declared order is the segment order, with
+    // the clustering prefix leading and __time an explicit positional column.
+    RowSignature rowSignature = RowSignature.builder()
+                                            .add("channel", ColumnType.STRING)
+                                            .add("__time", ColumnType.LONG)
+                                            .add("page", ColumnType.STRING)
+                                            .add("user", ColumnType.STRING)
+                                            .add("added", ColumnType.LONG)
+                                            .add("deleted", ColumnType.LONG)
+                                            .add("delta", ColumnType.LONG)
+                                            .build();
+
+    // The catalog also declares a projection: aggregate projections are 
segment-wide views that coexist with the
+    // per-group clustered layout.
+    AggregateProjectionMetadata expectedProjection = new 
AggregateProjectionMetadata(
+        AggregateProjectionSchema.schemaBuilder("channel_added_hourly")
+                                 
.timeColumnName(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
+                                 .virtualColumns(
+                                       Granularities.toVirtualColumn(
+                                           Granularities.HOUR,
+                                           
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME
+                                       )
+                                   )
+                                 
.groupAndOrder(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME, "channel")
+                                 .aggregators(new 
LongSumAggregatorFactory("sum_added", "added"))
+                                 .build(),
+        16
+    );
+
+    testIngestQuery().setSql(" insert into fooClustered SELECT\n"
+                             + "  floor(TIME_PARSE(\"timestamp\") to minute) 
AS __time,\n"
+                             + "  channel,\n"
+                             + "  page,\n"
+                             + "  user,\n"
+                             + "  added,\n"
+                             + "  deleted,\n"
+                             + "  delta\n"
+                             + "FROM TABLE(\n"
+                             + "  EXTERN(\n"
+                             + "    '{ \"files\": [" + toReadFileNameAsJson + 
"],\"type\":\"local\"}',\n"
+                             + "    '{\"type\": \"json\"}',\n"
+                             + "    '[{\"name\": \"timestamp\", \"type\": 
\"string\"}, {\"name\": \"channel\", \"type\": \"string\"}, {\"name\": 
\"page\", \"type\": \"string\"}, {\"name\": \"user\", \"type\": \"string\"}, 
{\"name\": \"added\", \"type\": \"long\"}, {\"name\": \"deleted\", \"type\": 
\"long\"}, {\"name\": \"delta\", \"type\": \"long\"}]'\n"
+                             + "  )\n"
+                             + ") PARTITIONED by day ")
+                     .setExpectedDataSource("fooClustered")
+                     .setExpectedRowSignature(rowSignature)
+                     .setQueryContext(context)
+                     .setExpectedSegments(ImmutableSet.of(SegmentId.of(
+                         "fooClustered",
+                         Intervals.of("2016-06-27/P1D"),
+                         "test",
+                         0
+                     )))
+                     .setExpectedProjections(List.of(expectedProjection))
+                     .setExpectedClusterGroups(
+                         new ClusterGroupTuples(
+                             RowSignature.builder().add("channel", 
ColumnType.STRING).build(),
+                             ImmutableList.of(
+                                 ImmutableList.of("#ceb.wikipedia"),
+                                 ImmutableList.of("#de.wikipedia"),
+                                 ImmutableList.of("#en.wikipedia"),
+                                 ImmutableList.of("#es.wikipedia"),
+                                 ImmutableList.of("#id.wikipedia"),
+                                 ImmutableList.of("#pl.wikipedia"),
+                                 ImmutableList.of("#pt.wikipedia"),
+                                 ImmutableList.of("#ru.wikipedia"),
+                                 ImmutableList.of("#sh.wikipedia"),
+                                 ImmutableList.of("#sv.wikipedia"),
+                                 ImmutableList.of("#zh.wikipedia")
+                             )
+                         )
+                     )
+                     // Rows are read back in segment order: cluster groups in 
clustering-value order, each group
+                     // internally ordered by the derived column order 
(channel, __time, page, ...).
+                     .setExpectedResultRows(
+                         ImmutableList.of(
+                             new Object[]{"#ceb.wikipedia", 1466985660000L, 
"Neqerssuaq", "Lsjbot", 4150L, 0L, 4150L},
+                             new Object[]{"#de.wikipedia", 1466992920000L, 
"Benutzer Diskussion:Squasher/Archiv/2016", "TaxonBot", 2560L, 0L, 2560L},
+                             new Object[]{"#de.wikipedia", 1466992980000L, 
"Benutzer Diskussion:HerrSonderbar", "GiftBot", 364L, 0L, 364L},
+                             new Object[]{"#en.wikipedia", 1466985600000L, 
"Bailando 2015", "181.230.118.178", 2L, 0L, 2L},
+                             new Object[]{"#en.wikipedia", 1466985600000L, 
"Richie Rich's Christmas Wish", "JasonAQuest", 0L, 2L, -2L},
+                             new Object[]{"#en.wikipedia", 1466985660000L, 
"Panama Canal", "Mariordo", 496L, 0L, 496L},
+                             new Object[]{"#en.wikipedia", 1466992980000L, 
"File:Paint.net 4.0.6 screenshot.png", "Calvin Hogg", 0L, 463L, -463L},
+                             new Object[]{"#es.wikipedia", 1466985660000L, 
"Sumo (banda)", "181.110.165.189", 0L, 173L, -173L},
+                             new Object[]{"#es.wikipedia", 1466989320000L, 
"Clasificación para la Eurocopa Sub-21 de 2017", "Guly600", 4L, 0L, 4L},
+                             new Object[]{"#id.wikipedia", 1466989320000L, 
"Ibnu Sina", "Ftihikam", 106L, 0L, 106L},
+                             new Object[]{"#pl.wikipedia", 1466985600000L, 
"Kategoria:Dyskusje nad usunięciem artykułu zakończone bez konsensusu − lipiec 
2016", "Beau.bot", 270L, 0L, 270L},
+                             new Object[]{"#pt.wikipedia", 1466992920000L, 
"Dobromir Zhechev", "Ceresta", 1926L, 0L, 1926L},
+                             new Object[]{"#ru.wikipedia", 1466985720000L, 
"Википедия:Опросы/Унификация шаблонов «Не переведено»", "Wanderer777", 196L, 
0L, 196L},
+                             new Object[]{"#sh.wikipedia", 1466985660000L, "El 
Terco, Bachíniva", "Kolega2357", 0L, 1L, -1L},
+                             new Object[]{"#sh.wikipedia", 1466985720000L, 
"Hermanos Díaz, Ascensión", "Kolega2357", 0L, 1L, -1L},
+                             new Object[]{"#sh.wikipedia", 1466989320000L, "El 
Sicomoro, Ascensión", "Kolega2357", 0L, 1L, -1L},
+                             new Object[]{"#sh.wikipedia", 1466992920000L, 
"Trinidad Jiménez G., Benemérito de las Américas", "Kolega2357", 0L, 1L, -1L},
+                             new Object[]{"#sv.wikipedia", 1466985600000L, 
"Salo Toraut", "Lsjbot", 31L, 0L, 31L},
+                             new Object[]{"#zh.wikipedia", 1466989320000L, 
"中共十八大以来的反腐败工作", "2001:DA8:207:E132:94DC:BA03:DFDF:8F9F", 18L, 0L, 18L},
+                             new Object[]{"#zh.wikipedia", 1466992920000L, 
"Wikipedia:頁面存廢討論/記錄/2016/06/27", "Tigerzeng", 1986L, 0L, 1986L}
+                         )
+                     )
+                     .setExpectedCountersForStageWorkerChannel(
+                         CounterSnapshotMatcher
+                             
.with().rows(20).bytes(toRead.length()).files(1).totalFiles(1),
+                         0, 0, "input0"
+                     )
+                     
.setExpectedSegmentGenerationProgressCountersForStageWorker(
+                         CounterSnapshotMatcher
+                             .with().segmentRowsProcessed(20),
+                         1, 0
+                     )
+                     .verifyResults();
+  }
+
   @MethodSource("data")
   @ParameterizedTest(name = "{index}:with context {0}")
   public void testInsertOnFoo1WithGroupByLimitWithoutClusterBy(String 
contextName, Map<String, Object> context)
diff --git 
a/multi-stage-query/src/test/java/org/apache/druid/msq/indexing/MSQCompactionRunnerTest.java
 
b/multi-stage-query/src/test/java/org/apache/druid/msq/indexing/MSQCompactionRunnerTest.java
index 10d9b09d81a..fe3a7cd7f79 100644
--- 
a/multi-stage-query/src/test/java/org/apache/druid/msq/indexing/MSQCompactionRunnerTest.java
+++ 
b/multi-stage-query/src/test/java/org/apache/druid/msq/indexing/MSQCompactionRunnerTest.java
@@ -120,7 +120,7 @@ public class MSQCompactionRunnerTest
       null
   );
   private static final LongDimensionSchema LONG_DIMENSION = new 
LongDimensionSchema("long_dim");
-  private static final NestedDataColumnSchema NESTED_DIMENSION = new 
NestedDataColumnSchema("nested_dim", 5);
+  private static final NestedDataColumnSchema NESTED_DIMENSION = new 
NestedDataColumnSchema("nested_dim", 
NestedDataColumnSchema.DEFAULT_FORMAT_VERSION);
   private static final AutoTypeColumnSchema AUTO_DIMENSION = 
AutoTypeColumnSchema.of("auto_dim");
   private static final List<DimensionSchema> DIMENSIONS = ImmutableList.of(
       STRING_DIMENSION,
diff --git 
a/multi-stage-query/src/test/java/org/apache/druid/msq/test/MSQTestBase.java 
b/multi-stage-query/src/test/java/org/apache/druid/msq/test/MSQTestBase.java
index 02cb12d7444..19ae4829f59 100644
--- a/multi-stage-query/src/test/java/org/apache/druid/msq/test/MSQTestBase.java
+++ b/multi-stage-query/src/test/java/org/apache/druid/msq/test/MSQTestBase.java
@@ -218,6 +218,7 @@ import org.apache.druid.storage.StorageConnectorModule;
 import org.apache.druid.storage.StorageConnectorProvider;
 import org.apache.druid.storage.local.LocalFileStorageConnector;
 import org.apache.druid.test.utils.TestSegmentManager;
+import org.apache.druid.timeline.ClusterGroupTuples;
 import org.apache.druid.timeline.CompactionState;
 import org.apache.druid.timeline.DataSegment;
 import org.apache.druid.timeline.PruneLoadSpec;
@@ -1289,6 +1290,8 @@ public class MSQTestBase extends BaseCalciteQueryTest
 
     private List<AggregateProjectionMetadata> expectedProjections = null;
 
+    private ClusterGroupTuples expectedClusterGroups = null;
+
     private IngestTester()
     {
       // nothing to do
@@ -1336,6 +1339,12 @@ public class MSQTestBase extends BaseCalciteQueryTest
       return this;
     }
 
+    public IngestTester setExpectedClusterGroups(ClusterGroupTuples 
expectedClusterGroups)
+    {
+      this.expectedClusterGroups = expectedClusterGroups;
+      return this;
+    }
+
     public void verifyResults()
     {
       Preconditions.checkArgument(
@@ -1441,16 +1450,24 @@ public class MSQTestBase extends BaseCalciteQueryTest
           // assert query granularity
           Assert.assertEquals(expectedQueryGranularity, 
queryableIndex.getMetadata().getQueryGranularity());
 
-          // assert aggregator factories
+          // assert aggregator factories; clustered base table segments have 
no aggregator metadata (never rollup),
+          // so treat null as empty
           Assert.assertArrayEquals(
               expectedAggregatorFactories.toArray(new AggregatorFactory[0]),
-              queryableIndex.getMetadata().getAggregators()
+              queryableIndex.getMetadata().getAggregators() == null
+              ? new AggregatorFactory[0]
+              : queryableIndex.getMetadata().getAggregators()
           );
 
           if (expectedProjections != null) {
             Assert.assertEquals(expectedProjections, 
queryableIndex.getMetadata().getProjections());
           }
 
+          if (expectedClusterGroups != null) {
+            Assert.assertEquals(expectedClusterGroups, 
dataSegment.getClusterGroups());
+            
Assert.assertNotNull(queryableIndex.getMetadata().getClusteredBaseTable());
+          }
+
           for (List<Object> row : 
FrameTestUtil.readRowsFromCursorFactory(cursorFactory).toList()) {
             // transforming rows for sketch assertions
             List<Object> transformedRow = row.stream()
diff --git 
a/multi-stage-query/src/test/java/org/apache/druid/msq/test/MSQTestWorkerContext.java
 
b/multi-stage-query/src/test/java/org/apache/druid/msq/test/MSQTestWorkerContext.java
index ef519108834..af88c704d1b 100644
--- 
a/multi-stage-query/src/test/java/org/apache/druid/msq/test/MSQTestWorkerContext.java
+++ 
b/multi-stage-query/src/test/java/org/apache/druid/msq/test/MSQTestWorkerContext.java
@@ -48,7 +48,7 @@ import org.apache.druid.query.policy.PolicyEnforcer;
 import org.apache.druid.query.rowsandcols.serde.WireTransferableContext;
 import org.apache.druid.segment.IndexIO;
 import org.apache.druid.segment.IndexMerger;
-import org.apache.druid.segment.IndexMergerV9;
+import org.apache.druid.segment.IndexMergerV10;
 import org.apache.druid.segment.SegmentWrangler;
 import org.apache.druid.segment.column.ColumnConfig;
 import org.apache.druid.segment.incremental.NoopRowIngestionMeters;
@@ -316,11 +316,10 @@ public class MSQTestWorkerContext implements WorkerContext
     @Override
     public IndexMerger indexMerger()
     {
-      return new IndexMergerV9(
+      return new IndexMergerV10(
           mapper,
           indexIO(),
-          OffHeapMemorySegmentWriteOutMediumFactory.instance(),
-          true
+          OffHeapMemorySegmentWriteOutMediumFactory.instance()
       );
     }
 
diff --git 
a/processing/src/main/java/org/apache/druid/data/input/impl/DimensionSchema.java
 
b/processing/src/main/java/org/apache/druid/data/input/impl/DimensionSchema.java
index b8179e463c1..216fcf5ef50 100644
--- 
a/processing/src/main/java/org/apache/druid/data/input/impl/DimensionSchema.java
+++ 
b/processing/src/main/java/org/apache/druid/data/input/impl/DimensionSchema.java
@@ -38,6 +38,7 @@ import org.apache.druid.segment.DimensionHandlerUtils;
 import org.apache.druid.segment.IndexSpec;
 import org.apache.druid.segment.NestedDataColumnSchema;
 import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.column.ColumnTypeFactory;
 import org.apache.druid.segment.column.TypeSignature;
 import org.apache.druid.segment.column.ValueType;
 import org.apache.druid.segment.incremental.IncrementalIndex;
@@ -90,7 +91,11 @@ public abstract class DimensionSchema
       case DOUBLE:
         return new DoubleDimensionSchema(name);
       default:
-        // the auto column indexer can handle any type
+        // Use the auto column with type coercion when dealing with arrays of 
primitive types.
+        // Otherwise, allow type inference, which may yield a column of a 
different type.
+        if (type.isPrimitiveArray()) {
+          return new AutoTypeColumnSchema(name, 
ColumnTypeFactory.ofType(type), null);
+        }
         return AutoTypeColumnSchema.of(name);
     }
   }
diff --git 
a/processing/src/main/java/org/apache/druid/segment/DefaultColumnFormatConfig.java
 
b/processing/src/main/java/org/apache/druid/segment/DefaultColumnFormatConfig.java
index 56118b02686..af26e802eb6 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/DefaultColumnFormatConfig.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/DefaultColumnFormatConfig.java
@@ -37,7 +37,7 @@ public class DefaultColumnFormatConfig
   public static Integer validateNestedFormatVersion(@Nullable Integer 
formatVersion)
   {
     if (formatVersion != null) {
-      if (formatVersion != 5) {
+      if (formatVersion != NestedDataColumnSchema.DEFAULT_FORMAT_VERSION) {
         LOG.warn("Unsupported nested column format version[%s], using default 
version instead", formatVersion);
         return null;
       }
diff --git 
a/processing/src/main/java/org/apache/druid/segment/NestedDataColumnSchema.java 
b/processing/src/main/java/org/apache/druid/segment/NestedDataColumnSchema.java
index 4d7a2790a80..ca6c372872e 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/NestedDataColumnSchema.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/NestedDataColumnSchema.java
@@ -36,6 +36,13 @@ import java.util.Objects;
  */
 public class NestedDataColumnSchema extends DimensionSchema
 {
+  /**
+   * The current (and only supported) nested column format version. This is 
sort of a lie: the segment does not
+   * actually contain a v5 nested column, rather v0 of the 'nested common 
format', but as far as this schema is
+   * concerned it is v5.
+   */
+  public static final int DEFAULT_FORMAT_VERSION = 5;
+
   final int formatVersion;
   @Nullable
   final NestedCommonFormatColumnFormatSpec columnFormatSpec;
@@ -54,9 +61,7 @@ public class NestedDataColumnSchema extends DimensionSchema
     } else if (defaultFormatConfig.getNestedColumnFormatVersion() != null) {
       formatVersion = defaultFormatConfig.getNestedColumnFormatVersion();
     } else {
-      // this is sort of a lie... it's not really v5 in the segment, rather 
its v0 of the 'nested common format'
-      // but as far as this is concerned it is v5
-      formatVersion = 5;
+      formatVersion = DEFAULT_FORMAT_VERSION;
     }
     DefaultColumnFormatConfig.validateNestedFormatVersion(formatVersion);
     if (columnFormatSpec == null) {
diff --git 
a/processing/src/main/java/org/apache/druid/segment/nested/NestedDataComplexTypeSerde.java
 
b/processing/src/main/java/org/apache/druid/segment/nested/NestedDataComplexTypeSerde.java
index a1496dd8dcc..6bd7e468834 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/nested/NestedDataComplexTypeSerde.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/nested/NestedDataComplexTypeSerde.java
@@ -159,7 +159,7 @@ public class NestedDataComplexTypeSerde extends 
ComplexMetricSerde
     @Override
     public DimensionSchema getColumnSchema(String columnName)
     {
-      return new NestedDataColumnSchema(columnName, 5);
+      return new NestedDataColumnSchema(columnName, 
NestedDataColumnSchema.DEFAULT_FORMAT_VERSION);
     }
 
     @Override
diff --git 
a/processing/src/test/java/org/apache/druid/data/input/impl/DimensionSchemaTest.java
 
b/processing/src/test/java/org/apache/druid/data/input/impl/DimensionSchemaTest.java
index 4341cc7529d..1a601955b5d 100644
--- 
a/processing/src/test/java/org/apache/druid/data/input/impl/DimensionSchemaTest.java
+++ 
b/processing/src/test/java/org/apache/druid/data/input/impl/DimensionSchemaTest.java
@@ -21,6 +21,8 @@ package org.apache.druid.data.input.impl;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
+import org.apache.druid.segment.AutoTypeColumnSchema;
+import org.apache.druid.segment.column.ColumnType;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -69,4 +71,49 @@ public class DimensionSchemaTest
         OBJECT_MAPPER.readValue(noType, DimensionSchema.class)
     );
   }
+
+  @Test
+  public void testGetDefaultSchemaForBuiltInType()
+  {
+    Assertions.assertEquals(
+        new StringDimensionSchema("foo"),
+        DimensionSchema.getDefaultSchemaForBuiltInType("foo", 
ColumnType.STRING)
+    );
+    Assertions.assertEquals(
+        new LongDimensionSchema("foo"),
+        DimensionSchema.getDefaultSchemaForBuiltInType("foo", ColumnType.LONG)
+    );
+    Assertions.assertEquals(
+        new FloatDimensionSchema("foo"),
+        DimensionSchema.getDefaultSchemaForBuiltInType("foo", ColumnType.FLOAT)
+    );
+    Assertions.assertEquals(
+        new DoubleDimensionSchema("foo"),
+        DimensionSchema.getDefaultSchemaForBuiltInType("foo", 
ColumnType.DOUBLE)
+    );
+    // Primitive arrays cast an auto column to the given type rather than 
leaving the physical type to be inferred
+    // from the ingested values (an all-null batch has no values to infer the 
array type from). The auto schema
+    // stores ARRAY<FLOAT> as ARRAY<DOUBLE>.
+    Assertions.assertEquals(
+        new AutoTypeColumnSchema("foo", ColumnType.STRING_ARRAY, null),
+        DimensionSchema.getDefaultSchemaForBuiltInType("foo", 
ColumnType.STRING_ARRAY)
+    );
+    Assertions.assertEquals(
+        new AutoTypeColumnSchema("foo", ColumnType.LONG_ARRAY, null),
+        DimensionSchema.getDefaultSchemaForBuiltInType("foo", 
ColumnType.LONG_ARRAY)
+    );
+    Assertions.assertEquals(
+        new AutoTypeColumnSchema("foo", ColumnType.DOUBLE_ARRAY, null),
+        DimensionSchema.getDefaultSchemaForBuiltInType("foo", 
ColumnType.FLOAT_ARRAY)
+    );
+    // Complex types remain untyped auto columns.
+    Assertions.assertEquals(
+        AutoTypeColumnSchema.of("foo"),
+        DimensionSchema.getDefaultSchemaForBuiltInType("foo", 
ColumnType.NESTED_DATA)
+    );
+    Assertions.assertEquals(
+        AutoTypeColumnSchema.of("foo"),
+        DimensionSchema.getDefaultSchemaForBuiltInType("foo", 
ColumnType.ofComplex("hyperUnique"))
+    );
+  }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/segment/NestedDataColumnSchemaTest.java
 
b/processing/src/test/java/org/apache/druid/segment/NestedDataColumnSchemaTest.java
index 66fd98ce043..99071b686ca 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/NestedDataColumnSchemaTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/NestedDataColumnSchemaTest.java
@@ -83,7 +83,7 @@ public class NestedDataColumnSchemaTest
   {
     final String there = "{\"type\":\"json\", \"name\":\"test\"}";
     NestedDataColumnSchema andBack = MAPPER.readValue(there, 
NestedDataColumnSchema.class);
-    Assertions.assertEquals(new NestedDataColumnSchema("test", 5), andBack);
+    Assertions.assertEquals(new NestedDataColumnSchema("test", 
NestedDataColumnSchema.DEFAULT_FORMAT_VERSION), andBack);
   }
 
   @Test
diff --git 
a/server/src/main/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadata.java
 
b/server/src/main/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadata.java
new file mode 100644
index 00000000000..e84cc853c63
--- /dev/null
+++ 
b/server/src/main/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadata.java
@@ -0,0 +1,178 @@
+/*
+ * 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.druid.catalog.model;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import 
org.apache.druid.data.input.impl.ClusteredValueGroupsBaseTableProjectionSpec;
+import org.apache.druid.data.input.impl.DimensionSchema;
+import org.apache.druid.error.InvalidInput;
+import org.apache.druid.segment.NestedDataColumnSchema;
+import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.utils.CollectionUtils;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Catalog layout metadata for {@link 
ClusteredValueGroupsBaseTableProjectionSpec} base tables. Declares the
+ * {@link #clusteringColumns} (names of declared catalog columns that rows are 
clustered by) and optional
+ * {@link #virtualColumns} that compute stored columns at ingest time; 
everything else about the physical spec
+ * (column names, types, and order) is taken from the catalog column list by 
{@link #createSpec(List)}. The declared
+ * column order is the physical segment order, so, mirroring the physical 
spec, the clustering columns must be
+ * declared as the leading prefix of the column list.
+ */
+@JsonTypeName(ClusteredValueGroupsBaseTableMetadata.TYPE_NAME)
+public class ClusteredValueGroupsBaseTableMetadata implements 
DatasourceBaseTableMetadata
+{
+  public static final String TYPE_NAME = 
ClusteredValueGroupsBaseTableProjectionSpec.TYPE_NAME;
+
+  private final List<String> clusteringColumns;
+  private final VirtualColumns virtualColumns;
+
+  @JsonCreator
+  public ClusteredValueGroupsBaseTableMetadata(
+      @JsonProperty("clusteringColumns") List<String> clusteringColumns,
+      @JsonProperty("virtualColumns") @Nullable VirtualColumns virtualColumns
+  )
+  {
+    this.clusteringColumns = clusteringColumns == null ? 
Collections.emptyList() : clusteringColumns;
+    this.virtualColumns = virtualColumns == null ? VirtualColumns.EMPTY : 
virtualColumns;
+  }
+
+  @Override
+  @JsonProperty("type")
+  public String getType()
+  {
+    return TYPE_NAME;
+  }
+
+  @JsonProperty("clusteringColumns")
+  public List<String> getClusteringColumns()
+  {
+    return clusteringColumns;
+  }
+
+  @Override
+  @JsonProperty("virtualColumns")
+  @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+  public VirtualColumns getVirtualColumns()
+  {
+    return virtualColumns;
+  }
+
+  /**
+   * Creates the physical spec from the declared catalog columns, used 
verbatim: the declared column order is the
+   * physical segment order, so the clustering columns must be declared as the 
leading prefix of the column list (in
+   * {@link #clusteringColumns} order); anything else is a validation error. 
Every clustering column must be a declared
+   * column, a clustering column computed by a virtual column at ingest time 
is still a stored, queryable column, so it
+   * too must appear in the column list. All ordering and layout rules are 
enforced by the spec itself.
+   */
+  @Override
+  public ClusteredValueGroupsBaseTableProjectionSpec 
createSpec(List<ColumnSpec> columns)
+  {
+    if (CollectionUtils.isNullOrEmpty(columns)) {
+      throw InvalidInput.exception(
+          "Cannot define a [%s] base table without declared columns; the 
catalog column list defines the table schema",
+          TYPE_NAME
+      );
+    }
+    final Set<String> declaredNames = new HashSet<>();
+    final List<DimensionSchema> specColumns = new ArrayList<>(columns.size());
+    for (ColumnSpec column : columns) {
+      declaredNames.add(column.name());
+      specColumns.add(toDimensionSchema(column));
+    }
+    for (String clusteringColumn : clusteringColumns) {
+      if (!declaredNames.contains(clusteringColumn)) {
+        throw InvalidInput.exception(
+            "clustering column [%s] is not a declared column; clustering 
columns must be declared as the leading"
+            + " prefix of the table's column list, including columns computed 
by a virtual column at ingest time"
+            + " (they are stored columns)",
+            clusteringColumn
+        );
+      }
+    }
+    return ClusteredValueGroupsBaseTableProjectionSpec.builder()
+                                                      
.virtualColumns(virtualColumns)
+                                                      .columns(specColumns)
+                                                      
.clusteringColumns(clusteringColumns)
+                                                      .build();
+  }
+
+  private static DimensionSchema toDimensionSchema(ColumnSpec column)
+  {
+    ColumnType druidType = Columns.druidType(column);
+    if (druidType == null) {
+      druidType = ColumnType.STRING;
+    }
+    if (druidType.isPrimitive() || druidType.isPrimitiveArray()) {
+      // The declared type is retained in the ingestion schema (primitive 
arrays are cast, rather than left to an
+      // untyped auto column whose type is inferred from the ingested values; 
note that the auto schema stores
+      // FLOAT ARRAY as DOUBLE ARRAY).
+      return DimensionSchema.getDefaultSchemaForBuiltInType(column.name(), 
druidType);
+    }
+    if (ColumnType.NESTED_DATA.equals(druidType)) {
+      return new NestedDataColumnSchema(column.name(), 
NestedDataColumnSchema.DEFAULT_FORMAT_VERSION);
+    }
+    // Other complex types cannot be ingested into a clustered base table: 
there is no dimension handler for them,
+    // and clustered base tables have no aggregators to produce them.
+    throw InvalidInput.exception(
+        "column [%s] has unsupported type [%s] for a clustered base table; 
supported types are primitive, primitive"
+        + " array, and COMPLEX<json> columns",
+        column.name(),
+        druidType
+    );
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    ClusteredValueGroupsBaseTableMetadata that = 
(ClusteredValueGroupsBaseTableMetadata) o;
+    return Objects.equals(clusteringColumns, that.clusteringColumns)
+           && Objects.equals(virtualColumns, that.virtualColumns);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(clusteringColumns, virtualColumns);
+  }
+
+  @Override
+  public String toString()
+  {
+    return "ClusteredValueGroupsBaseTableMetadata{" +
+           "clusteringColumns=" + clusteringColumns +
+           ", virtualColumns=" + virtualColumns +
+           '}';
+  }
+}
diff --git 
a/server/src/main/java/org/apache/druid/catalog/model/DatasourceBaseTableMetadata.java
 
b/server/src/main/java/org/apache/druid/catalog/model/DatasourceBaseTableMetadata.java
new file mode 100644
index 00000000000..b73cfd7f4b4
--- /dev/null
+++ 
b/server/src/main/java/org/apache/druid/catalog/model/DatasourceBaseTableMetadata.java
@@ -0,0 +1,65 @@
+/*
+ * 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.druid.catalog.model;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import org.apache.druid.data.input.impl.BaseTableProjectionSpec;
+import org.apache.druid.segment.VirtualColumns;
+
+import java.util.List;
+
+/**
+ * Catalog model for the physical layout of a datasource "base table", the 
counterpart of
+ * {@link BaseTableProjectionSpec} for tables defined in the catalog. Unlike 
the physical spec, this metadata does NOT
+ * declare the table schema: the catalog column list remains the single source 
of truth for column names, types, and
+ * order. Implementations carry only the layout details the column list cannot 
express (such as clustering columns and
+ * ingest-time virtual columns), and {@link #createSpec(List)} combines the 
two into the physical spec used to
+ * generate segments.
+ */
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = 
JsonTypeInfo.As.EXISTING_PROPERTY, property = "type")
+@JsonSubTypes(value = {
+    @JsonSubTypes.Type(
+        name = ClusteredValueGroupsBaseTableMetadata.TYPE_NAME,
+        value = ClusteredValueGroupsBaseTableMetadata.class
+    )
+})
+public interface DatasourceBaseTableMetadata
+{
+  /**
+   * The type discriminator, declared as a regular JSON property ({@link 
JsonTypeInfo.As#EXISTING_PROPERTY}) rather
+   * than a synthetic one so that it is emitted even when an instance is 
serialized from an untyped context;catalog
+   * property values live in a plain {@code Map<String, Object>}, where 
Jackson serializes by runtime type and would
+   * otherwise drop a synthetic type id.
+   */
+  String getType();
+
+  /**
+   * Virtual columns computed at ingest time to materialize stored columns.
+   */
+  VirtualColumns getVirtualColumns();
+
+  /**
+   * Creates the physical {@link BaseTableProjectionSpec} by combining this 
layout metadata with the declared catalog
+   * columns. Throws {@link org.apache.druid.error.DruidException} if the 
layout is inconsistent with the declared
+   * columns, so this doubles as the cross-validation of the catalog property 
against the column list.
+   */
+  BaseTableProjectionSpec createSpec(List<ColumnSpec> columns);
+}
diff --git 
a/server/src/main/java/org/apache/druid/catalog/model/facade/DatasourceFacade.java
 
b/server/src/main/java/org/apache/druid/catalog/model/facade/DatasourceFacade.java
index 39027ac8f09..1ee50c75c6e 100644
--- 
a/server/src/main/java/org/apache/druid/catalog/model/facade/DatasourceFacade.java
+++ 
b/server/src/main/java/org/apache/druid/catalog/model/facade/DatasourceFacade.java
@@ -19,14 +19,18 @@
 
 package org.apache.druid.catalog.model.facade;
 
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableMap;
 import org.apache.druid.catalog.model.CatalogUtils;
 import org.apache.druid.catalog.model.ColumnSpec;
 import org.apache.druid.catalog.model.Columns;
+import org.apache.druid.catalog.model.DatasourceBaseTableMetadata;
 import org.apache.druid.catalog.model.DatasourceProjectionMetadata;
 import org.apache.druid.catalog.model.ResolvedTable;
 import org.apache.druid.catalog.model.table.ClusterKeySpec;
 import org.apache.druid.catalog.model.table.DatasourceDefn;
+import org.apache.druid.error.InvalidSqlInput;
 import org.apache.druid.java.util.common.granularity.Granularity;
 import org.apache.druid.java.util.common.logger.Logger;
 import org.apache.druid.segment.column.ColumnType;
@@ -104,6 +108,12 @@ public class DatasourceFacade extends TableFacade
 
   private final List<ColumnFacade> columns;
   private final Map<String, ColumnFacade> columnIndex;
+  /**
+   * Memoized: facades live as long as their cached table entry and {@link 
#insertColumn} consults this per column,
+   * while decoding the property does a Jackson conversion each time.
+   */
+  private final Supplier<DatasourceBaseTableMetadata> baseTableMetadata =
+      Suppliers.memoize(this::decodeBaseTableMetadata);
 
   public DatasourceFacade(ResolvedTable resolved)
   {
@@ -146,7 +156,7 @@ public class DatasourceFacade extends TableFacade
       return jsonMapper().convertValue(value, 
ClusterKeySpec.CLUSTER_KEY_LIST_TYPE_REF);
     }
     catch (Exception e) {
-      LOG.error("Failed to convert a catalog %s property of value [%s]",
+      LOG.error("Failed to convert a catalog [%s] property of value [%s]",
           DatasourceDefn.CLUSTER_KEYS_PROPERTY,
           value
       );
@@ -176,6 +186,45 @@ public class DatasourceFacade extends TableFacade
     return columnIndex.get(name);
   }
 
+  /**
+   * Resolves a column as the target of an INSERT/REPLACE write, enforcing the 
catalog write rules: a column that is
+   * not declared is rejected when the table schema is {@link #isSealed() 
sealed}, and a column computed by a base
+   * table layout virtual column at ingest time is always rejected, its stored 
value is always the computed one, so
+   * the supplied values would be silently discarded. Returns the column 
facade for a declared column, or null for an
+   * undeclared column of a non-sealed table (the caller adds the column based 
on the query type).
+   *
+   * @param columnName      name of the column the query writes to
+   * @param targetTableName display name of the target table, used only in 
error messages. This is the table this
+   *                        facade describes, but supplied by the caller: a 
{@link ResolvedTable} does not know its
+   *                        own name (identity lives on {@link 
org.apache.druid.catalog.model.TableMetadata}), and
+   *                        SQL callers report the name as written in the 
query rather than the catalog identifier.
+   */
+  @Nullable
+  public ColumnFacade insertColumn(String columnName, String targetTableName)
+  {
+    final ColumnFacade definedCol = column(columnName);
+    if (definedCol == null) {
+      if (isSealed()) {
+        throw InvalidSqlInput.exception(
+            "Column [%s] is not defined in the target table [%s] strict 
schema",
+            columnName,
+            targetTableName
+        );
+      }
+      return null;
+    }
+    final DatasourceBaseTableMetadata baseTable = baseTableMetadata.get();
+    if (baseTable != null && baseTable.getVirtualColumns().exists(columnName)) 
{
+      throw InvalidSqlInput.exception(
+          "Column [%s] of target table [%s] is computed by a virtual column at 
ingest time and cannot be written"
+          + " directly; supply the virtual column's input columns instead",
+          columnName,
+          targetTableName
+      );
+    }
+    return definedCol;
+  }
+
   @Nullable
   public List<DatasourceProjectionMetadata> projections()
   {
@@ -188,11 +237,38 @@ public class DatasourceFacade extends TableFacade
     }
     catch (Exception e) {
       LOG.error(
-          "Failed to convert a catalog %s property of value [%s]",
+          "Failed to convert a catalog [%s] property of value [%s]",
           DatasourceDefn.PROJECTIONS_KEYS_PROPERTY,
           value
       );
       return null;
     }
   }
+
+  @Nullable
+  public DatasourceBaseTableMetadata baseTableMetadata()
+  {
+    return baseTableMetadata.get();
+  }
+
+  @Nullable
+  private DatasourceBaseTableMetadata decodeBaseTableMetadata()
+  {
+    Object value = property(DatasourceDefn.BASE_TABLE_PROPERTY);
+    if (value == null) {
+      return null;
+    }
+    try {
+      return jsonMapper().convertValue(value, 
DatasourceDefn.BaseTableDefn.TYPE_REF);
+    }
+    catch (Exception e) {
+      LOG.error(
+          e,
+          "Failed to convert a catalog [%s] property of value [%s]",
+          DatasourceDefn.BASE_TABLE_PROPERTY,
+          value
+      );
+      return null;
+    }
+  }
 }
diff --git 
a/server/src/main/java/org/apache/druid/catalog/model/table/DatasourceDefn.java 
b/server/src/main/java/org/apache/druid/catalog/model/table/DatasourceDefn.java
index 723beaf2d89..ef3a41dba46 100644
--- 
a/server/src/main/java/org/apache/druid/catalog/model/table/DatasourceDefn.java
+++ 
b/server/src/main/java/org/apache/druid/catalog/model/table/DatasourceDefn.java
@@ -23,6 +23,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.druid.catalog.model.ColumnSpec;
 import org.apache.druid.catalog.model.Columns;
+import org.apache.druid.catalog.model.DatasourceBaseTableMetadata;
 import org.apache.druid.catalog.model.DatasourceProjectionMetadata;
 import org.apache.druid.catalog.model.ModelProperties;
 import org.apache.druid.catalog.model.ModelProperties.GranularityPropertyDefn;
@@ -30,6 +31,7 @@ import 
org.apache.druid.catalog.model.ModelProperties.StringListPropertyDefn;
 import org.apache.druid.catalog.model.ResolvedTable;
 import org.apache.druid.catalog.model.TableDefn;
 import org.apache.druid.catalog.model.TableSpec;
+import org.apache.druid.error.InvalidInput;
 import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.StringUtils;
 
@@ -63,6 +65,14 @@ public class DatasourceDefn extends TableDefn
 
   public static final String PROJECTIONS_KEYS_PROPERTY = "projections";
 
+  /**
+   * Physical layout of the base table. The layout combines with the declared 
column list (which remains the source of
+   * truth for column names, types, and order) to derive the physical spec 
used to generate segments;
+   *
+   * @see DatasourceBaseTableMetadata#createSpec
+   */
+  public static final String BASE_TABLE_PROPERTY = "baseTable";
+
   /**
    * The set of existing columns to "delete" (actually, just hide) from the
    * SQL layer. Used to "remove" unwanted columns to avoid the need to rewrite
@@ -85,12 +95,37 @@ public class DatasourceDefn extends TableDefn
             new ClusterKeysDefn(),
             new HiddenColumnsDefn(),
             new ModelProperties.BooleanPropertyDefn(SEALED_PROPERTY),
-            new ProjectionsDefn()
+            new ProjectionsDefn(),
+            new BaseTableDefn()
         ),
         null
     );
   }
 
+  @Override
+  public void validate(ResolvedTable table)
+  {
+    super.validate(table);
+    final DatasourceBaseTableMetadata baseTable = 
table.decodeProperty(BASE_TABLE_PROPERTY);
+    if (baseTable != null) {
+      // A base table layout derives the physical segment schema from the 
declared columns, so a column the query
+      // produces but the table does not declare cannot be stored; require 
'sealed' so ingestion rejects such columns
+      // instead of silently dropping them. Requiring the flag allows us to 
someday support non-sealed definitions,
+      // which could work by appending undeclared columns to the derived 
schema.
+      if (!table.booleanProperty(SEALED_PROPERTY)) {
+        throw InvalidInput.exception(
+            "Datasource with a [%s] layout must also set [%s] to true; the 
declared columns define the physical"
+            + " segment schema, so columns not declared in the table cannot be 
ingested",
+            BASE_TABLE_PROPERTY,
+            SEALED_PROPERTY
+        );
+      }
+      // Cross-validate the layout against the declared columns by deriving 
the physical spec, so that catalog writes
+      // fail fast instead of surfacing layout problems at ingest time.
+      baseTable.createSpec(table.spec().columns());
+    }
+  }
+
   @Override
   protected void validateColumn(ColumnSpec spec)
   {
@@ -182,4 +217,14 @@ public class DatasourceDefn extends TableDefn
       super(PROJECTIONS_KEYS_PROPERTY, "DatasourceProjectionMetadata list", 
TYPE_REF);
     }
   }
+
+  public static class BaseTableDefn extends 
ModelProperties.TypeRefPropertyDefn<DatasourceBaseTableMetadata>
+  {
+    public static final TypeReference<DatasourceBaseTableMetadata> TYPE_REF = 
new TypeReference<>() {};
+
+    public BaseTableDefn()
+    {
+      super(BASE_TABLE_PROPERTY, "DatasourceBaseTableMetadata", TYPE_REF);
+    }
+  }
 }
diff --git 
a/server/src/main/java/org/apache/druid/catalog/model/table/TableBuilder.java 
b/server/src/main/java/org/apache/druid/catalog/model/table/TableBuilder.java
index e2a9cbe6537..bb32ca68665 100644
--- 
a/server/src/main/java/org/apache/druid/catalog/model/table/TableBuilder.java
+++ 
b/server/src/main/java/org/apache/druid/catalog/model/table/TableBuilder.java
@@ -24,6 +24,7 @@ import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import org.apache.druid.catalog.model.ColumnSpec;
 import org.apache.druid.catalog.model.Columns;
+import org.apache.druid.catalog.model.DatasourceBaseTableMetadata;
 import org.apache.druid.catalog.model.ResolvedTable;
 import org.apache.druid.catalog.model.TableDefn;
 import org.apache.druid.catalog.model.TableId;
@@ -141,6 +142,11 @@ public class TableBuilder
     return property(DatasourceDefn.CLUSTER_KEYS_PROPERTY, 
Arrays.asList(clusterKeys));
   }
 
+  public TableBuilder baseTable(DatasourceBaseTableMetadata baseTable)
+  {
+    return property(DatasourceDefn.BASE_TABLE_PROPERTY, baseTable);
+  }
+
   public TableBuilder hiddenColumns(List<String> hiddenColumns)
   {
     return property(DatasourceDefn.HIDDEN_COLUMNS_PROPERTY, hiddenColumns);
diff --git 
a/server/src/test/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadataTest.java
 
b/server/src/test/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadataTest.java
new file mode 100644
index 00000000000..2d99f03a8e2
--- /dev/null
+++ 
b/server/src/test/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadataTest.java
@@ -0,0 +1,340 @@
+/*
+ * 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.druid.catalog.model;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.InjectableValues;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import nl.jqno.equalsverifier.EqualsVerifier;
+import 
org.apache.druid.data.input.impl.ClusteredValueGroupsBaseTableProjectionSpec;
+import org.apache.druid.data.input.impl.DoubleDimensionSchema;
+import org.apache.druid.data.input.impl.LongDimensionSchema;
+import org.apache.druid.data.input.impl.StringDimensionSchema;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.jackson.DefaultObjectMapper;
+import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.segment.AutoTypeColumnSchema;
+import org.apache.druid.segment.NestedDataColumnSchema;
+import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+public class ClusteredValueGroupsBaseTableMetadataTest
+{
+  private final ObjectMapper mapper = new 
DefaultObjectMapper().setInjectableValues(
+      new InjectableValues.Std().addValue(ExprMacroTable.class, 
ExprMacroTable.nil())
+  );
+
+  // Declared order is the physical segment order: clustering columns lead, 
__time is an explicit positional column.
+  private static final List<ColumnSpec> COLUMNS = Arrays.asList(
+      new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+      new ColumnSpec(Columns.TIME_COLUMN, Columns.SQL_TIMESTAMP, null),
+      new ColumnSpec("region", null, null),
+      new ColumnSpec("delta", Columns.SQL_BIGINT, null),
+      new ColumnSpec("value", Columns.SQL_DOUBLE, null)
+  );
+
+  @Test
+  public void testSerde() throws Exception
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant_lower"),
+        VirtualColumns.create(
+            new ExpressionVirtualColumn("tenant_lower", "lower(\"tenant\")", 
ColumnType.STRING, ExprMacroTable.nil())
+        )
+    );
+    final String json = mapper.writeValueAsString(metadata);
+    final DatasourceBaseTableMetadata fromJson = mapper.readValue(json, 
DatasourceBaseTableMetadata.class);
+    Assert.assertEquals(metadata, fromJson);
+  }
+
+  @Test
+  public void testSerdeNoVirtualColumns() throws Exception
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Arrays.asList("tenant", "region"),
+        null
+    );
+    final String json = mapper.writeValueAsString(metadata);
+    Assert.assertFalse(json.contains("virtualColumns"));
+    final DatasourceBaseTableMetadata fromJson = mapper.readValue(json, 
DatasourceBaseTableMetadata.class);
+    Assert.assertEquals(metadata, fromJson);
+  }
+
+  @Test
+  public void testSerdeAsUntypedMapValue() throws Exception
+  {
+    // Catalog property values are serialized from a Map<String, Object>, 
where Jackson serializes by runtime type;
+    // the type discriminator must survive that path (it is an 
EXISTING_PROPERTY for this reason).
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant"),
+        null
+    );
+    final String json = 
mapper.writeValueAsString(Collections.singletonMap("baseTable", metadata));
+    Assert.assertTrue(json.contains("\"type\":\"clusteredValueGroups\""));
+    final Map<String, Object> untyped = mapper.readValue(json, new 
TypeReference<>() {});
+    Assert.assertEquals(
+        metadata,
+        mapper.convertValue(untyped.get("baseTable"), 
DatasourceBaseTableMetadata.class)
+    );
+  }
+
+  @Test
+  public void testCreateSpec()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant"),
+        null
+    );
+    // The declared column order is the physical segment order, used verbatim; 
types map through Columns.druidType
+    // with untyped -> STRING.
+    Assert.assertEquals(
+        ClusteredValueGroupsBaseTableProjectionSpec.builder()
+                                                   .columns(
+                                                       new 
StringDimensionSchema("tenant"),
+                                                       new 
LongDimensionSchema(Columns.TIME_COLUMN),
+                                                       new 
StringDimensionSchema("region"),
+                                                       new 
LongDimensionSchema("delta"),
+                                                       new 
DoubleDimensionSchema("value")
+                                                   )
+                                                   .clusteringColumns("tenant")
+                                                   .build(),
+        metadata.createSpec(COLUMNS)
+    );
+  }
+
+  @Test
+  public void testCreateSpecWithVirtualColumn()
+  {
+    // A clustering column computed at ingest time: the virtual column 
materializes the stored, declared
+    // 'tenant_lower' column, reading the stored 'tenant' column (virtual 
column inputs must themselves be stored
+    // columns or other virtual columns).
+    final VirtualColumns virtualColumns = VirtualColumns.create(
+        new ExpressionVirtualColumn("tenant_lower", "lower(\"tenant\")", 
ColumnType.STRING, ExprMacroTable.nil())
+    );
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant_lower"),
+        virtualColumns
+    );
+    final List<ColumnSpec> columns = Arrays.asList(
+        new ColumnSpec("tenant_lower", Columns.SQL_VARCHAR, null),
+        new ColumnSpec(Columns.TIME_COLUMN, null, null),
+        new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+        new ColumnSpec("region", Columns.SQL_VARCHAR, null)
+    );
+    Assert.assertEquals(
+        ClusteredValueGroupsBaseTableProjectionSpec.builder()
+                                                   
.virtualColumns(virtualColumns)
+                                                   .columns(
+                                                       new 
StringDimensionSchema("tenant_lower"),
+                                                       new 
LongDimensionSchema(Columns.TIME_COLUMN),
+                                                       new 
StringDimensionSchema("tenant"),
+                                                       new 
StringDimensionSchema("region")
+                                                   )
+                                                   
.clusteringColumns("tenant_lower")
+                                                   .build(),
+        metadata.createSpec(columns)
+    );
+  }
+
+  @Test
+  public void testCreateSpecMultipleClusteringColumns()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Arrays.asList("region", "tenant"),
+        null
+    );
+    final List<ColumnSpec> columns = Arrays.asList(
+        new ColumnSpec("region", Columns.SQL_VARCHAR, null),
+        new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+        new ColumnSpec(Columns.TIME_COLUMN, null, null),
+        new ColumnSpec("delta", Columns.SQL_BIGINT, null)
+    );
+    Assert.assertEquals(
+        ClusteredValueGroupsBaseTableProjectionSpec.builder()
+                                                   .columns(
+                                                       new 
StringDimensionSchema("region"),
+                                                       new 
StringDimensionSchema("tenant"),
+                                                       new 
LongDimensionSchema(Columns.TIME_COLUMN),
+                                                       new 
LongDimensionSchema("delta")
+                                                   )
+                                                   
.clusteringColumns("region", "tenant")
+                                                   .build(),
+        metadata.createSpec(columns)
+    );
+  }
+
+  @Test
+  public void testCreateSpecRetainsDeclaredArrayAndNestedTypes()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant"),
+        null
+    );
+    final List<ColumnSpec> columns = Arrays.asList(
+        new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+        new ColumnSpec(Columns.TIME_COLUMN, null, null),
+        new ColumnSpec("tags", Columns.SQL_VARCHAR_ARRAY, null),
+        new ColumnSpec("vals", Columns.SQL_BIGINT_ARRAY, null),
+        new ColumnSpec("ratios", Columns.SQL_FLOAT_ARRAY, null),
+        new ColumnSpec("attrs", ColumnType.NESTED_DATA.asTypeString(), null)
+    );
+    // Declared types are retained in the ingestion schema rather than left to 
inference: arrays cast an auto column
+    // to the declared type (an all-null batch has no values to infer from; 
FLOAT ARRAY is stored as DOUBLE ARRAY by
+    // the auto schema), and COMPLEX<json> uses the dedicated nested column 
schema.
+    Assert.assertEquals(
+        ClusteredValueGroupsBaseTableProjectionSpec.builder()
+                                                   .columns(
+                                                       new 
StringDimensionSchema("tenant"),
+                                                       new 
LongDimensionSchema(Columns.TIME_COLUMN),
+                                                       new 
AutoTypeColumnSchema("tags", ColumnType.STRING_ARRAY, null),
+                                                       new 
AutoTypeColumnSchema("vals", ColumnType.LONG_ARRAY, null),
+                                                       new 
AutoTypeColumnSchema("ratios", ColumnType.DOUBLE_ARRAY, null),
+                                                       new 
NestedDataColumnSchema("attrs", NestedDataColumnSchema.DEFAULT_FORMAT_VERSION)
+                                                   )
+                                                   .clusteringColumns("tenant")
+                                                   .build(),
+        metadata.createSpec(columns)
+    );
+  }
+
+  @Test
+  public void testCreateSpecUnsupportedComplexTypeFails()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant"),
+        null
+    );
+    final List<ColumnSpec> columns = Arrays.asList(
+        new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+        new ColumnSpec(Columns.TIME_COLUMN, null, null),
+        new ColumnSpec("unique_things", "COMPLEX<hyperUnique>", null)
+    );
+    final DruidException e = Assert.assertThrows(DruidException.class, () -> 
metadata.createSpec(columns));
+    Assert.assertTrue(e.getMessage().contains("column [unique_things] has 
unsupported type [COMPLEX<hyperUnique>]"));
+  }
+
+  @Test
+  public void testCreateSpecClusteringColumnsNotLeadingPrefixFails()
+  {
+    // 'region' is declared, but not as part of the leading prefix of the 
column list; the declared order is the
+    // physical segment order, so this is an error rather than a silent 
reorder.
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("region"),
+        null
+    );
+    final DruidException e = Assert.assertThrows(DruidException.class, () -> 
metadata.createSpec(COLUMNS));
+    Assert.assertTrue(e.getMessage().contains("clusteringColumns must be the 
leading prefix of columns"));
+  }
+
+  @Test
+  public void testCreateSpecClusteringColumnsWrongOrderFails()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Arrays.asList("tenant", "region"),
+        null
+    );
+    final List<ColumnSpec> columns = Arrays.asList(
+        new ColumnSpec("region", Columns.SQL_VARCHAR, null),
+        new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+        new ColumnSpec(Columns.TIME_COLUMN, null, null)
+    );
+    final DruidException e = Assert.assertThrows(DruidException.class, () -> 
metadata.createSpec(columns));
+    Assert.assertTrue(e.getMessage().contains("clusteringColumns must be the 
leading prefix of columns"));
+  }
+
+  @Test
+  public void testCreateSpecUndeclaredClusteringColumnFails()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("no_such_column"),
+        null
+    );
+    final DruidException e = Assert.assertThrows(DruidException.class, () -> 
metadata.createSpec(COLUMNS));
+    Assert.assertTrue(e.getMessage().contains("clustering column 
[no_such_column] is not a declared column"));
+  }
+
+  @Test
+  public void testCreateSpecMissingTimeColumnFails()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant"),
+        null
+    );
+    final List<ColumnSpec> columns = Arrays.asList(
+        new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+        new ColumnSpec("region", Columns.SQL_VARCHAR, null)
+    );
+    final DruidException e = Assert.assertThrows(DruidException.class, () -> 
metadata.createSpec(columns));
+    Assert.assertTrue(e.getMessage().contains("must include [__time]"));
+  }
+
+  @Test
+  public void testCreateSpecDisallowedClusteringTypeFails()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tags"),
+        null
+    );
+    final List<ColumnSpec> columns = Arrays.asList(
+        new ColumnSpec("tags", Columns.SQL_VARCHAR_ARRAY, null),
+        new ColumnSpec(Columns.TIME_COLUMN, Columns.SQL_TIMESTAMP, null)
+    );
+    final DruidException e = Assert.assertThrows(DruidException.class, () -> 
metadata.createSpec(columns));
+    Assert.assertTrue(e.getMessage().contains("unsupported type"));
+  }
+
+  @Test
+  public void testCreateSpecEmptyClusteringColumnsFails()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(null, null);
+    final DruidException e = Assert.assertThrows(DruidException.class, () -> 
metadata.createSpec(COLUMNS));
+    Assert.assertTrue(e.getMessage().contains("clusteringColumns must be 
non-empty"));
+  }
+
+  @Test
+  public void testCreateSpecNoDeclaredColumnsFails()
+  {
+    final DatasourceBaseTableMetadata metadata = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant"),
+        null
+    );
+    final DruidException e = Assert.assertThrows(
+        DruidException.class,
+        () -> metadata.createSpec(Collections.emptyList())
+    );
+    Assert.assertTrue(e.getMessage().contains("without declared columns"));
+  }
+
+  @Test
+  public void testEquals()
+  {
+    EqualsVerifier.forClass(ClusteredValueGroupsBaseTableMetadata.class)
+                  .usingGetClass()
+                  .verify();
+  }
+}
diff --git 
a/server/src/test/java/org/apache/druid/catalog/model/table/DatasourceTableTest.java
 
b/server/src/test/java/org/apache/druid/catalog/model/table/DatasourceTableTest.java
index b93db97acc6..b4e06cb1a01 100644
--- 
a/server/src/test/java/org/apache/druid/catalog/model/table/DatasourceTableTest.java
+++ 
b/server/src/test/java/org/apache/druid/catalog/model/table/DatasourceTableTest.java
@@ -19,23 +19,30 @@
 
 package org.apache.druid.catalog.model.table;
 
+import com.fasterxml.jackson.databind.InjectableValues;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import nl.jqno.equalsverifier.EqualsVerifier;
 import org.apache.druid.catalog.CatalogTest;
+import org.apache.druid.catalog.model.ClusteredValueGroupsBaseTableMetadata;
 import org.apache.druid.catalog.model.ColumnSpec;
 import org.apache.druid.catalog.model.Columns;
+import org.apache.druid.catalog.model.DatasourceBaseTableMetadata;
 import org.apache.druid.catalog.model.ResolvedTable;
 import org.apache.druid.catalog.model.TableDefn;
 import org.apache.druid.catalog.model.TableDefnRegistry;
 import org.apache.druid.catalog.model.TableSpec;
 import org.apache.druid.catalog.model.facade.DatasourceFacade;
 import org.apache.druid.catalog.model.facade.DatasourceFacade.ColumnFacade;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.logger.Logger;
+import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.segment.VirtualColumns;
 import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.experimental.categories.Category;
@@ -139,6 +146,142 @@ public class DatasourceTableTest
     }
   }
 
+  @Test
+  public void testSpecWithBaseTableProp()
+  {
+    // The declared column order is the physical segment order: the clustering 
columns must lead.
+    final List<ColumnSpec> columns = Arrays.asList(
+        new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+        new ColumnSpec(Columns.TIME_COLUMN, null, null),
+        new ColumnSpec("region", Columns.SQL_VARCHAR, null)
+    );
+
+    {
+      TableSpec spec = new TableSpec(
+          DatasourceDefn.TABLE_TYPE,
+          ImmutableMap.of(
+              DatasourceDefn.SEALED_PROPERTY, true,
+              DatasourceDefn.BASE_TABLE_PROPERTY,
+              new 
ClusteredValueGroupsBaseTableMetadata(Collections.singletonList("tenant"), null)
+          ),
+          columns
+      );
+      expectValidationSucceeds(spec);
+    }
+
+    {
+      // A base table layout requires 'sealed': the declared columns define 
the physical segment schema, so
+      // undeclared columns cannot be ingested.
+      TableSpec spec = new TableSpec(
+          DatasourceDefn.TABLE_TYPE,
+          ImmutableMap.of(
+              DatasourceDefn.BASE_TABLE_PROPERTY,
+              new 
ClusteredValueGroupsBaseTableMetadata(Collections.singletonList("tenant"), null)
+          ),
+          columns
+      );
+      ResolvedTable table = registry.resolve(spec);
+      DruidException e = assertThrows(DruidException.class, table::validate);
+      assertTrue(e.getMessage().contains("must also set [sealed] to true"));
+    }
+
+    {
+      // Validation is cross-checked against the declared columns: an 
undeclared clustering column fails at
+      // catalog write time.
+      TableSpec spec = new TableSpec(
+          DatasourceDefn.TABLE_TYPE,
+          ImmutableMap.of(
+              DatasourceDefn.SEALED_PROPERTY, true,
+              DatasourceDefn.BASE_TABLE_PROPERTY,
+              new 
ClusteredValueGroupsBaseTableMetadata(Collections.singletonList("no_such_column"),
 null)
+          ),
+          columns
+      );
+      ResolvedTable table = registry.resolve(spec);
+      assertThrows(DruidException.class, table::validate);
+    }
+
+    {
+      // The clustering columns must be declared as the leading prefix of the 
column list; declared-but-not-first
+      // is an error, not a silent reorder.
+      TableSpec spec = new TableSpec(
+          DatasourceDefn.TABLE_TYPE,
+          ImmutableMap.of(
+              DatasourceDefn.SEALED_PROPERTY, true,
+              DatasourceDefn.BASE_TABLE_PROPERTY,
+              new 
ClusteredValueGroupsBaseTableMetadata(Collections.singletonList("region"), null)
+          ),
+          columns
+      );
+      ResolvedTable table = registry.resolve(spec);
+      assertThrows(DruidException.class, table::validate);
+    }
+
+    {
+      // __time must be declared in the column list.
+      TableSpec spec = new TableSpec(
+          DatasourceDefn.TABLE_TYPE,
+          ImmutableMap.of(
+              DatasourceDefn.SEALED_PROPERTY, true,
+              DatasourceDefn.BASE_TABLE_PROPERTY,
+              new 
ClusteredValueGroupsBaseTableMetadata(Collections.singletonList("tenant"), null)
+          ),
+          Collections.singletonList(new ColumnSpec("tenant", 
Columns.SQL_VARCHAR, null))
+      );
+      ResolvedTable table = registry.resolve(spec);
+      assertThrows(DruidException.class, table::validate);
+    }
+  }
+
+  @Test
+  public void testBaseTableFacade()
+  {
+    final ObjectMapper mapperWithInjectables = new 
DefaultObjectMapper().setInjectableValues(
+        new InjectableValues.Std().addValue(ExprMacroTable.class, 
ExprMacroTable.nil())
+    );
+    final TableDefnRegistry registryWithInjectables = new 
TableDefnRegistry(mapperWithInjectables);
+    final DatasourceBaseTableMetadata baseTable = new 
ClusteredValueGroupsBaseTableMetadata(
+        Collections.singletonList("tenant_lower"),
+        VirtualColumns.create(
+            new ExpressionVirtualColumn("tenant_lower", "lower(\"tenant\")", 
ColumnType.STRING, ExprMacroTable.nil())
+        )
+    );
+    TableSpec spec = new TableSpec(
+        DatasourceDefn.TABLE_TYPE,
+        ImmutableMap.of(
+            DatasourceDefn.SEALED_PROPERTY, true,
+            DatasourceDefn.BASE_TABLE_PROPERTY, baseTable
+        ),
+        Arrays.asList(
+            new ColumnSpec("tenant_lower", Columns.SQL_VARCHAR, null),
+            new ColumnSpec(Columns.TIME_COLUMN, null, null),
+            new ColumnSpec("tenant", Columns.SQL_VARCHAR, null)
+        )
+    );
+    ResolvedTable table = registryWithInjectables.resolve(spec);
+    table.validate();
+    DatasourceFacade facade = new DatasourceFacade(table);
+    assertEquals(baseTable, facade.baseTableMetadata());
+
+    // insertColumn enforces the catalog write rules: declared columns 
resolve, undeclared columns are rejected by
+    // the sealed schema, and columns computed by a virtual column at ingest 
time cannot be written directly.
+    assertSame(facade.column("tenant"), facade.insertColumn("tenant", 
"druid.tbl"));
+    DruidException sealedError =
+        assertThrows(DruidException.class, () -> 
facade.insertColumn("no_such_column", "druid.tbl"));
+    assertTrue(sealedError.getMessage().contains("not defined in the target 
table [druid.tbl] strict schema"));
+    DruidException computedError =
+        assertThrows(DruidException.class, () -> 
facade.insertColumn("tenant_lower", "druid.tbl"));
+    assertTrue(computedError.getMessage().contains("computed by a virtual 
column at ingest time"));
+
+    // An undeclared column of a non-sealed table resolves to null: the caller 
adds it from the query type.
+    DatasourceFacade unsealed = new 
DatasourceFacade(registryWithInjectables.resolve(new TableSpec(
+        DatasourceDefn.TABLE_TYPE,
+        ImmutableMap.of(),
+        Collections.singletonList(new ColumnSpec(Columns.TIME_COLUMN, null, 
null))
+    )));
+    assertNull(unsealed.insertColumn("anything", "druid.tbl"));
+  }
+
   @Test
   public void testAllProperties()
   {
diff --git 
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidSqlValidator.java 
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidSqlValidator.java
index 66d66174cc5..aa0781b6ba8 100644
--- 
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidSqlValidator.java
+++ 
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidSqlValidator.java
@@ -534,21 +534,12 @@ public class DruidSqlValidator extends 
BaseDruidSqlValidator
       return sourceType;
     }
 
-    // disable sealed mode validation if catalog validation is disabled.
-    final boolean isStrict = tableMetadata.isSealed();
     final List<Map.Entry<String, RelDataType>> fields = new ArrayList<>();
     for (RelDataTypeField sourceField : sourceFields) {
       final String colName = sourceField.getName();
-      final DatasourceFacade.ColumnFacade definedCol = 
tableMetadata.column(colName);
+      final DatasourceFacade.ColumnFacade definedCol =
+          tableMetadata.insertColumn(colName, 
insert.getTargetTable().toString());
       if (definedCol == null) {
-        if (isStrict) {
-          throw InvalidSqlInput.exception(
-              "Column [%s] is not defined in the target table [%s] strict 
schema",
-              colName,
-              insert.getTargetTable()
-          );
-        }
-
         // Table is not strict: add a new column based on the SELECT column.
         fields.add(Pair.of(colName, sourceField.getType()));
         continue;
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteCatalogIngestionDmlTest.java
 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteCatalogIngestionDmlTest.java
index d89d618bda1..fcd498450c3 100644
--- 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteCatalogIngestionDmlTest.java
+++ 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteCatalogIngestionDmlTest.java
@@ -19,9 +19,11 @@
 
 package org.apache.druid.sql.calcite;
 
+import com.fasterxml.jackson.databind.InjectableValues;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import org.apache.druid.catalog.model.ClusteredValueGroupsBaseTableMetadata;
 import org.apache.druid.catalog.model.ColumnSpec;
 import org.apache.druid.catalog.model.Columns;
 import org.apache.druid.catalog.model.ResolvedTable;
@@ -36,6 +38,7 @@ import org.apache.druid.error.DruidException;
 import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.math.expr.ExprMacroTable;
 import org.apache.druid.query.OrderBy;
 import org.apache.druid.query.QueryContexts;
 import org.apache.druid.query.TableDataSource;
@@ -43,8 +46,10 @@ import 
org.apache.druid.query.aggregation.cardinality.CardinalityAggregatorFacto
 import 
org.apache.druid.query.aggregation.hyperloglog.HyperUniquesAggregatorFactory;
 import org.apache.druid.query.dimension.DefaultDimensionSpec;
 import org.apache.druid.query.groupby.GroupByQuery;
+import org.apache.druid.segment.VirtualColumns;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
 import 
org.apache.druid.sql.calcite.CalciteCatalogIngestionDmlTest.CatalogIngestionDmlComponentSupplier;
 import org.apache.druid.sql.calcite.external.ExternalDataSource;
 import org.apache.druid.sql.calcite.external.Externals;
@@ -92,7 +97,9 @@ public abstract class CalciteCatalogIngestionDmlTest extends 
CalciteIngestionDml
       super(tempFolderProducer);
     }
 
-    private static final ObjectMapper MAPPER = new DefaultObjectMapper();
+    private static final ObjectMapper MAPPER = new 
DefaultObjectMapper().setInjectableValues(
+        new InjectableValues.Std().addValue(ExprMacroTable.class, 
ExprMacroTable.nil())
+    );
     public static ImmutableMap<String, DatasourceTable> RESOLVED_TABLES = 
ImmutableMap.of(
         "hourDs", new DatasourceTable(
             RowSignature.builder().addTimeColumn().build(),
@@ -352,6 +359,73 @@ public abstract class CalciteCatalogIngestionDmlTest 
extends CalciteIngestionDml
                     .build()),
                 false
             )
+        ),
+        "tableWithBaseTable", new DatasourceTable(
+            RowSignature.builder()
+                .add("tenant_lower", ColumnType.STRING)
+                .addTimeColumn()
+                .add("tenant", ColumnType.STRING)
+                .add("dim1", ColumnType.STRING)
+                .add("cnt", ColumnType.LONG)
+                .build(),
+            new DatasourceTable.PhysicalDatasourceMetadata(
+                new TableDataSource("tableWithBaseTable"),
+                RowSignature.builder()
+                    .add("tenant_lower", ColumnType.STRING)
+                    .addTimeColumn()
+                    .add("tenant", ColumnType.STRING)
+                    .add("dim1", ColumnType.STRING)
+                    .add("cnt", ColumnType.LONG)
+                    .build(),
+                false,
+                false
+            ),
+            new DatasourceTable.EffectiveMetadata(
+                new DatasourceFacade(new ResolvedTable(
+                    new TableDefn(
+                        "tableWithBaseTable",
+                        DatasourceDefn.TABLE_TYPE,
+                        null,
+                        null
+                    ),
+                    new TableSpec(
+                        DatasourceDefn.TABLE_TYPE,
+                        ImmutableMap.of(
+                            DatasourceDefn.SEALED_PROPERTY, true,
+                            DatasourceDefn.BASE_TABLE_PROPERTY,
+                            new ClusteredValueGroupsBaseTableMetadata(
+                                ImmutableList.of("tenant_lower"),
+                                VirtualColumns.create(
+                                    new ExpressionVirtualColumn(
+                                        "tenant_lower",
+                                        "lower(\"tenant\")",
+                                        ColumnType.STRING,
+                                        ExprMacroTable.nil()
+                                    )
+                                )
+                            )
+                        ),
+                        // declared order is the physical segment order: the 
clustering column leads. 'tenant' is
+                        // declared because virtual column inputs must 
themselves be stored columns.
+                        ImmutableList.of(
+                            new ColumnSpec("tenant_lower", Columns.STRING, 
null),
+                            new ColumnSpec("__time", Columns.TIME_COLUMN, 
null),
+                            new ColumnSpec("tenant", Columns.STRING, null),
+                            new ColumnSpec("dim1", Columns.STRING, null),
+                            new ColumnSpec("cnt", Columns.LONG, null)
+                        )
+                    ),
+                    MAPPER
+                )),
+                
DatasourceTable.EffectiveMetadata.toEffectiveColumns(RowSignature.builder()
+                    .add("tenant_lower", ColumnType.STRING)
+                    .addTimeColumn()
+                    .add("tenant", ColumnType.STRING)
+                    .add("dim1", ColumnType.STRING)
+                    .add("cnt", ColumnType.LONG)
+                    .build()),
+                false
+            )
         )
     );
 
@@ -996,6 +1070,117 @@ public abstract class CalciteCatalogIngestionDmlTest 
extends CalciteIngestionDml
         .verify();
   }
 
+  /**
+   * Inserting declared columns into a table with a base table layout 
succeeds. The computed clustering column
+   * 'tenant_lower' is not (and cannot be) supplied by the query: the virtual 
column populates it at ingest time from
+   * the supplied 'tenant' column.
+   */
+  @Test
+  public void testInsertIntoBaseTableCatalogTable()
+  {
+    ExternalDataSource externalDataSource = new ExternalDataSource(
+        new InlineInputSource("2022-12-26T12:34:56,extra,10,\"20\",foo\n"),
+        new CsvInputFormat(ImmutableList.of("a", "b", "c", "d", "e"), null, 
false, false, 0, null),
+        RowSignature.builder()
+            .add("a", ColumnType.STRING)
+            .add("b", ColumnType.STRING)
+            .add("c", ColumnType.LONG)
+            .add("d", ColumnType.STRING)
+            .add("e", ColumnType.STRING)
+            .build()
+    );
+    final RowSignature signature = RowSignature.builder()
+        .add("__time", ColumnType.LONG)
+        .add("tenant", ColumnType.STRING)
+        .add("dim1", ColumnType.STRING)
+        .add("cnt", ColumnType.LONG)
+        .build();
+    testIngestionQuery()
+        .sql(StringUtils.format(dmlPrefixPattern, "tableWithBaseTable") + "\n" 
+
+             "SELECT\n" +
+             "  TIME_PARSE(a) AS __time,\n" +
+             "  b AS tenant,\n" +
+             "  d AS dim1,\n" +
+             "  c AS cnt\n" +
+             "FROM TABLE(inline(\n" +
+             "  data => ARRAY['2022-12-26T12:34:56,extra,10,\"20\",foo'],\n" +
+             "  format => 'csv'))\n" +
+             "  (a VARCHAR, b VARCHAR, c BIGINT, d VARCHAR, e VARCHAR)\n" +
+             "PARTITIONED BY ALL TIME")
+        .authentication(CalciteTests.SUPER_USER_AUTH_RESULT)
+        .expectTarget("tableWithBaseTable", signature)
+        .expectResources(dataSourceWrite("tableWithBaseTable"), 
Externals.externalRead("EXTERNAL"))
+        .expectQuery(
+            newScanQueryBuilder()
+                .dataSource(externalDataSource)
+                .intervals(querySegmentSpec(Filtration.eternity()))
+                .virtualColumns(
+                    expressionVirtualColumn("v0", 
"timestamp_parse(\"a\",null,'UTC')", ColumnType.LONG)
+                )
+                .columns("v0", "b", "d", "c")
+                .columnTypes(ColumnType.LONG, ColumnType.STRING, 
ColumnType.STRING, ColumnType.LONG)
+                
.context(CalciteIngestionDmlTest.PARTITIONED_BY_ALL_TIME_QUERY_CONTEXT)
+                .build()
+        )
+        .verify();
+  }
+
+  /**
+   * A base table layout derives the physical segment schema from the declared 
columns, so a column that is not
+   * declared cannot be stored: base table layouts require the sealed 
property, which rejects undeclared columns.
+   */
+  @Test
+  public void testInsertAddNonDefinedColumnIntoBaseTableCatalogTable()
+  {
+    testIngestionQuery()
+        .sql(StringUtils.format(dmlPrefixPattern, "tableWithBaseTable") + "\n" 
+
+             "SELECT\n" +
+             "  TIME_PARSE(a) AS __time,\n" +
+             "  b AS tenant,\n" +
+             "  d AS dim1,\n" +
+             "  c AS cnt,\n" +
+             "  e AS extra\n" +
+             "FROM TABLE(inline(\n" +
+             "  data => ARRAY['2022-12-26T12:34:56,extra,10,\"20\",foo'],\n" +
+             "  format => 'csv'))\n" +
+             "  (a VARCHAR, b VARCHAR, c BIGINT, d VARCHAR, e VARCHAR)\n" +
+             "PARTITIONED BY ALL TIME")
+        .authentication(CalciteTests.SUPER_USER_AUTH_RESULT)
+        .expectValidationError(
+            DruidException.class,
+            "Column [extra] is not defined in the target table 
[druid.tableWithBaseTable] strict schema"
+        )
+        .verify();
+  }
+
+  /**
+   * Columns computed by the base table layout's ingest-time virtual columns 
always store the computed value, so a
+   * query output with that name would never be read; writing to them directly 
is rejected rather than silently
+   * discarding the supplied values.
+   */
+  @Test
+  public void testInsertComputedColumnIntoBaseTableCatalogTable()
+  {
+    testIngestionQuery()
+        .sql(StringUtils.format(dmlPrefixPattern, "tableWithBaseTable") + "\n" 
+
+             "SELECT\n" +
+             "  TIME_PARSE(a) AS __time,\n" +
+             "  LOWER(b) AS tenant_lower,\n" +
+             "  d AS dim1,\n" +
+             "  c AS cnt\n" +
+             "FROM TABLE(inline(\n" +
+             "  data => ARRAY['2022-12-26T12:34:56,extra,10,\"20\",foo'],\n" +
+             "  format => 'csv'))\n" +
+             "  (a VARCHAR, b VARCHAR, c BIGINT, d VARCHAR, e VARCHAR)\n" +
+             "PARTITIONED BY ALL TIME")
+        .authentication(CalciteTests.SUPER_USER_AUTH_RESULT)
+        .expectValidationError(
+            DruidException.class,
+            "Column [tenant_lower] of target table [druid.tableWithBaseTable] 
is computed by a virtual column at"
+            + " ingest time and cannot be written directly; supply the virtual 
column's input columns instead"
+        )
+        .verify();
+  }
 
   /**
    * Inserting into a catalog table with a WITH source succeeds


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to