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

clintropolis 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 20d733e735b feat: allow non-sealed schemas for MSQ when using base 
table spec (#19845)
20d733e735b is described below

commit 20d733e735b7b4691624b93e4efba760ac6525ee
Author: Clint Wylie <[email protected]>
AuthorDate: Mon Aug 24 14:49:32 2026 -0700

    feat: allow non-sealed schemas for MSQ when using base table spec (#19845)
---
 .../destination/SegmentGenerationUtils.java        |  66 +++++++++-
 .../org/apache/druid/msq/exec/MSQInsertTest.java   | 130 +++++++++++++++++++
 .../input/impl/AdaptedBaseTableProjectionSpec.java |  12 ++
 .../data/input/impl/BaseTableProjectionSpec.java   |   5 +
 ...lusteredValueGroupsBaseTableProjectionSpec.java |  37 ++++++
 ...eredValueGroupsBaseTableProjectionSpecTest.java | 141 +++++++++++++++++++++
 .../druid/catalog/model/table/DatasourceDefn.java  |  13 --
 .../catalog/model/table/DatasourceTableTest.java   |   8 +-
 .../druid/segment/indexing/DataSchemaTest.java     |  29 +++++
 9 files changed, 422 insertions(+), 19 deletions(-)

diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/indexing/destination/SegmentGenerationUtils.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/indexing/destination/SegmentGenerationUtils.java
index f0b9ca01c25..87f3065afae 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/indexing/destination/SegmentGenerationUtils.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/indexing/destination/SegmentGenerationUtils.java
@@ -28,6 +28,7 @@ import org.apache.druid.data.input.impl.DimensionsSpec;
 import org.apache.druid.data.input.impl.LongDimensionSchema;
 import org.apache.druid.data.input.impl.TimestampSpec;
 import org.apache.druid.error.DruidException;
+import org.apache.druid.error.InvalidInput;
 import org.apache.druid.frame.key.ClusterBy;
 import org.apache.druid.frame.key.KeyColumn;
 import org.apache.druid.indexer.granularity.ArbitraryGranularitySpec;
@@ -66,6 +67,7 @@ import org.apache.druid.utils.CollectionUtils;
 import javax.annotation.Nullable;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -104,8 +106,14 @@ public final class SegmentGenerationUtils
     if (destination.getBaseTable() != null) {
       final Granularity queryGranularity =
           
query.context().getGranularity(DruidSqlInsert.SQL_INSERT_QUERY_GRANULARITY, 
jsonMapper);
-      final BaseTableProjectionSpec baseTable =
+      final BaseTableProjectionSpec declared =
           destination.getBaseTable().withQueryGranularity(queryGranularity);
+      // The query may produce columns the base table does not declare, which 
happens when the target table does not
+      // require a strict schema. Store them rather than drop them; they are 
appended after the declared columns, so
+      // the shape the operator asked for is unchanged.
+      final BaseTableProjectionSpec baseTable = declared.withAdditionalColumns(
+          undeclaredColumns(declared, querySignature, columnMappings, query, 
destination.getDimensionSchemas())
+      );
       return DataSchema.builder()
                        .withDataSource(destination.getDataSource())
                        .withTimestamp(new 
TimestampSpec(ColumnHolder.TIME_COLUMN_NAME, "millis", null))
@@ -212,6 +220,62 @@ public final class SegmentGenerationUtils
            && 
!query.context().getBoolean(GroupByQueryConfig.CTX_KEY_ENABLE_MULTI_VALUE_UNNESTING,
 true);
   }
 
+  /**
+   * The columns a query produces that a base table does not declare, in query 
output order, as the
+   * {@link DimensionSchema}s they should be stored with.
+   * <p>
+   * A base table declares the columns an operator asked for. When the target 
table does not require a strict schema,
+   * a query may produce others; they are stored so that ingesting a column is 
never silently a no-op.
+   */
+  private static List<DimensionSchema> undeclaredColumns(
+      final BaseTableProjectionSpec baseTable,
+      final RowSignature querySignature,
+      final ColumnMappings columnMappings,
+      final Query<?> query,
+      @Nullable final Map<String, DimensionSchema> dimensionSchemas
+  )
+  {
+    final Set<String> declared = new HashSet<>();
+    for (DimensionSchema dimension : 
baseTable.getDimensionsSpec().getDimensions()) {
+      declared.add(dimension.getName());
+    }
+    for (AggregatorFactory metric : baseTable.getMetrics() == null ? new 
AggregatorFactory[0] : baseTable.getMetrics()) {
+      declared.add(metric.getName());
+    }
+    // The time column is positional in a base table, never appended.
+    declared.add(ColumnHolder.TIME_COLUMN_NAME);
+
+    final List<DimensionSchema> undeclared = new ArrayList<>();
+    for (final String outputColumnName : 
columnMappings.getOutputColumnNames()) {
+      if (!declared.add(outputColumnName)) {
+        continue;
+      }
+      final int outputColumn = CollectionUtils.getOnlyElement(
+          columnMappings.getOutputColumnsByName(outputColumnName),
+          xs -> new ISE("Expected single output column for name [%s], but got 
[%s]", outputColumnName, xs)
+      );
+      final String queryColumn = 
columnMappings.getQueryColumnName(outputColumn);
+      final ColumnType type =
+          querySignature.getColumnType(queryColumn)
+                        .orElseThrow(() -> new ISE("No type for column [%s]", 
outputColumnName));
+
+      if (type.is(ValueType.COMPLEX)) {
+        final String typeName = type.getComplexTypeName();
+        if (typeName == null || 
!DimensionHandlerUtils.DIMENSION_HANDLER_PROVIDERS.containsKey(typeName)) {
+          // A base table stores columns as dimensions, so a complex type with 
no dimension handler has nowhere to go
+          throw InvalidInput.exception(
+              "Column [%s] has type [%s], which cannot be stored in a base 
table that does not declare it. Declare the"
+              + " column in the table, or cast it to a type that can be stored 
as a dimension",
+              outputColumnName,
+              type
+          );
+        }
+      }
+      undeclared.add(getDimensionSchema(outputColumnName, type, 
query.context(), dimensionSchemas));
+    }
+    return undeclared;
+  }
+
   private static DimensionSchema getDimensionSchema(
       final String outputColumnName,
       @Nullable final ColumnType queryType,
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 c4708d0904b..28ff630363f 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
@@ -280,6 +280,17 @@ public class MSQInsertTest extends MSQTestBase
                     )
                     .buildSpec()
     );
+    metadataCatalog.addSpec(
+        TableId.datasource("fooClusteredUnsealed"),
+        // Not sealed: the table declares the layout it cares about 
(clustering column, time position, delta) and lets
+        // a query bring whatever else it produces.
+        TableBuilder.datasource("fooClusteredUnsealed", 
Granularities.DAY.toString())
+                    .column("channel", Columns.SQL_VARCHAR)
+                    .timeColumn()
+                    .column("delta", Columns.SQL_BIGINT)
+                    .baseTable(new 
ClusteredValueGroupsBaseTableMetadata(ImmutableList.of("channel"), null, null))
+                    .buildSpec()
+    );
     return new LiveCatalogResolver(metadataCatalog);
   }
 
@@ -875,6 +886,125 @@ public class MSQInsertTest extends MSQTestBase
                      .verifyResults();
   }
 
+  @MethodSource("data")
+  @ParameterizedTest(name = "{index}:with context {0}")
+  public void 
testInsertOnExternalDataSourceWithUnsealedCatalogClusteredBaseTable(
+      String contextName,
+      Map<String, Object> context
+  ) throws IOException
+  {
+    final File toRead = getResourceAsTemporaryFile("/wikipedia-sampled.json");
+    final String toReadFileNameAsJson = 
queryFramework().queryJsonMapper().writeValueAsString(toRead.getAbsolutePath());
+
+    // The table declares only channel, __time and delta. The other four 
columns the query produces are appended after
+    // them in query output order, so the declared layout (clustering prefix, 
time position) is unchanged and nothing
+    // the query selected is dropped.
+    RowSignature rowSignature = RowSignature.builder()
+                                            .add("channel", ColumnType.STRING)
+                                            .add("__time", ColumnType.LONG)
+                                            .add("delta", ColumnType.LONG)
+                                            .add("page", ColumnType.STRING)
+                                            .add("user", ColumnType.STRING)
+                                            .add("added", ColumnType.LONG)
+                                            .add("deleted", ColumnType.LONG)
+                                            .build();
+
+    testIngestQuery().setSql(" insert into fooClusteredUnsealed 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("fooClusteredUnsealed")
+                     .setExpectedRowSignature(rowSignature)
+                     .setQueryContext(context)
+                     .setExpectedSegments(ImmutableSet.of(SegmentId.of(
+                         "fooClusteredUnsealed",
+                         Intervals.of("2016-06-27/P1D"),
+                         "test",
+                         0
+                     )))
+                     // The appended columns are not clustered on; clustering 
is what the table declared.
+                     .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, which now sorts 
by delta ahead of the appended columns.
+                     .setExpectedResultRows(
+                         ImmutableList.of(
+                             new Object[]{"#ceb.wikipedia", 1466985660000L, 
4150L, "Neqerssuaq", "Lsjbot", 4150L, 0L},
+                             new Object[]{"#de.wikipedia", 1466992920000L, 
2560L, "Benutzer Diskussion:Squasher/Archiv/2016", "TaxonBot", 2560L, 0L},
+                             new Object[]{"#de.wikipedia", 1466992980000L, 
364L, "Benutzer Diskussion:HerrSonderbar", "GiftBot", 364L, 0L},
+                             new Object[]{"#en.wikipedia", 1466985600000L, 
-2L, "Richie Rich's Christmas Wish", "JasonAQuest", 0L, 2L},
+                             new Object[]{"#en.wikipedia", 1466985600000L, 2L, 
"Bailando 2015", "181.230.118.178", 2L, 0L},
+                             new Object[]{"#en.wikipedia", 1466985660000L, 
496L, "Panama Canal", "Mariordo", 496L, 0L},
+                             new Object[]{"#en.wikipedia", 1466992980000L, 
-463L, "File:Paint.net 4.0.6 screenshot.png", "Calvin Hogg", 0L, 463L},
+                             new Object[]{"#es.wikipedia", 1466985660000L, 
-173L, "Sumo (banda)", "181.110.165.189", 0L, 173L},
+                             new Object[]{"#es.wikipedia", 1466989320000L, 4L, 
"Clasificación para la Eurocopa Sub-21 de 2017", "Guly600", 4L, 0L},
+                             new Object[]{"#id.wikipedia", 1466989320000L, 
106L, "Ibnu Sina", "Ftihikam", 106L, 0L},
+                             new Object[]{"#pl.wikipedia", 1466985600000L, 
270L, "Kategoria:Dyskusje nad usunięciem artykułu zakończone bez konsensusu − 
lipiec 2016", "Beau.bot", 270L, 0L},
+                             new Object[]{"#pt.wikipedia", 1466992920000L, 
1926L, "Dobromir Zhechev", "Ceresta", 1926L, 0L},
+                             new Object[]{"#ru.wikipedia", 1466985720000L, 
196L, "Википедия:Опросы/Унификация шаблонов «Не переведено»", "Wanderer777", 
196L, 0L},
+                             new Object[]{"#sh.wikipedia", 1466985660000L, 
-1L, "El Terco, Bachíniva", "Kolega2357", 0L, 1L},
+                             new Object[]{"#sh.wikipedia", 1466985720000L, 
-1L, "Hermanos Díaz, Ascensión", "Kolega2357", 0L, 1L},
+                             new Object[]{"#sh.wikipedia", 1466989320000L, 
-1L, "El Sicomoro, Ascensión", "Kolega2357", 0L, 1L},
+                             new Object[]{"#sh.wikipedia", 1466992920000L, 
-1L, "Trinidad Jiménez G., Benemérito de las Américas", "Kolega2357", 0L, 1L},
+                             new Object[]{"#sv.wikipedia", 1466985600000L, 
31L, "Salo Toraut", "Lsjbot", 31L, 0L},
+                             new Object[]{"#zh.wikipedia", 1466989320000L, 
18L, "中共十八大以来的反腐败工作", "2001:DA8:207:E132:94DC:BA03:DFDF:8F9F", 18L, 0L},
+                             new Object[]{"#zh.wikipedia", 1466992920000L, 
1986L, "Wikipedia:頁面存廢討論/記錄/2016/06/27", "Tigerzeng", 1986L, 0L}
+                         )
+                     )
+                     .verifyResults();
+  }
+
+  @MethodSource("data")
+  @ParameterizedTest(name = "{index}:with context {0}")
+  public void testInsertOnUnsealedCatalogClusteredBaseTableUnstorableColumn(
+      String contextName,
+      Map<String, Object> context
+  )
+  {
+    // A base table stores columns as dimensions, so an undeclared sketch 
column has nowhere to go: it could only be
+    // stored as a metric, and a base table declares its own metrics.
+    testIngestQuery().setSql(
+                         "insert into fooClusteredUnsealed "
+                         + "select __time, dim1 as channel, cnt as delta, 
unique_dim1 as unique_users "
+                         + "from foo partitioned by day"
+                     )
+                     .setQueryContext(context)
+                     .setExpectedExecutionErrorMatcher(
+                         ThrowableMatcher.of(ISE.class).expectMessageContains(
+                             "Column [unique_users] has type 
[COMPLEX<hyperUnique>], which cannot be stored in a"
+                             + " base table that does not declare it. Declare 
the column in the table, or cast it"
+                             + " to a type that can be stored as a dimension"
+                         )
+                     )
+                     .verifyExecutionError();
+  }
+
   @MethodSource("data")
   @ParameterizedTest(name = "{index}:with context {0}")
   public void testInsertOnFoo1WithGroupByLimitWithoutClusterBy(String 
contextName, Map<String, Object> context)
diff --git 
a/processing/src/main/java/org/apache/druid/data/input/impl/AdaptedBaseTableProjectionSpec.java
 
b/processing/src/main/java/org/apache/druid/data/input/impl/AdaptedBaseTableProjectionSpec.java
index ac59162a514..2828d2cda53 100644
--- 
a/processing/src/main/java/org/apache/druid/data/input/impl/AdaptedBaseTableProjectionSpec.java
+++ 
b/processing/src/main/java/org/apache/druid/data/input/impl/AdaptedBaseTableProjectionSpec.java
@@ -26,6 +26,7 @@ import org.apache.druid.query.OrderBy;
 import org.apache.druid.query.aggregation.AggregatorFactory;
 import org.apache.druid.segment.VirtualColumns;
 import org.apache.druid.segment.column.ColumnHolder;
+import org.apache.druid.utils.CollectionUtils;
 
 import javax.annotation.Nullable;
 import java.util.ArrayList;
@@ -70,6 +71,17 @@ public final class AdaptedBaseTableProjectionSpec implements 
BaseTableProjection
     return granularitySpec;
   }
 
+  @Override
+  public AdaptedBaseTableProjectionSpec withAdditionalColumns(@Nullable 
List<DimensionSchema> additionalColumns)
+  {
+    if (CollectionUtils.isNullOrEmpty(additionalColumns)) {
+      return this;
+    }
+    final List<DimensionSchema> revised = new 
ArrayList<>(dimensionsSpec.getDimensions());
+    revised.addAll(additionalColumns);
+    return new AdaptedBaseTableProjectionSpec(granularitySpec, 
dimensionsSpec.withDimensions(revised), metrics);
+  }
+
   @Override
   public VirtualColumns getVirtualColumns()
   {
diff --git 
a/processing/src/main/java/org/apache/druid/data/input/impl/BaseTableProjectionSpec.java
 
b/processing/src/main/java/org/apache/druid/data/input/impl/BaseTableProjectionSpec.java
index c236a427210..292a26fe68c 100644
--- 
a/processing/src/main/java/org/apache/druid/data/input/impl/BaseTableProjectionSpec.java
+++ 
b/processing/src/main/java/org/apache/druid/data/input/impl/BaseTableProjectionSpec.java
@@ -85,6 +85,11 @@ public interface BaseTableProjectionSpec
    */
   BaseTableProjectionSpec withQueryGranularity(@Nullable Granularity 
queryGranularity);
 
+  /**
+   * Returns a copy of this spec with the given columns appended to those it 
already declares.
+   */
+  BaseTableProjectionSpec withAdditionalColumns(@Nullable 
List<DimensionSchema> additionalColumns);
+
   /**
    * Returns true if this spec is equivalent to {@code other} for the purpose 
of deciding whether a segment is already
    * compacted. Segment granularity, query granularity, and rollup are each 
compared by their own compaction check
diff --git 
a/processing/src/main/java/org/apache/druid/data/input/impl/ClusteredValueGroupsBaseTableProjectionSpec.java
 
b/processing/src/main/java/org/apache/druid/data/input/impl/ClusteredValueGroupsBaseTableProjectionSpec.java
index 401816421dc..4a2855ba9b3 100644
--- 
a/processing/src/main/java/org/apache/druid/data/input/impl/ClusteredValueGroupsBaseTableProjectionSpec.java
+++ 
b/processing/src/main/java/org/apache/druid/data/input/impl/ClusteredValueGroupsBaseTableProjectionSpec.java
@@ -229,6 +229,43 @@ public final class 
ClusteredValueGroupsBaseTableProjectionSpec implements BaseTa
         .equals(((ClusteredValueGroupsBaseTableProjectionSpec) 
other).withoutQueryGranularity());
   }
 
+  /**
+   * Appends the given columns after the declared ones, which keeps the 
clustering columns the leading prefix of
+   * {@link #getColumns()}.
+   */
+  @Override
+  public ClusteredValueGroupsBaseTableProjectionSpec withAdditionalColumns(
+      @Nullable List<DimensionSchema> additionalColumns
+  )
+  {
+    if (CollectionUtils.isNullOrEmpty(additionalColumns)) {
+      return this;
+    }
+    final List<DimensionSchema> revised = new ArrayList<>(columns.size() + 
additionalColumns.size());
+    revised.addAll(columns);
+    for (DimensionSchema additionalColumn : additionalColumns) {
+      if (ColumnHolder.TIME_COLUMN_NAME.equals(additionalColumn.getName())) {
+        throw InvalidInput.exception(
+            "Cannot append column [%s] to a [%s] base table; it must be 
declared at its position in the column list",
+            ColumnHolder.TIME_COLUMN_NAME,
+            TYPE_NAME
+        );
+      }
+      if (virtualColumns.getVirtualColumn(additionalColumn.getName()) != null) 
{
+        throw InvalidInput.exception(
+            "Cannot append column [%s] to a [%s] base table; it is computed by 
a virtual column, so"
+            + " the arriving values for this column would be ignored",
+            additionalColumn.getName(),
+            TYPE_NAME
+        );
+      }
+      revised.add(additionalColumn);
+    }
+    // Duplicates of a declared column, and of a column materialized by a 
virtual column, are rejected by the
+    // constructor's validation.
+    return new ClusteredValueGroupsBaseTableProjectionSpec(virtualColumns, 
revised, clusteringColumns);
+  }
+
   /**
    * Returns a copy of this spec with the {@link 
Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME} virtual column removed,
    * the inverse of {@link #withQueryGranularity(Granularity)}. If no such 
virtual column is present this returns
diff --git 
a/processing/src/test/java/org/apache/druid/data/input/impl/ClusteredValueGroupsBaseTableProjectionSpecTest.java
 
b/processing/src/test/java/org/apache/druid/data/input/impl/ClusteredValueGroupsBaseTableProjectionSpecTest.java
index 603919e7112..a09fdfc42de 100644
--- 
a/processing/src/test/java/org/apache/druid/data/input/impl/ClusteredValueGroupsBaseTableProjectionSpecTest.java
+++ 
b/processing/src/test/java/org/apache/druid/data/input/impl/ClusteredValueGroupsBaseTableProjectionSpecTest.java
@@ -19,8 +19,10 @@
 
 package org.apache.druid.data.input.impl;
 
+import com.google.common.collect.ImmutableList;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.query.OrderBy;
 import org.apache.druid.query.dimension.DimensionSpec;
 import org.apache.druid.query.expression.TestExprMacroTable;
 import org.apache.druid.segment.ColumnSelectorFactory;
@@ -37,6 +39,7 @@ import org.junit.jupiter.api.Test;
 
 import java.util.Collections;
 import java.util.List;
+import java.util.stream.Collectors;
 
 class ClusteredValueGroupsBaseTableProjectionSpecTest extends 
InitializedNullHandlingTest
 {
@@ -294,6 +297,144 @@ class ClusteredValueGroupsBaseTableProjectionSpecTest 
extends InitializedNullHan
     Assertions.assertTrue(e.getMessage().contains("[dotty]"));
   }
 
+  @Test
+  void testWithAdditionalColumnsAppendsAfterDeclaredColumns()
+  {
+    final ClusteredValueGroupsBaseTableProjectionSpec spec = 
tenantSpec().withAdditionalColumns(
+        ImmutableList.of(new LongDimensionSchema("cnt"), new 
StringDimensionSchema("city"))
+    );
+
+    Assertions.assertEquals(
+        ImmutableList.of("tenant", "region", "__time", "cnt", "city"),
+        
spec.getColumns().stream().map(DimensionSchema::getName).collect(Collectors.toList())
+    );
+    // The clustering prefix is untouched: the appended columns are stored and 
sorted by, but not clustered on.
+    Assertions.assertEquals(ImmutableList.of("tenant"), 
spec.getClusteringColumnNames());
+    Assertions.assertEquals(
+        ImmutableList.of("tenant"),
+        
spec.getClusteringColumns().stream().map(DimensionSchema::getName).collect(Collectors.toList())
+    );
+    Assertions.assertEquals(
+        ImmutableList.of("region", "__time", "cnt", "city"),
+        
spec.getNonClusteringColumns().stream().map(DimensionSchema::getName).collect(Collectors.toList())
+    );
+    // Rows are physically sorted by every column present, so the appended 
columns join the ordering at the end.
+    Assertions.assertEquals(
+        ImmutableList.of(
+            OrderBy.ascending("tenant"),
+            OrderBy.ascending("region"),
+            OrderBy.ascending("__time"),
+            OrderBy.ascending("cnt"),
+            OrderBy.ascending("city")
+        ),
+        spec.getOrdering()
+    );
+    Assertions.assertEquals(spec.getColumns(), 
spec.getDimensionsSpec().getDimensions());
+  }
+
+  @Test
+  void testWithAdditionalColumnsNullAndEmptyAreNoOps()
+  {
+    final ClusteredValueGroupsBaseTableProjectionSpec spec = tenantSpec();
+    Assertions.assertSame(spec, spec.withAdditionalColumns(null));
+    Assertions.assertSame(spec, 
spec.withAdditionalColumns(Collections.emptyList()));
+  }
+
+  @Test
+  void testWithAdditionalColumnsKeepsVirtualColumnsAndQueryGranularity()
+  {
+    final ClusteredValueGroupsBaseTableProjectionSpec spec = 
ClusteredValueGroupsBaseTableProjectionSpec.builder()
+        .virtualColumns(VirtualColumns.create(
+            new ExpressionVirtualColumn("region_upper", "upper(region)", 
ColumnType.STRING, TestExprMacroTable.INSTANCE)
+        ))
+        .columns(
+            new StringDimensionSchema("tenant"),
+            new StringDimensionSchema("region"),
+            new StringDimensionSchema("region_upper"),
+            new LongDimensionSchema("__time")
+        )
+        .clusteringColumns("tenant")
+        .build()
+        .withQueryGranularity(Granularities.HOUR)
+        .withAdditionalColumns(ImmutableList.of(new 
LongDimensionSchema("cnt")));
+
+    
Assertions.assertNotNull(spec.getVirtualColumns().getVirtualColumn("region_upper"));
+    Assertions.assertEquals(Granularities.HOUR, spec.getQueryGranularity());
+    Assertions.assertEquals("cnt", 
spec.getColumns().get(spec.getColumns().size() - 1).getName());
+  }
+
+  @Test
+  void testWithAdditionalColumnsRejectsTimeColumn()
+  {
+    // __time marks a position in the column list, so it can never arrive as 
an appended extra.
+    final DruidException e = Assertions.assertThrows(
+        DruidException.class,
+        () -> tenantSpec().withAdditionalColumns(ImmutableList.of(new 
LongDimensionSchema("__time")))
+    );
+    Assertions.assertTrue(e.getMessage().contains("[__time]"));
+  }
+
+  @Test
+  void testWithAdditionalColumnsRejectsDuplicateOfDeclaredColumn()
+  {
+    final DruidException e = Assertions.assertThrows(
+        DruidException.class,
+        () -> tenantSpec().withAdditionalColumns(ImmutableList.of(new 
StringDimensionSchema("region")))
+    );
+    Assertions.assertTrue(e.getMessage().contains("duplicate name [region]"));
+  }
+
+  @Test
+  void testWithAdditionalColumnsRejectsDuplicateOfVirtualColumnOutput()
+  {
+    // region_upper is materialized by a virtual column, so an incoming column 
of the same name would be computed by
+    // the virtual column rather than read; the arriving values would be 
ignored.
+    final DruidException e = Assertions.assertThrows(
+        DruidException.class,
+        () -> ClusteredValueGroupsBaseTableProjectionSpec.builder()
+            .virtualColumns(VirtualColumns.create(
+                new ExpressionVirtualColumn("region_upper", "upper(region)", 
ColumnType.STRING, TestExprMacroTable.INSTANCE)
+            ))
+            .columns(
+                new StringDimensionSchema("tenant"),
+                new StringDimensionSchema("region"),
+                new StringDimensionSchema("region_upper"),
+                new LongDimensionSchema("__time")
+            )
+            .clusteringColumns("tenant")
+            .build()
+            .withAdditionalColumns(ImmutableList.of(new 
StringDimensionSchema("region_upper")))
+    );
+    Assertions.assertTrue(e.getMessage().contains("[region_upper]"));
+    Assertions.assertTrue(e.getMessage().contains("computed by a virtual 
column"));
+  }
+
+  @Test
+  void testWithAdditionalColumnsRejectsUnmaterializedVirtualColumnName()
+  {
+    // Chain: tenant_key := upper(v0), v0 := lower(tenant); the intermediate 
v0 is not a stored column. Appending a
+    // column named v0 would pass the constructor's rules (its output would 
simply become stored), but ingest reads
+    // virtual columns first, so the arriving v0 values would be silently 
discarded.
+    final ClusteredValueGroupsBaseTableProjectionSpec spec = 
ClusteredValueGroupsBaseTableProjectionSpec.builder()
+        .virtualColumns(VirtualColumns.create(
+            new ExpressionVirtualColumn("v0", "lower(tenant)", 
ColumnType.STRING, TestExprMacroTable.INSTANCE),
+            new ExpressionVirtualColumn("tenant_key", "upper(v0)", 
ColumnType.STRING, TestExprMacroTable.INSTANCE)
+        ))
+        .columns(
+            new StringDimensionSchema("tenant_key"),
+            new StringDimensionSchema("tenant"),
+            new LongDimensionSchema("__time")
+        )
+        .clusteringColumns("tenant_key")
+        .build();
+    final DruidException e = Assertions.assertThrows(
+        DruidException.class,
+        () -> spec.withAdditionalColumns(ImmutableList.of(new 
StringDimensionSchema("v0")))
+    );
+    Assertions.assertTrue(e.getMessage().contains("[v0]"));
+    Assertions.assertTrue(e.getMessage().contains("computed by a virtual 
column"));
+  }
+
   /**
    * Minimal test-only virtual column whose only meaningful behavior is {@link 
#usesDotNotation()} returning true; the
    * selector/capability methods are never reached by spec validation. (No 
core virtual column uses dot notation.)
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 89982a94883..5b2e163eb94 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
@@ -30,7 +30,6 @@ 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;
 
@@ -107,18 +106,6 @@ public class DatasourceDefn extends TableDefn
     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());
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 dc40a131578..657f74b21ee 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
@@ -163,8 +163,8 @@ public class DatasourceTableTest extends 
InitializedNullHandlingTest
     }
 
     {
-      // A base table layout requires 'sealed': the declared columns define 
the physical segment schema, so
-      // undeclared columns cannot be ingested.
+      // A base table layout does not require 'sealed': columns the table does 
not declare are appended after the
+      // declared ones at ingest time rather than dropped.
       TableSpec spec = new TableSpec(
           DatasourceDefn.TABLE_TYPE,
           ImmutableMap.of(
@@ -173,9 +173,7 @@ public class DatasourceTableTest extends 
InitializedNullHandlingTest
           ),
           columns
       );
-      ResolvedTable table = registry.resolve(spec);
-      DruidException e = Assertions.assertThrows(DruidException.class, 
table::validate);
-      Assertions.assertTrue(e.getMessage().contains("must also set [sealed] to 
true"));
+      expectValidationSucceeds(spec);
     }
 
     {
diff --git 
a/server/src/test/java/org/apache/druid/segment/indexing/DataSchemaTest.java 
b/server/src/test/java/org/apache/druid/segment/indexing/DataSchemaTest.java
index d030618ea59..0bf965fe5b0 100644
--- a/server/src/test/java/org/apache/druid/segment/indexing/DataSchemaTest.java
+++ b/server/src/test/java/org/apache/druid/segment/indexing/DataSchemaTest.java
@@ -63,6 +63,7 @@ import org.mockito.Mockito;
 
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -841,6 +842,34 @@ class DataSchemaTest extends InitializedNullHandlingTest
     Assertions.assertArrayEquals(schema.getAggregators(), 
effective.getMetrics());
   }
 
+  @Test
+  void testLegacyModeEffectiveBaseTableSpecAppendsAdditionalColumns()
+  {
+    final BaseTableProjectionSpec effective = DataSchema.builder()
+                                                       
.withDataSource("datasource")
+                                                       
.withTimestamp(TIMESTAMP_SPEC)
+                                                       .withDimensions(new 
StringDimensionSchema("tenant"))
+                                                       .withAggregators(new 
CountAggregatorFactory("rows"))
+                                                       
.withGranularity(ARBITRARY_GRANULARITY)
+                                                       .build()
+                                                       
.getEffectiveBaseTableSpec();
+
+    Assertions.assertSame(effective, effective.withAdditionalColumns(null));
+    Assertions.assertSame(effective, 
effective.withAdditionalColumns(Collections.emptyList()));
+
+    final BaseTableProjectionSpec appended =
+        effective.withAdditionalColumns(ImmutableList.of(new 
StringDimensionSchema("region")));
+    Assertions.assertEquals(
+        ImmutableList.of(new StringDimensionSchema("tenant"), new 
StringDimensionSchema("region")),
+        appended.getDimensionsSpec().getDimensions()
+    );
+    Assertions.assertArrayEquals(effective.getMetrics(), 
appended.getMetrics());
+    Assertions.assertEquals(
+        ARBITRARY_GRANULARITY,
+        ((AdaptedBaseTableProjectionSpec) appended).getGranularitySpec()
+    );
+  }
+
   @Test
   void testLegacyModeJsonRoundTripOmitsBaseTable() throws IOException
   {


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

Reply via email to