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 443688fea12 feat: add plain table base table spec for catalog wiring
(#20377)
443688fea12 is described below
commit 443688fea12b1fee730f38f3aef87462573b475e
Author: Clint Wylie <[email protected]>
AuthorDate: Tue Sep 29 15:30:36 2026 -0700
feat: add plain table base table spec for catalog wiring (#20377)
---
.../embedded/catalog/CatalogDdlAndIngestTest.java | 96 +++++
.../testing/embedded/indexing/IndexTaskTest.java | 56 +++
.../data/input/impl/AggregateProjectionSpec.java | 8 +-
.../data/input/impl/BaseTableProjectionSpec.java | 50 ++-
...lusteredValueGroupsBaseTableProjectionSpec.java | 9 +-
.../druid/data/input/impl/TableProjectionSpec.java | 360 +++++++++++++++++
.../util/common/granularity/Granularities.java | 31 +-
.../org/apache/druid/segment/VirtualColumn.java | 20 +
.../segment/virtual/ExpressionVirtualColumn.java | 12 +
.../segment/virtual/NestedFieldVirtualColumn.java | 20 +
.../segment/virtual/NestedMergeVirtualColumn.java | 6 +
.../segment/virtual/NestedObjectVirtualColumn.java | 6 +
.../SpecializedExpressionVirtualColumn.java | 13 +
.../input/impl/AggregateProjectionSpecTest.java | 21 +
...eredValueGroupsBaseTableProjectionSpecTest.java | 105 +++++
.../data/input/impl/TableProjectionSpecTest.java | 428 +++++++++++++++++++++
.../virtual/ExpressionVirtualColumnTest.java | 12 +
.../virtual/NestedFieldVirtualColumnTest.java | 14 +
.../virtual/NestedMergeVirtualColumnTest.java | 17 +
.../virtual/NestedObjectVirtualColumnTest.java | 16 +
.../druid/catalog/model/BaseTableColumns.java | 166 ++++++++
.../ClusteredValueGroupsBaseTableMetadata.java | 137 +------
.../catalog/model/DatasourceBaseTableMetadata.java | 1 +
.../catalog/model/TableBaseTableMetadata.java | 164 ++++++++
.../apache/druid/segment/indexing/DataSchema.java | 5 +-
.../catalog/model/TableBaseTableMetadataTest.java | 190 +++++++++
.../druid/segment/indexing/DataSchemaTest.java | 42 ++
.../sql/calcite/planner/CatalogDdlHandler.java | 4 +-
.../calcite/planner/ProjectionSpecTranslator.java | 180 +++++++--
.../druid/sql/calcite/CalciteCatalogDdlTest.java | 180 +++++++++
.../sql/calcite/parser/DruidSqlDdlParserTest.java | 5 +
31 files changed, 2197 insertions(+), 177 deletions(-)
diff --git
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogDdlAndIngestTest.java
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogDdlAndIngestTest.java
index 9d021e8e7d5..b280a7a15a0 100644
---
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogDdlAndIngestTest.java
+++
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/catalog/CatalogDdlAndIngestTest.java
@@ -407,6 +407,102 @@ public class CatalogDdlAndIngestTest extends
CatalogTestBase
);
}
+ /**
+ * A computed column composed of specialized expressions is lifted with its
dependency closure (the inner JSON_MERGE
+ * becomes an intermediary virtual column the outer nested-field virtual
column reads by synthetic name), and the
+ * clustered write path evaluates the chain at ingest to materialize the
declared column.
+ */
+ @Test
+ public void testCreateClusteredBaseTableWithComposedComputedColumn()
+ {
+ final String tableName = dataSource;
+
+ cluster.callApi().runSql(
+ "CREATE TABLE \"%s\" (\n"
+ + " tenant VARCHAR,\n"
+ + " __time TIMESTAMP,\n"
+ + " payload TYPE('COMPLEX<json>'),\n"
+ + " v BIGINT,\n"
+ + " PROJECTION __base AS (\n"
+ + " SELECT tenant, __time, payload, JSON_VALUE(JSON_MERGE(payload,
payload), '$.v' RETURNING BIGINT) AS v\n"
+ + " CLUSTERED BY tenant\n"
+ + " )\n"
+ + ")\n"
+ + "PARTITIONED BY DAY",
+ tableName
+ );
+
+ // 'v' is computed by the base table, so the INSERT supplies only its
input column.
+ ingest(
+ "INSERT INTO \"%s\"\n"
+ + "SELECT TIME_PARSE(t) AS __time, a AS tenant, p AS payload\n"
+ + "FROM TABLE(\n"
+ + " EXTERN(\n"
+ + " '{\"type\":\"inline\",\"data\":\""
+ +
"{\\\"t\\\":\\\"2022-12-26T12:34:56\\\",\\\"a\\\":\\\"bbb\\\",\\\"p\\\":{\\\"v\\\":7}}"
+ +
"\\n{\\\"t\\\":\\\"2022-12-26T12:34:56\\\",\\\"a\\\":\\\"aaa\\\",\\\"p\\\":{\\\"v\\\":12}}\"}',\n"
+ + " '{\"type\":\"json\"}'\n"
+ + " )\n"
+ + ") EXTEND (t VARCHAR, a VARCHAR, p TYPE('COMPLEX<json>'))\n",
+ tableName
+ );
+
+ // Rows come back in clustering order, with 'v' materialized at ingest by
the composed virtual column chain.
+ cluster.callApi().verifySqlQuery(
+ "SELECT tenant, v FROM %s",
+ tableName,
+ "aaa,12\n"
+ + "bbb,7"
+ );
+ }
+
+ /**
+ * A {@code __base} projection without {@code CLUSTERED BY} declares the
plain-table layout: declared column order is
+ * the segment storage and sort order, and {@code TIME_FLOOR(__time,
<period>)} declares the table's query
+ * granularity. Rows arrive unsorted with unfloored timestamps and come back
sorted by the declared order (item
+ * before {@code __time}) with {@code __time} floored to the hour.
+ */
+ @Test
+ public void testCreatePlainBaseTableThenIngestAndQuery()
+ {
+ final String tableName = dataSource;
+
+ cluster.callApi().runSql(
+ "CREATE TABLE \"%s\" (\n"
+ + " item VARCHAR,\n"
+ + " __time TIMESTAMP,\n"
+ + " val BIGINT,\n"
+ + " PROJECTION __base AS (SELECT item, TIME_FLOOR(__time, 'PT1H') AS
__time, val)\n"
+ + ")\n"
+ + "PARTITIONED BY DAY",
+ tableName
+ );
+
+ final TableMetadata table =
client.readTable(TableId.datasource(tableName));
+
assertNotNull(table.spec().properties().get(DatasourceDefn.BASE_TABLE_PROPERTY));
+
+ ingest(
+ "INSERT INTO \"%s\"\n"
+ + "SELECT TIME_PARSE(a) AS __time, b AS item, c AS val\n"
+ + "FROM TABLE(\n"
+ + " EXTERN(\n"
+ + " '{\"type\":\"inline\",\"data\":\"2022-12-26T05:30:10,cherry,3"
+ +
"\\n2022-12-26T01:45:20,banana,2\\n2022-12-26T09:10:30,apple,1\"}',\n"
+ + "
'{\"type\":\"csv\",\"findColumnsFromHeader\":false,\"columns\":[\"a\",\"b\",\"c\"]}'\n"
+ + " )\n"
+ + ") EXTEND (a VARCHAR, b VARCHAR, c BIGINT)\n",
+ tableName
+ );
+
+ cluster.callApi().verifySqlQuery(
+ "SELECT * FROM %s",
+ tableName,
+ "apple,2022-12-26T09:00:00.000Z,1\n"
+ + "banana,2022-12-26T01:00:00.000Z,2\n"
+ + "cherry,2022-12-26T05:00:00.000Z,3"
+ );
+ }
+
/**
* The same layout without SEALED: a column the query produces but the table
does not declare is stored after the
* declared layout rather than rejected or dropped, and the clustering the
table declared is unchanged.
diff --git
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/IndexTaskTest.java
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/IndexTaskTest.java
index bde5c512a36..2b63fde274a 100644
---
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/IndexTaskTest.java
+++
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/IndexTaskTest.java
@@ -19,9 +19,15 @@
package org.apache.druid.testing.embedded.indexing;
+import org.apache.druid.data.input.impl.LongDimensionSchema;
+import org.apache.druid.data.input.impl.StringDimensionSchema;
+import org.apache.druid.data.input.impl.TableProjectionSpec;
+import org.apache.druid.indexer.granularity.SegmentGranularitySpec;
import org.apache.druid.indexing.common.task.IndexTask;
+import org.apache.druid.indexing.common.task.TaskBuilder;
import org.apache.druid.java.util.common.DateTimes;
import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.java.util.common.granularity.Granularities;
import org.apache.druid.java.util.common.guava.Comparators;
import org.apache.druid.testing.embedded.EmbeddedBroker;
import org.apache.druid.testing.embedded.EmbeddedClusterApis;
@@ -106,6 +112,56 @@ public class IndexTaskTest extends EmbeddedClusterTestBase
Assertions.assertEquals("10", cluster.runSql("SELECT COUNT(*) FROM %s",
dataSource));
}
+ /**
+ * Batch ingestion driven by a plain {@link TableProjectionSpec} base table
instead of the legacy top-level schema
+ * fields. The spec lowers into the standard ingest inputs, so declared
column order becomes segment sort order
+ * (item before __time here — not the legacy time-first sort) and the spec's
query-granularity carrier floors
+ * {@code __time}, both observable in the ingested segment.
+ */
+ @Test
+ @Timeout(60)
+ public void test_runIndexTask_withBaseTableSpec()
+ {
+ final String taskId = EmbeddedClusterApis.newTaskId(dataSource);
+ final TableProjectionSpec baseTable = TableProjectionSpec
+ .builder()
+ .columns(
+ new StringDimensionSchema("item"),
+ new LongDimensionSchema("__time"),
+ new LongDimensionSchema("value")
+ )
+ .build()
+ .withQueryGranularity(Granularities.HOUR);
+ final IndexTask task = TaskBuilder
+ .ofTypeIndex()
+ .isoTimestampColumn("time")
+ .csvInputFormatWithColumns("time", "item", "value")
+ .inlineInputSourceWithData(
+ "2025-06-01T05:30:00.000Z,cherry,3"
+ + "\n2025-06-01T01:45:00.000Z,banana,2"
+ + "\n2025-06-01T09:10:00.000Z,apple,1"
+ )
+ .dataSchema(
+ schema -> schema
+ .withBaseTable(baseTable)
+ .withSegmentGranularity(new
SegmentGranularitySpec(Granularities.DAY, null))
+ )
+ .dataSource(dataSource)
+ .withId(taskId);
+
+ cluster.callApi().runTask(task, overlord);
+ cluster.callApi().waitForAllSegmentsToBeAvailable(dataSource, coordinator,
broker);
+
+ // A scan without ORDER BY returns rows in segment order: sorted by item
first (declared order), with __time
+ // floored to the hour by the carrier, even though the rows arrived
unsorted with unfloored timestamps.
+ Assertions.assertEquals(
+ "apple,1,2025-06-01T09:00:00.000Z"
+ + "\nbanana,2,2025-06-01T01:00:00.000Z"
+ + "\ncherry,3,2025-06-01T05:00:00.000Z",
+ cluster.runSql("SELECT item, \"value\", __time FROM %s", dataSource)
+ );
+ }
+
@Test
@Timeout(20)
public void test_run10Tasks_concurrently()
diff --git
a/processing/src/main/java/org/apache/druid/data/input/impl/AggregateProjectionSpec.java
b/processing/src/main/java/org/apache/druid/data/input/impl/AggregateProjectionSpec.java
index a19a5e1a6e6..b60106dca4e 100644
---
a/processing/src/main/java/org/apache/druid/data/input/impl/AggregateProjectionSpec.java
+++
b/processing/src/main/java/org/apache/druid/data/input/impl/AggregateProjectionSpec.java
@@ -30,7 +30,6 @@ import org.apache.druid.error.DruidException;
import org.apache.druid.error.InvalidInput;
import org.apache.druid.java.util.common.granularity.Granularities;
import org.apache.druid.java.util.common.granularity.Granularity;
-import org.apache.druid.java.util.common.granularity.PeriodGranularity;
import org.apache.druid.query.OrderBy;
import org.apache.druid.query.aggregation.AggregatorFactory;
import org.apache.druid.query.filter.DimFilter;
@@ -41,7 +40,6 @@ import org.apache.druid.segment.column.ValueType;
import org.apache.druid.segment.projections.AggregateProjectionSchema;
import org.apache.druid.segment.projections.Projections;
import org.apache.druid.utils.CollectionUtils;
-import org.joda.time.DateTimeZone;
import javax.annotation.Nullable;
import java.util.Arrays;
@@ -248,16 +246,14 @@ public class AggregateProjectionSpec
continue;
}
final VirtualColumn vc =
virtualColumns.getVirtualColumn(groupingColumn.getName());
- final Granularity maybeGranularity =
Granularities.fromVirtualColumn(vc);
+ final Granularity maybeGranularity =
Granularities.fromTimeVirtualColumn(vc);
if (maybeGranularity == null ||
maybeGranularity.equals(Granularities.ALL)) {
// no __time in inputs or not supported, skip
} else if (Granularities.NONE.equals(maybeGranularity)) {
timeColumnName = groupingColumn.getName();
// already found exact __time grouping, skip assigning, granularity
= Granularities.NONE;
break;
- } else if (maybeGranularity.getClass().equals(PeriodGranularity.class)
- && maybeGranularity.getTimeZone().equals(DateTimeZone.UTC)
- && ((PeriodGranularity) maybeGranularity).getOrigin() == null
+ } else if (Granularities.isStandardUtcPeriod(maybeGranularity)
&& (granularity == null ||
maybeGranularity.isFinerThan(granularity))) {
// found a finer period granularity than the existing granularity,
or it's the first one
timeColumnName = groupingColumn.getName();
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 292a26fe68c..e9d42175e4c 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
@@ -21,15 +21,18 @@ package org.apache.druid.data.input.impl;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import org.apache.druid.error.InvalidInput;
import org.apache.druid.java.util.common.granularity.Granularities;
import org.apache.druid.java.util.common.granularity.Granularity;
import org.apache.druid.query.OrderBy;
import org.apache.druid.query.aggregation.AggregatorFactory;
import org.apache.druid.segment.VirtualColumn;
import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.segment.column.ColumnHolder;
import org.apache.druid.segment.projections.BaseTableProjectionSchema;
import javax.annotation.Nullable;
+import java.util.Collections;
import java.util.List;
/**
@@ -47,6 +50,7 @@ import java.util.List;
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
+ @JsonSubTypes.Type(name = TableProjectionSpec.TYPE_NAME, value =
TableProjectionSpec.class),
@JsonSubTypes.Type(
name = ClusteredValueGroupsBaseTableProjectionSpec.TYPE_NAME,
value = ClusteredValueGroupsBaseTableProjectionSpec.class
@@ -72,10 +76,7 @@ public interface BaseTableProjectionSpec
{
final VirtualColumn granularityVirtualColumn =
getVirtualColumns().getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME);
- if (granularityVirtualColumn == null) {
- return Granularities.NONE;
- }
- final Granularity granularity =
Granularities.fromVirtualColumn(granularityVirtualColumn);
+ final Granularity granularity =
Granularities.fromTimeVirtualColumn(granularityVirtualColumn);
return granularity == null ? Granularities.NONE : granularity;
}
@@ -104,4 +105,45 @@ public interface BaseTableProjectionSpec
{
return equals(other);
}
+
+ /**
+ * Validates that {@code queryGranularity} can be used by a base-table spec:
only period granularities in the UTC
+ * time zone without an origin are currently allowed.
+ */
+ static void validateQueryGranularity(Granularity queryGranularity, String
typeName)
+ {
+ if (!Granularities.isStandardUtcPeriod(queryGranularity)) {
+ throw InvalidInput.exception(
+ "Query granularity[%s] is not supported for [%s] base tables; only
period granularities in the UTC time"
+ + " zone without an origin are supported",
+ queryGranularity,
+ typeName
+ );
+ }
+ }
+
+ /**
+ * Validates a {@link Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME}
supplied directly to a spec (catalog JSON, or
+ * a translated DDL body): it must decode to a granularity {@link
#validateQueryGranularity} accepts, so a
+ * spec cannot be constructed claiming a granularity that reads back as
something else.
+ */
+ static void validateGranularity(VirtualColumn granularityColumn, String
typeName)
+ {
+ if
(!Collections.singletonList(ColumnHolder.TIME_COLUMN_NAME).equals(granularityColumn.requiredColumns()))
{
+ throw InvalidInput.exception(
+ "virtual column [%s] must be computed from [%s] alone, but reads %s",
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME,
+ ColumnHolder.TIME_COLUMN_NAME,
+ granularityColumn.requiredColumns()
+ );
+ }
+ final Granularity granularity =
Granularities.fromTimeVirtualColumn(granularityColumn);
+ if (granularity == null || Granularities.NONE.equals(granularity) ||
Granularities.ALL.equals(granularity)) {
+ throw InvalidInput.exception(
+ "virtual column [%s] does not encode a query granularity",
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME
+ );
+ }
+ validateQueryGranularity(granularity, typeName);
+ }
}
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 4a2855ba9b3..9ed41ee9a1c 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
@@ -184,7 +184,8 @@ public final class
ClusteredValueGroupsBaseTableProjectionSpec implements BaseTa
* {@code null} or {@code NONE} granularity is a no-op (no flooring), so
this returns {@code this} unchanged.
* <p>
* {@code ALL} is rejected: it would floor {@code __time} to a single
constant (the interval start) for the whole
- * segment, which clustered base tables do not yet support.
+ * segment, which clustered base tables do not yet support. Everything other
than a period granularity in the UTC
+ * time zone is rejected too, see {@link
BaseTableProjectionSpec#validateQueryGranularity}.
* <p>
* Idempotent: if the spec already declares a query-granularity virtual
column, that one is authoritative and this is
* a no-op. (The compaction path attaches the virtual column up front; the
MSQ generation path then calls this again
@@ -203,6 +204,7 @@ public final class
ClusteredValueGroupsBaseTableProjectionSpec implements BaseTa
||
virtualColumns.getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
!= null) {
return this;
}
+ BaseTableProjectionSpec.validateQueryGranularity(queryGranularity,
TYPE_NAME);
final VirtualColumn granularityVirtualColumn =
Granularities.toVirtualColumn(queryGranularity,
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME);
final List<VirtualColumn> merged = new
ArrayList<>(Arrays.asList(virtualColumns.getVirtualColumns()));
@@ -378,6 +380,11 @@ public final class
ClusteredValueGroupsBaseTableProjectionSpec implements BaseTa
if (all.length == 0) {
return;
}
+ final VirtualColumn granularity =
+
virtualColumns.getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME);
+ if (granularity != null) {
+ BaseTableProjectionSpec.validateGranularity(granularity, TYPE_NAME);
+ }
// Declared column types, doubling as the ColumnInspector used to infer
each materialized virtual column's output
// type (an expression's output type can depend on its input column types).
final RowSignature.Builder signatureBuilder = RowSignature.builder();
diff --git
a/processing/src/main/java/org/apache/druid/data/input/impl/TableProjectionSpec.java
b/processing/src/main/java/org/apache/druid/data/input/impl/TableProjectionSpec.java
new file mode 100644
index 00000000000..18545e45736
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/data/input/impl/TableProjectionSpec.java
@@ -0,0 +1,360 @@
+/*
+ * 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.data.input.impl;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.google.common.collect.Sets;
+import org.apache.druid.error.InvalidInput;
+import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.java.util.common.granularity.Granularity;
+import org.apache.druid.query.OrderBy;
+import org.apache.druid.query.aggregation.AggregatorFactory;
+import org.apache.druid.segment.VirtualColumn;
+import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.segment.column.ColumnHolder;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * {@link BaseTableProjectionSpec} for a plain (non-clustered, non-rollup)
table: the operator declares a single
+ * ordered {@link #columns} list, the full set of columns in segment order,
and rows are stored and sorted by every
+ * column in declared order. The time position is an explicit positional entry
in {@link #columns} named
+ * {@code __time}. A plain base table is never rollup and has no metric
columns.
+ * <p>
+ * Operator-facing counterpart of the segment metadata-side {@link
org.apache.druid.segment.projections.TableProjectionSchema}.
+ * <p>
+ * {@link #getDimensionsSpec()} returns the unified spec built from {@link
#columns} in declared order with
+ * {@code forceSegmentSortByTime=false}; {@link #getOrdering()} is computed as
every column of {@link #columns}
+ * ascending, in list order.
+ * <p>
+ * Query granularity, when wanted, is a virtual column in {@link
#getVirtualColumns()} named
+ * {@link Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME}. It is a granularity
<em>carrier</em>: it supplies the
+ * granularity that floors the stored {@code __time} column, and is NOT itself
a stored column, so it never appears in
+ * {@link #columns} (declare {@code __time} there as the time column). Absent
that virtual column the query granularity
+ * is {@code NONE}.
+ * <p>
+ * The granularity carrier is the only virtual column a plain base table
accepts. The standard segment-generation path
+ * this spec lowers into does not evaluate spec virtual columns (only the
clustered write path materializes them), so a
+ * materialized virtual column here would be silently dropped; computed
columns belong in a
+ * {@code transformSpec} instead. This can be relaxed if the plain write path
learns to materialize virtual columns.
+ */
+@JsonTypeName(TableProjectionSpec.TYPE_NAME)
+public final class TableProjectionSpec implements BaseTableProjectionSpec
+{
+ public static final String TYPE_NAME = "table";
+
+ private final VirtualColumns virtualColumns;
+ private final List<DimensionSchema> columns;
+ private final DimensionsSpec dimensionsSpec;
+ private final List<OrderBy> ordering;
+
+ public static Builder builder()
+ {
+ return new Builder();
+ }
+
+ @JsonCreator
+ public TableProjectionSpec(
+ @JsonProperty("virtualColumns") @Nullable VirtualColumns virtualColumns,
+ @JsonProperty("columns") List<DimensionSchema> columns
+ )
+ {
+ validate(columns);
+ this.virtualColumns = virtualColumns == null ? VirtualColumns.EMPTY :
virtualColumns;
+ validateVirtualColumns(this.virtualColumns);
+ this.columns = Collections.unmodifiableList(new ArrayList<>(columns));
+ this.dimensionsSpec = DimensionsSpec.builder()
+ .setDimensions(this.columns)
+ .setForceSegmentSortByTime(false)
+ .build();
+ this.ordering = computeOrdering(this.columns);
+ }
+
+ @Override
+ @JsonProperty
+ @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+ public VirtualColumns getVirtualColumns()
+ {
+ return virtualColumns;
+ }
+
+ /**
+ * The full, ordered list of columns in segment order, with the explicit
{@code __time} (or query-granularity)
+ * marker at its declared position.
+ */
+ @JsonProperty("columns")
+ public List<DimensionSchema> getColumns()
+ {
+ return columns;
+ }
+
+ @Override
+ public AggregatorFactory[] getMetrics()
+ {
+ return new AggregatorFactory[0];
+ }
+
+ @Override
+ @JsonIgnore
+ public List<OrderBy> getOrdering()
+ {
+ return ordering;
+ }
+
+ @Override
+ @JsonIgnore
+ public DimensionsSpec getDimensionsSpec()
+ {
+ return dimensionsSpec;
+ }
+
+ /**
+ * Returns a copy of this spec with a new {@code queryGranularity},
expressed as a
+ * {@link Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME} virtual column
added to {@link #getVirtualColumns()}. A
+ * {@code null} or {@code NONE} granularity is a no-op (no flooring), so
this returns {@code this} unchanged.
+ * <p>
+ * {@code ALL} is rejected: it has no granularity virtual column
representation, so accepting it would silently
+ * degrade to {@code NONE} when the spec is read back.
+ * <p>
+ * Idempotent: if the spec already declares a query-granularity virtual
column, that one is authoritative and this is
+ * a no-op. (The compaction path attaches the virtual column up front; the
MSQ generation path then calls this again
+ * with the query-derived granularity, which must not double-add.)
+ */
+ @Override
+ public TableProjectionSpec withQueryGranularity(@Nullable Granularity
queryGranularity)
+ {
+ if (Granularities.ALL.equals(queryGranularity)) {
+ throw InvalidInput.exception(
+ "Query granularity[ALL] is not supported for [%s] base tables",
+ TYPE_NAME
+ );
+ }
+ if (queryGranularity == null
+ || Granularities.NONE.equals(queryGranularity)
+ ||
virtualColumns.getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
!= null) {
+ return this;
+ }
+ BaseTableProjectionSpec.validateQueryGranularity(queryGranularity,
TYPE_NAME);
+ final VirtualColumn granularityVirtualColumn =
+ Granularities.toVirtualColumn(queryGranularity,
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME);
+ final List<VirtualColumn> merged = new
ArrayList<>(Arrays.asList(virtualColumns.getVirtualColumns()));
+ merged.add(granularityVirtualColumn);
+ return new TableProjectionSpec(VirtualColumns.create(merged), columns);
+ }
+
+ /**
+ * Compares table-spec state for compaction up-to-date checks: query
granularity is compared separately (via its
+ * carrier virtual column), so it is stripped from both sides; everything
else (the columns, and any other virtual
+ * columns) must match. A spec of a different type is never equivalent.
+ */
+ @Override
+ public boolean hasEqualCompactionState(BaseTableProjectionSpec other)
+ {
+ if (!(other instanceof TableProjectionSpec)) {
+ return false;
+ }
+ return withoutQueryGranularity().equals(((TableProjectionSpec)
other).withoutQueryGranularity());
+ }
+
+ @Override
+ public TableProjectionSpec withAdditionalColumns(@Nullable
List<DimensionSchema> additionalColumns)
+ {
+ if (additionalColumns == null || additionalColumns.isEmpty()) {
+ 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
+ );
+ }
+ revised.add(additionalColumn);
+ }
+ // Duplicates of a declared column, and a column named for the
query-granularity carrier, are rejected by the
+ // constructor's validation.
+ return new TableProjectionSpec(virtualColumns, revised);
+ }
+
+ /**
+ * 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
+ * {@code this} unchanged. Used to compare schema independently of query
granularity in {@link #hasEqualCompactionState}.
+ */
+ private TableProjectionSpec withoutQueryGranularity()
+ {
+ if
(virtualColumns.getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
== null) {
+ return this;
+ }
+ final List<VirtualColumn> remaining = new ArrayList<>();
+ for (VirtualColumn vc : virtualColumns.getVirtualColumns()) {
+ if
(!Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME.equals(vc.getOutputName())) {
+ remaining.add(vc);
+ }
+ }
+ return new TableProjectionSpec(VirtualColumns.create(remaining), columns);
+ }
+
+ private static void validate(List<DimensionSchema> columns)
+ {
+ if (columns == null || columns.isEmpty()) {
+ throw InvalidInput.exception("columns must be non-empty for [%s] base
table", TYPE_NAME);
+ }
+ final Set<String> seen = Sets.newHashSetWithExpectedSize(columns.size());
+ for (DimensionSchema d : columns) {
+ if (!seen.add(d.getName())) {
+ throw InvalidInput.exception("columns contains duplicate name [%s]",
d.getName());
+ }
+ }
+ boolean foundTime = false;
+ for (DimensionSchema column : columns) {
+ final String name = column.getName();
+ // The query-granularity virtual column is a granularity carrier in
virtualColumns (it floors the stored __time
+ // column); it is not itself a stored column, so it must not be declared
in 'columns'.
+ if (Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME.equals(name)) {
+ throw InvalidInput.exception(
+ "[%s] is the query-granularity virtual column, not a stored
column; declare it in 'virtualColumns' and use"
+ + " [%s] as the time column in 'columns'",
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME,
+ ColumnHolder.TIME_COLUMN_NAME
+ );
+ }
+ if (ColumnHolder.TIME_COLUMN_NAME.equals(name)) {
+ foundTime = true;
+ }
+ }
+ if (!foundTime) {
+ throw InvalidInput.exception(
+ "[%s] base table must include [%s] in 'columns' to define the time
position",
+ TYPE_NAME,
+ ColumnHolder.TIME_COLUMN_NAME
+ );
+ }
+ }
+
+ /**
+ * A plain base table accepts only the {@link
Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME} carrier: the standard
+ * segment-generation path does not evaluate spec virtual columns, so any
other virtual column would be dead metadata
+ * whose column never gets materialized. Computed columns belong in a {@code
transformSpec}.
+ */
+ private static void validateVirtualColumns(VirtualColumns virtualColumns)
+ {
+ for (VirtualColumn virtualColumn : virtualColumns.getVirtualColumns()) {
+ if
(!Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME.equals(virtualColumn.getOutputName()))
{
+ throw InvalidInput.exception(
+ "virtual column [%s] is not supported: a [%s] base table stores
only declared columns and does not"
+ + " materialize virtual columns; use a transformSpec to compute
columns at ingest, or the [%s] virtual"
+ + " column to carry query granularity",
+ virtualColumn.getOutputName(),
+ TYPE_NAME,
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME
+ );
+ }
+ BaseTableProjectionSpec.validateGranularity(virtualColumn, TYPE_NAME);
+ }
+ }
+
+ private static List<OrderBy> computeOrdering(List<DimensionSchema> columns)
+ {
+ final List<OrderBy> ordering = new ArrayList<>(columns.size());
+ for (DimensionSchema d : columns) {
+ ordering.add(OrderBy.ascending(d.getName()));
+ }
+ return Collections.unmodifiableList(ordering);
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ TableProjectionSpec that = (TableProjectionSpec) o;
+ return Objects.equals(virtualColumns, that.virtualColumns)
+ && Objects.equals(columns, that.columns);
+ }
+
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(virtualColumns, columns);
+ }
+
+ @Override
+ public String toString()
+ {
+ return "TableProjectionSpec{" +
+ "virtualColumns=" + virtualColumns +
+ ", columns=" + columns +
+ '}';
+ }
+
+ /**
+ * Fluent builder for {@link TableProjectionSpec}, avoiding the
constructor's positional nullable leading
+ * {@code virtualColumns} argument. {@link #columns} (the full ordered
column list) is required.
+ */
+ public static final class Builder
+ {
+ @Nullable
+ private VirtualColumns virtualColumns;
+ private List<DimensionSchema> columns = Collections.emptyList();
+
+ public Builder virtualColumns(@Nullable VirtualColumns virtualColumns)
+ {
+ this.virtualColumns = virtualColumns;
+ return this;
+ }
+
+ /**
+ * The full, ordered list of columns in segment order, including the
explicit time marker.
+ */
+ public Builder columns(List<DimensionSchema> columns)
+ {
+ this.columns = columns;
+ return this;
+ }
+
+ public Builder columns(DimensionSchema... columns)
+ {
+ return columns(Arrays.asList(columns));
+ }
+
+ public TableProjectionSpec build()
+ {
+ return new TableProjectionSpec(virtualColumns, columns);
+ }
+ }
+}
diff --git
a/processing/src/main/java/org/apache/druid/java/util/common/granularity/Granularities.java
b/processing/src/main/java/org/apache/druid/java/util/common/granularity/Granularities.java
index 0bba55f9693..803eb4f59b0 100644
---
a/processing/src/main/java/org/apache/druid/java/util/common/granularity/Granularities.java
+++
b/processing/src/main/java/org/apache/druid/java/util/common/granularity/Granularities.java
@@ -37,6 +37,7 @@ import org.apache.druid.segment.column.ColumnHolder;
import org.apache.druid.segment.column.ColumnType;
import org.apache.druid.segment.projections.AggregateProjectionSchema;
import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
+import org.joda.time.DateTimeZone;
import javax.annotation.Nullable;
import java.util.Arrays;
@@ -183,7 +184,9 @@ public class Granularities
* IMPORTANT - this method DOES NOT VERIFY that the virtual column has a
single input that is a time column
* ({@link ColumnHolder#TIME_COLUMN_NAME} or equivalent projection time
column as defined by
* {@link AggregateProjectionSchema#getTimeColumnName()}). Callers must
verify this externally before
- * calling this method by examining {@link VirtualColumn#requiredColumns()}.
+ * calling this method by examining {@link VirtualColumn#requiredColumns()},
or call
+ * {@link #fromTimeVirtualColumn(VirtualColumn)}, which performs the {@link
ColumnHolder#TIME_COLUMN_NAME} check
+ * itself.
* <p>
* This method also does not handle other time expressions, or if the
virtual column is just an identifier for a
* time column
@@ -215,4 +218,30 @@ public class Granularities
}
return null;
}
+
+ /**
+ * Like {@link #fromVirtualColumn(VirtualColumn)}, but first verifies that
the virtual column reads
+ * {@link ColumnHolder#TIME_COLUMN_NAME} and nothing else, returning null
otherwise.
+ */
+ @Nullable
+ public static Granularity fromTimeVirtualColumn(@Nullable VirtualColumn
virtualColumn)
+ {
+ if (virtualColumn == null
+ ||
!Collections.singletonList(ColumnHolder.TIME_COLUMN_NAME).equals(virtualColumn.requiredColumns()))
{
+ return null;
+ }
+ return fromVirtualColumn(virtualColumn);
+ }
+
+ /**
+ * Returns true when {@code granularity} is exactly a {@link
PeriodGranularity} in the UTC time zone with no origin.
+ */
+ public static boolean isStandardUtcPeriod(@Nullable Granularity granularity)
+ {
+ if (granularity == null ||
!granularity.getClass().equals(PeriodGranularity.class)) {
+ return false;
+ }
+ final PeriodGranularity period = (PeriodGranularity) granularity;
+ return DateTimeZone.UTC.equals(period.getTimeZone()) && period.getOrigin()
== null;
+ }
}
diff --git
a/processing/src/main/java/org/apache/druid/segment/VirtualColumn.java
b/processing/src/main/java/org/apache/druid/segment/VirtualColumn.java
index 3c2fdcf5f69..ee26bacde57 100644
--- a/processing/src/main/java/org/apache/druid/segment/VirtualColumn.java
+++ b/processing/src/main/java/org/apache/druid/segment/VirtualColumn.java
@@ -548,6 +548,26 @@ public interface VirtualColumn extends Cacheable
return null;
}
+ /**
+ * Returns true if the virtual column supports {@link
#withOutputName(String)}
+ */
+ default boolean supportsOutputNameRewrite()
+ {
+ return false;
+ }
+
+ /**
+ * Returns a copy of this virtual column identical except for the given
output name. Used where a virtual column
+ * generated under a synthetic name (such as the planner's {@code v0},
{@code v1}, ...) must be re-issued under a
+ * declared column name — for example, lifting the computed columns of a
catalog base-table definition out of a
+ * planned query. Callers should check {@link #supportsOutputNameRewrite()}
first to ensure that this method is
+ * supported.
+ */
+ default VirtualColumn withOutputName(String outputName)
+ {
+ throw DruidException.defensive("Output name rewrite is not supported by
virtual column[%s].", getOutputName());
+ }
+
@SubclassesMustOverrideEqualsAndHashCode
interface EquivalenceKey
{
diff --git
a/processing/src/main/java/org/apache/druid/segment/virtual/ExpressionVirtualColumn.java
b/processing/src/main/java/org/apache/druid/segment/virtual/ExpressionVirtualColumn.java
index 1ea737b9d35..efee2eaa6b3 100644
---
a/processing/src/main/java/org/apache/druid/segment/virtual/ExpressionVirtualColumn.java
+++
b/processing/src/main/java/org/apache/druid/segment/virtual/ExpressionVirtualColumn.java
@@ -154,6 +154,18 @@ public class ExpressionVirtualColumn implements
VirtualColumn
return expression.parsed;
}
+ @Override
+ public boolean supportsOutputNameRewrite()
+ {
+ return true;
+ }
+
+ @Override
+ public ExpressionVirtualColumn withOutputName(final String outputName)
+ {
+ return new ExpressionVirtualColumn(outputName, getExpression(),
getOutputType(), expression.parsed);
+ }
+
@Override
public DimensionSelector makeDimensionSelector(
final DimensionSpec dimensionSpec,
diff --git
a/processing/src/main/java/org/apache/druid/segment/virtual/NestedFieldVirtualColumn.java
b/processing/src/main/java/org/apache/druid/segment/virtual/NestedFieldVirtualColumn.java
index 1cbcf36871f..f067a222bae 100644
---
a/processing/src/main/java/org/apache/druid/segment/virtual/NestedFieldVirtualColumn.java
+++
b/processing/src/main/java/org/apache/druid/segment/virtual/NestedFieldVirtualColumn.java
@@ -233,6 +233,26 @@ public class NestedFieldVirtualColumn implements
VirtualColumn
return fieldSpec.processFromRaw;
}
+ @Override
+ public boolean supportsOutputNameRewrite()
+ {
+ return true;
+ }
+
+ @Override
+ public NestedFieldVirtualColumn withOutputName(String newOutputName)
+ {
+ return new NestedFieldVirtualColumn(
+ getColumnName(),
+ newOutputName,
+ getExpectedType(),
+ getPathParts(),
+ isProcessFromRaw(),
+ null,
+ null
+ );
+ }
+
@Override
public DimensionSelector makeDimensionSelector(
DimensionSpec dimensionSpec,
diff --git
a/processing/src/main/java/org/apache/druid/segment/virtual/NestedMergeVirtualColumn.java
b/processing/src/main/java/org/apache/druid/segment/virtual/NestedMergeVirtualColumn.java
index 7c46a0bd2ef..7946a0d37ed 100644
---
a/processing/src/main/java/org/apache/druid/segment/virtual/NestedMergeVirtualColumn.java
+++
b/processing/src/main/java/org/apache/druid/segment/virtual/NestedMergeVirtualColumn.java
@@ -141,6 +141,12 @@ public class NestedMergeVirtualColumn extends
SpecializedExpressionVirtualColumn
);
}
+ @Override
+ public NestedMergeVirtualColumn withOutputName(String outputName)
+ {
+ return new NestedMergeVirtualColumn(outputName, columns, macroTable);
+ }
+
@Override
public String toString()
{
diff --git
a/processing/src/main/java/org/apache/druid/segment/virtual/NestedObjectVirtualColumn.java
b/processing/src/main/java/org/apache/druid/segment/virtual/NestedObjectVirtualColumn.java
index 8096b26490f..b9e3cb07c0b 100644
---
a/processing/src/main/java/org/apache/druid/segment/virtual/NestedObjectVirtualColumn.java
+++
b/processing/src/main/java/org/apache/druid/segment/virtual/NestedObjectVirtualColumn.java
@@ -158,6 +158,12 @@ public class NestedObjectVirtualColumn extends
SpecializedExpressionVirtualColum
);
}
+ @Override
+ public NestedObjectVirtualColumn withOutputName(String outputName)
+ {
+ return new NestedObjectVirtualColumn(outputName, keyExprMap, macroTable);
+ }
+
@Override
public String toString()
{
diff --git
a/processing/src/main/java/org/apache/druid/segment/virtual/SpecializedExpressionVirtualColumn.java
b/processing/src/main/java/org/apache/druid/segment/virtual/SpecializedExpressionVirtualColumn.java
index b08ee20888d..f0ad5e85814 100644
---
a/processing/src/main/java/org/apache/druid/segment/virtual/SpecializedExpressionVirtualColumn.java
+++
b/processing/src/main/java/org/apache/druid/segment/virtual/SpecializedExpressionVirtualColumn.java
@@ -196,6 +196,19 @@ public abstract class SpecializedExpressionVirtualColumn
implements VirtualColum
return delegate.getEquivalanceKey();
}
+ @Override
+ public boolean supportsOutputNameRewrite()
+ {
+ return true;
+ }
+
+ /**
+ * Declared abstract rather than inherited so that every specialization
implements the rename that
+ * {@link #supportsOutputNameRewrite()} advertises on its behalf.
+ */
+ @Override
+ public abstract SpecializedExpressionVirtualColumn withOutputName(String
outputName);
+
@Override
public SelectableColumn toSelectableColumn(ColumnIndexSelector
columnSelector)
{
diff --git
a/processing/src/test/java/org/apache/druid/data/input/impl/AggregateProjectionSpecTest.java
b/processing/src/test/java/org/apache/druid/data/input/impl/AggregateProjectionSpecTest.java
index 638b7029ce9..a5c67e9c83f 100644
---
a/processing/src/test/java/org/apache/druid/data/input/impl/AggregateProjectionSpecTest.java
+++
b/processing/src/test/java/org/apache/druid/data/input/impl/AggregateProjectionSpecTest.java
@@ -184,6 +184,27 @@ class AggregateProjectionSpecTest extends
InitializedNullHandlingTest
).toMetadataSchema().getTimeColumnName());
}
+ @Test
+ void testComputeOrdering_floorOfNonTimeColumnIsNotATimeColumn()
+ {
+ // A grouping column flooring some column other than {@code __time} is a
perfectly good grouping column, but it is
+ // not a time grouping: its granularity describes buckets of the other
column, so designating it the time column
+ // would claim a time bucketing the projection does not have.
+ ExpressionVirtualColumn dailyOther = new ExpressionVirtualColumn(
+ "dailyOther",
+ "timestamp_floor(other, 'P1D', null, null)",
+ ColumnType.LONG,
+ TestExprMacroTable.INSTANCE
+ );
+ Assertions.assertNull(new AggregateProjectionSpec(
+ "some_projection",
+ null,
+ VirtualColumns.create(dailyOther),
+ List.of(new LongDimensionSchema("dailyOther")),
+ new AggregatorFactory[]{}
+ ).toMetadataSchema().getTimeColumnName());
+ }
+
@Test
void testMissingName()
{
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 a09fdfc42de..bb0f7226fd0 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
@@ -21,7 +21,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.DateTimes;
+import org.apache.druid.java.util.common.granularity.DurationGranularity;
import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.java.util.common.granularity.PeriodGranularity;
import org.apache.druid.query.OrderBy;
import org.apache.druid.query.dimension.DimensionSpec;
import org.apache.druid.query.expression.TestExprMacroTable;
@@ -34,6 +37,7 @@ import org.apache.druid.segment.column.ColumnCapabilities;
import org.apache.druid.segment.column.ColumnType;
import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
import org.apache.druid.testing.InitializedNullHandlingTest;
+import org.joda.time.Period;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -89,6 +93,107 @@ class ClusteredValueGroupsBaseTableProjectionSpecTest
extends InitializedNullHan
Assertions.assertTrue(e.getMessage().contains("ALL"));
}
+ @Test
+ void testWithQueryGranularityDurationIsRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> tenantSpec().withQueryGranularity(new
DurationGranularity(3_600_000, null))
+ );
+ Assertions.assertTrue(e.getMessage().contains("only period granularities
in the UTC time zone"), e.getMessage());
+ }
+
+ @Test
+ void testWithQueryGranularityNonUtcPeriodIsRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> tenantSpec().withQueryGranularity(
+ new PeriodGranularity(new Period("P1D"), null,
DateTimes.inferTzFromString("America/Los_Angeles"))
+ )
+ );
+ Assertions.assertTrue(e.getMessage().contains("only period granularities
in the UTC time zone"), e.getMessage());
+ }
+
+ @Test
+ void testWithQueryGranularityOriginPeriodIsRejected()
+ {
+ // An origin shifts the bucket boundaries, which the projection machinery
does not treat as a standard time
+ // grouping (AggregateProjectionSpec requires a null origin to recognize a
time column), so it is rejected here
+ // for the same reason as a non-UTC time zone.
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> tenantSpec().withQueryGranularity(
+ new PeriodGranularity(new Period("P1D"),
DateTimes.of("2020-01-02T03:00:00Z"), null)
+ )
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("only period granularities in the UTC time
zone without an origin"),
+ e.getMessage()
+ );
+ }
+
+ @Test
+ void testNonUtcGranularityCarrierRejectedAtConstruction()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> ClusteredValueGroupsBaseTableProjectionSpec.builder()
+ .virtualColumns(VirtualColumns.create(
+ Granularities.toVirtualColumn(
+ new PeriodGranularity(new Period("P1D"), null,
DateTimes.inferTzFromString("America/Los_Angeles")),
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME
+ )
+ ))
+ .columns(new StringDimensionSchema("tenant"), new
LongDimensionSchema("__time"))
+ .clusteringColumns("tenant")
+ .build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("only period granularities
in the UTC time zone"), e.getMessage());
+ }
+
+ @Test
+ void testUndecodableGranularityCarrierRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> ClusteredValueGroupsBaseTableProjectionSpec.builder()
+ .virtualColumns(VirtualColumns.create(
+ new ExpressionVirtualColumn(
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME,
+ "__time",
+ ColumnType.LONG,
+ TestExprMacroTable.INSTANCE
+ )
+ ))
+ .columns(new StringDimensionSchema("tenant"), new
LongDimensionSchema("__time"))
+ .clusteringColumns("tenant")
+ .build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("does not encode a query
granularity"), e.getMessage());
+ }
+
+ @Test
+ void testGranularityCarrierReadingOtherColumnRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> ClusteredValueGroupsBaseTableProjectionSpec.builder()
+ .virtualColumns(VirtualColumns.create(
+ new ExpressionVirtualColumn(
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME,
+ "timestamp_floor(\"tenant\", 'P1D')",
+ ColumnType.LONG,
+ TestExprMacroTable.INSTANCE
+ )
+ ))
+ .columns(new StringDimensionSchema("tenant"), new
LongDimensionSchema("__time"))
+ .clusteringColumns("tenant")
+ .build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("must be computed from
[__time] alone"), e.getMessage());
+ }
+
@Test
void testGetQueryGranularityRoundTrips()
{
diff --git
a/processing/src/test/java/org/apache/druid/data/input/impl/TableProjectionSpecTest.java
b/processing/src/test/java/org/apache/druid/data/input/impl/TableProjectionSpecTest.java
new file mode 100644
index 00000000000..459e663dca4
--- /dev/null
+++
b/processing/src/test/java/org/apache/druid/data/input/impl/TableProjectionSpecTest.java
@@ -0,0 +1,428 @@
+/*
+ * 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.data.input.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableList;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.java.util.common.granularity.DurationGranularity;
+import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.java.util.common.granularity.PeriodGranularity;
+import org.apache.druid.query.OrderBy;
+import org.apache.druid.query.expression.TestExprMacroTable;
+import org.apache.druid.segment.TestHelper;
+import org.apache.druid.segment.VirtualColumn;
+import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
+import org.apache.druid.testing.InitializedNullHandlingTest;
+import org.joda.time.Period;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.stream.Collectors;
+
+class TableProjectionSpecTest extends InitializedNullHandlingTest
+{
+ private static TableProjectionSpec pagesSpec()
+ {
+ return TableProjectionSpec.builder()
+ .columns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"), new LongDimensionSchema("cnt"))
+ .build();
+ }
+
+ @Test
+ void testOrderingAndDimensionsSpecFollowDeclaredOrder()
+ {
+ final TableProjectionSpec spec = pagesSpec();
+
+ // Declared order is both storage and sort order: every column ascending,
in list order, with __time at its
+ // declared position rather than forced first.
+ Assertions.assertEquals(
+ ImmutableList.of(OrderBy.ascending("page"),
OrderBy.ascending("__time"), OrderBy.ascending("cnt")),
+ spec.getOrdering()
+ );
+ Assertions.assertEquals(spec.getColumns(),
spec.getDimensionsSpec().getDimensions());
+
Assertions.assertFalse(spec.getDimensionsSpec().isForceSegmentSortByTime());
+ Assertions.assertEquals(0, spec.getMetrics().length);
+ }
+
+ @Test
+ void testWithQueryGranularityAddsVirtualGranularityColumn()
+ {
+ final TableProjectionSpec spec =
pagesSpec().withQueryGranularity(Granularities.HOUR);
+
+ final VirtualColumn vc =
spec.getVirtualColumns().getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME);
+ Assertions.assertNotNull(vc);
+ Assertions.assertEquals(Granularities.HOUR,
Granularities.fromVirtualColumn(vc));
+
+ // The rest of the spec is unchanged.
+ Assertions.assertEquals(pagesSpec().getColumns(), spec.getColumns());
+ Assertions.assertEquals(pagesSpec().getOrdering(), spec.getOrdering());
+ Assertions.assertArrayEquals(pagesSpec().getMetrics(), spec.getMetrics());
+ }
+
+ @Test
+ void testWithQueryGranularityNullAndNoneAreNoOps()
+ {
+ // Absent __virtualGranularity virtual column already means NONE, so
null/NONE add nothing and return the same spec.
+ final TableProjectionSpec spec = pagesSpec();
+ Assertions.assertSame(spec, spec.withQueryGranularity(null));
+ Assertions.assertSame(spec, spec.withQueryGranularity(Granularities.NONE));
+ }
+
+ @Test
+ void testWithQueryGranularityAllIsRejected()
+ {
+ // ALL has no granularity virtual column representation, so accepting it
would silently degrade to NONE when the
+ // spec is read back; it is rejected rather than silently ignored.
+ final TableProjectionSpec spec = pagesSpec();
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> spec.withQueryGranularity(Granularities.ALL)
+ );
+ Assertions.assertTrue(e.getMessage().contains("ALL"));
+ }
+
+ @Test
+ void testWithQueryGranularityDurationIsRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> pagesSpec().withQueryGranularity(new
DurationGranularity(3_600_000, null))
+ );
+ Assertions.assertTrue(e.getMessage().contains("only period granularities
in the UTC time zone"), e.getMessage());
+ }
+
+ @Test
+ void testWithQueryGranularityNonUtcPeriodIsRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> pagesSpec().withQueryGranularity(
+ new PeriodGranularity(new Period("P1D"), null,
DateTimes.inferTzFromString("America/Los_Angeles"))
+ )
+ );
+ Assertions.assertTrue(e.getMessage().contains("only period granularities
in the UTC time zone"), e.getMessage());
+ }
+
+ @Test
+ void testWithQueryGranularityOriginPeriodIsRejected()
+ {
+ // An origin shifts the bucket boundaries, which the projection machinery
does not treat as a standard time
+ // grouping (AggregateProjectionSpec requires a null origin to recognize a
time column), so it is rejected here
+ // for the same reason as a non-UTC time zone.
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> pagesSpec().withQueryGranularity(
+ new PeriodGranularity(new Period("P1D"),
DateTimes.of("2020-01-02T03:00:00Z"), null)
+ )
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("only period granularities in the UTC time
zone without an origin"),
+ e.getMessage()
+ );
+ }
+
+ @Test
+ void testNonUtcGranularityCarrierRejectedAtConstruction()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> TableProjectionSpec.builder()
+ .virtualColumns(VirtualColumns.create(
+ Granularities.toVirtualColumn(
+ new PeriodGranularity(new Period("P1D"), null,
DateTimes.inferTzFromString("America/Los_Angeles")),
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME
+ )
+ ))
+ .columns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("only period granularities
in the UTC time zone"), e.getMessage());
+ }
+
+ @Test
+ void testGranularityCarrierReadingOtherColumnRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> TableProjectionSpec.builder()
+ .virtualColumns(VirtualColumns.create(
+ new ExpressionVirtualColumn(
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME,
+ "timestamp_floor(\"page\", 'P1D')",
+ ColumnType.LONG,
+ TestExprMacroTable.INSTANCE
+ )
+ ))
+ .columns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("must be computed from
[__time] alone"), e.getMessage());
+ }
+
+ @Test
+ void testUndecodableGranularityCarrierRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> TableProjectionSpec.builder()
+ .virtualColumns(VirtualColumns.create(
+ new ExpressionVirtualColumn(
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME,
+ "__time",
+ ColumnType.LONG,
+ TestExprMacroTable.INSTANCE
+ )
+ ))
+ .columns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("does not encode a query
granularity"), e.getMessage());
+ }
+
+ @Test
+ void testGetQueryGranularityRoundTrips()
+ {
+ Assertions.assertEquals(
+ Granularities.HOUR,
+
pagesSpec().withQueryGranularity(Granularities.HOUR).getQueryGranularity()
+ );
+ }
+
+ @Test
+ void testGetQueryGranularityIsNoneWhenNoVirtualColumn()
+ {
+ Assertions.assertEquals(Granularities.NONE,
pagesSpec().getQueryGranularity());
+ }
+
+ @Test
+ void testWithQueryGranularityIsIdempotentWhenAlreadyPresent()
+ {
+ // Once a query-granularity virtual column is present it is authoritative;
a second call is a no-op and does not
+ // double-add or change it (the compaction path attaches it up front, then
MSQ generation calls this again).
+ final TableProjectionSpec withGranularity =
pagesSpec().withQueryGranularity(Granularities.HOUR);
+ final TableProjectionSpec reapplied =
withGranularity.withQueryGranularity(Granularities.DAY);
+
+ Assertions.assertSame(withGranularity, reapplied);
+ Assertions.assertEquals(
+ Granularities.HOUR,
+ Granularities.fromVirtualColumn(
+
reapplied.getVirtualColumns().getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
+ )
+ );
+ }
+
+ @Test
+ void testHasEqualCompactionStateIgnoresQueryGranularity()
+ {
+ // Two specs that differ only in their query granularity are equivalent
for compaction (query granularity is
+ // compared by its own check), regardless of which granularity each
carries.
+
Assertions.assertTrue(pagesSpec().withQueryGranularity(Granularities.HOUR).hasEqualCompactionState(pagesSpec()));
+
Assertions.assertTrue(pagesSpec().hasEqualCompactionState(pagesSpec().withQueryGranularity(Granularities.HOUR)));
+ Assertions.assertTrue(
+ pagesSpec().withQueryGranularity(Granularities.HOUR)
+
.hasEqualCompactionState(pagesSpec().withQueryGranularity(Granularities.DAY))
+ );
+ }
+
+ @Test
+ void testHasEqualCompactionStateComparesColumns()
+ {
+ final TableProjectionSpec differentColumns = TableProjectionSpec.builder()
+ .columns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .build();
+
+
Assertions.assertFalse(pagesSpec().hasEqualCompactionState(differentColumns));
+ Assertions.assertTrue(pagesSpec().hasEqualCompactionState(pagesSpec()));
+ }
+
+ @Test
+ void testHasEqualCompactionStateRejectsDifferentSpecType()
+ {
+ // A clustered spec over the same columns is a different physical layout,
never equivalent to a plain table.
+ final ClusteredValueGroupsBaseTableProjectionSpec clustered =
ClusteredValueGroupsBaseTableProjectionSpec.builder()
+ .columns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"), new LongDimensionSchema("cnt"))
+ .clusteringColumns("page")
+ .build();
+
+ Assertions.assertFalse(pagesSpec().hasEqualCompactionState(clustered));
+ Assertions.assertFalse(clustered.hasEqualCompactionState(pagesSpec()));
+ }
+
+ @Test
+ void testWithAdditionalColumnsAppendsAfterDeclaredColumns()
+ {
+ final TableProjectionSpec spec = pagesSpec().withAdditionalColumns(
+ ImmutableList.of(new LongDimensionSchema("clicks"), new
StringDimensionSchema("city"))
+ );
+
+ Assertions.assertEquals(
+ ImmutableList.of("page", "__time", "cnt", "clicks", "city"),
+
spec.getColumns().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("page"),
+ OrderBy.ascending("__time"),
+ OrderBy.ascending("cnt"),
+ OrderBy.ascending("clicks"),
+ OrderBy.ascending("city")
+ ),
+ spec.getOrdering()
+ );
+ Assertions.assertEquals(spec.getColumns(),
spec.getDimensionsSpec().getDimensions());
+ }
+
+ @Test
+ void testWithAdditionalColumnsNullAndEmptyAreNoOps()
+ {
+ final TableProjectionSpec spec = pagesSpec();
+ Assertions.assertSame(spec, spec.withAdditionalColumns(null));
+ Assertions.assertSame(spec,
spec.withAdditionalColumns(Collections.emptyList()));
+ }
+
+ @Test
+ void testWithAdditionalColumnsKeepsQueryGranularity()
+ {
+ final TableProjectionSpec spec = pagesSpec()
+ .withQueryGranularity(Granularities.HOUR)
+ .withAdditionalColumns(ImmutableList.of(new
LongDimensionSchema("clicks")));
+
+ Assertions.assertEquals(Granularities.HOUR, spec.getQueryGranularity());
+ Assertions.assertEquals("clicks",
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,
+ () -> pagesSpec().withAdditionalColumns(ImmutableList.of(new
LongDimensionSchema("__time")))
+ );
+ Assertions.assertTrue(e.getMessage().contains("[__time]"));
+ }
+
+ @Test
+ void testWithAdditionalColumnsRejectsDuplicateOfDeclaredColumn()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> pagesSpec().withAdditionalColumns(ImmutableList.of(new
StringDimensionSchema("page")))
+ );
+ Assertions.assertTrue(e.getMessage().contains("duplicate name [page]"));
+ }
+
+ @Test
+ void testEmptyColumnsRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> TableProjectionSpec.builder().build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("non-empty"));
+ }
+
+ @Test
+ void testDuplicateColumnRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> TableProjectionSpec.builder()
+ .columns(
+ new StringDimensionSchema("page"),
+ new StringDimensionSchema("page"),
+ new LongDimensionSchema("__time")
+ )
+ .build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("duplicate name [page]"));
+ }
+
+ @Test
+ void testMissingTimeColumnRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> TableProjectionSpec.builder().columns(new
StringDimensionSchema("page")).build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("[__time]"));
+ }
+
+ @Test
+ void testGranularityCarrierInColumnsRejected()
+ {
+ // The query-granularity virtual column is a carrier in virtualColumns,
never a stored column.
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> TableProjectionSpec.builder()
+ .columns(
+ new StringDimensionSchema("page"),
+ new
LongDimensionSchema(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
+ )
+ .build()
+ );
+
Assertions.assertTrue(e.getMessage().contains(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME));
+ }
+
+ @Test
+ void testNonCarrierVirtualColumnRejected()
+ {
+ // The standard segment-generation path does not evaluate spec virtual
columns, so any virtual column other than
+ // the query-granularity carrier would never be materialized; computed
columns belong in a transformSpec.
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> TableProjectionSpec.builder()
+ .virtualColumns(VirtualColumns.create(
+ new ExpressionVirtualColumn("page_upper", "upper(page)",
ColumnType.STRING, TestExprMacroTable.INSTANCE)
+ ))
+ .columns(
+ new StringDimensionSchema("page"),
+ new StringDimensionSchema("page_upper"),
+ new LongDimensionSchema("__time")
+ )
+ .build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("[page_upper]"));
+ Assertions.assertTrue(e.getMessage().contains("transformSpec"));
+ }
+
+ @Test
+ void testSerdeRoundTripsThroughInterface() throws Exception
+ {
+ final ObjectMapper mapper = TestHelper.makeJsonMapper();
+
+ final BaseTableProjectionSpec bare = pagesSpec();
+ final String bareJson = mapper.writeValueAsString(bare);
+ Assertions.assertTrue(bareJson.contains("\"type\":\"table\""), bareJson);
+ Assertions.assertEquals(bare, mapper.readValue(bareJson,
BaseTableProjectionSpec.class));
+
+ final BaseTableProjectionSpec withGranularity =
pagesSpec().withQueryGranularity(Granularities.HOUR);
+ Assertions.assertEquals(
+ withGranularity,
+ mapper.readValue(mapper.writeValueAsString(withGranularity),
BaseTableProjectionSpec.class)
+ );
+ }
+}
diff --git
a/processing/src/test/java/org/apache/druid/segment/virtual/ExpressionVirtualColumnTest.java
b/processing/src/test/java/org/apache/druid/segment/virtual/ExpressionVirtualColumnTest.java
index 558791c67ee..c0c5c500f81 100644
---
a/processing/src/test/java/org/apache/druid/segment/virtual/ExpressionVirtualColumnTest.java
+++
b/processing/src/test/java/org/apache/druid/segment/virtual/ExpressionVirtualColumnTest.java
@@ -794,6 +794,18 @@ public class ExpressionVirtualColumnTest extends
InitializedNullHandlingTest
Assertions.assertEquals(ImmutableList.of("a", "b", "c"),
multiConstantSelector.getObject());
}
+ @Test
+ public void testWithOutputName()
+ {
+ Assertions.assertTrue(X_PLUS_Y.supportsOutputNameRewrite());
+ final ExpressionVirtualColumn renamed =
X_PLUS_Y.withOutputName("declared");
+ Assertions.assertEquals(
+ new ExpressionVirtualColumn("declared", "x + y", ColumnType.FLOAT,
TestExprMacroTable.INSTANCE),
+ renamed
+ );
+ Assertions.assertEquals(X_PLUS_Y.getEquivalanceKey(),
renamed.getEquivalanceKey());
+ }
+
@Test
public void testNowCacheKeyIsNotStableAcrossInstances()
{
diff --git
a/processing/src/test/java/org/apache/druid/segment/virtual/NestedFieldVirtualColumnTest.java
b/processing/src/test/java/org/apache/druid/segment/virtual/NestedFieldVirtualColumnTest.java
index f894d2e552c..7c7b4bcf30c 100644
---
a/processing/src/test/java/org/apache/druid/segment/virtual/NestedFieldVirtualColumnTest.java
+++
b/processing/src/test/java/org/apache/druid/segment/virtual/NestedFieldVirtualColumnTest.java
@@ -98,6 +98,20 @@ public class NestedFieldVirtualColumnTest
Assertions.assertNotEquals(v1.getEquivalanceKey(), v3.getEquivalanceKey());
}
+ @Test
+ public void testWithOutputName()
+ {
+ NestedFieldVirtualColumn original = new NestedFieldVirtualColumn("nested",
"$.x.y.z", "v0", ColumnType.LONG);
+ Assertions.assertTrue(original.supportsOutputNameRewrite());
+ NestedFieldVirtualColumn renamed = original.withOutputName("declared");
+ Assertions.assertEquals(
+ new NestedFieldVirtualColumn("nested", "$.x.y.z", "declared",
ColumnType.LONG),
+ renamed
+ );
+ // Everything except the output name is identical, which the
name-independent equivalence key certifies.
+ Assertions.assertEquals(original.getEquivalanceKey(),
renamed.getEquivalanceKey());
+ }
+
@Test
public void testEqualsAndHashcode()
{
diff --git
a/processing/src/test/java/org/apache/druid/segment/virtual/NestedMergeVirtualColumnTest.java
b/processing/src/test/java/org/apache/druid/segment/virtual/NestedMergeVirtualColumnTest.java
index cdc8e334d0c..ef8c2e5a003 100644
---
a/processing/src/test/java/org/apache/druid/segment/virtual/NestedMergeVirtualColumnTest.java
+++
b/processing/src/test/java/org/apache/druid/segment/virtual/NestedMergeVirtualColumnTest.java
@@ -71,6 +71,23 @@ public class NestedMergeVirtualColumnTest
Assertions.assertEquals(Arrays.asList("col1", "col2", "col3"),
column.getColumns());
}
+ @Test
+ public void testWithOutputName()
+ {
+ NestedMergeVirtualColumn original = new NestedMergeVirtualColumn(
+ "v0",
+ Arrays.asList("col1", "col2"),
+ TestExprMacroTable.INSTANCE
+ );
+ Assertions.assertTrue(original.supportsOutputNameRewrite());
+ NestedMergeVirtualColumn renamed = original.withOutputName("declared");
+ Assertions.assertEquals(
+ new NestedMergeVirtualColumn("declared", Arrays.asList("col1",
"col2"), TestExprMacroTable.INSTANCE),
+ renamed
+ );
+ Assertions.assertEquals(original.getEquivalanceKey(),
renamed.getEquivalanceKey());
+ }
+
@Test
public void testEquivalence()
{
diff --git
a/processing/src/test/java/org/apache/druid/segment/virtual/NestedObjectVirtualColumnTest.java
b/processing/src/test/java/org/apache/druid/segment/virtual/NestedObjectVirtualColumnTest.java
index 1ac48d02fe0..678cbbd0b8f 100644
---
a/processing/src/test/java/org/apache/druid/segment/virtual/NestedObjectVirtualColumnTest.java
+++
b/processing/src/test/java/org/apache/druid/segment/virtual/NestedObjectVirtualColumnTest.java
@@ -52,6 +52,22 @@ public class NestedObjectVirtualColumnTest
Assertions.assertEquals(there, andBackAgain);
}
+ @Test
+ public void testWithOutputName()
+ {
+ Map<String, NestedObjectVirtualColumn.TypedExpression> keyExprMap =
ImmutableMap.of(
+ "computed", new NestedObjectVirtualColumn.TypedExpression("col1 +
col2", ColumnType.LONG)
+ );
+ NestedObjectVirtualColumn original = new NestedObjectVirtualColumn("v0",
keyExprMap, TestExprMacroTable.INSTANCE);
+ Assertions.assertTrue(original.supportsOutputNameRewrite());
+ NestedObjectVirtualColumn renamed = original.withOutputName("declared");
+ Assertions.assertEquals(
+ new NestedObjectVirtualColumn("declared", keyExprMap,
TestExprMacroTable.INSTANCE),
+ renamed
+ );
+ Assertions.assertEquals(original.getEquivalanceKey(),
renamed.getEquivalanceKey());
+ }
+
@Test
public void testGetKeyExprMap()
{
diff --git
a/server/src/main/java/org/apache/druid/catalog/model/BaseTableColumns.java
b/server/src/main/java/org/apache/druid/catalog/model/BaseTableColumns.java
new file mode 100644
index 00000000000..9161feec07c
--- /dev/null
+++ b/server/src/main/java/org/apache/druid/catalog/model/BaseTableColumns.java
@@ -0,0 +1,166 @@
+/*
+ * 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 org.apache.druid.data.input.impl.DimensionSchema;
+import org.apache.druid.error.InvalidInput;
+import org.apache.druid.segment.AutoTypeColumnSchema;
+import org.apache.druid.segment.DimensionHandlerUtils;
+import org.apache.druid.segment.column.ColumnHolder;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.column.ValueType;
+
+import javax.annotation.Nullable;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Shared logic for {@link DatasourceBaseTableMetadata} implementations to
derive the physical column list of a
+ * base-table spec from the declared catalog columns, with optional per-column
{@link DimensionSchema}
+ * customizations. Layout-specific rules (such as which columns a layout
forbids customizing) stay with the
+ * implementations.
+ */
+final class BaseTableColumns
+{
+ /**
+ * Indexes per-column schema customizations by column name, rejecting nulls
and duplicates.
+ */
+ static Map<String, DimensionSchema> indexColumnSchemas(List<DimensionSchema>
columnSchemas)
+ {
+ final Map<String, DimensionSchema> customSchemas = new HashMap<>();
+ for (DimensionSchema schema : columnSchemas) {
+ if (schema == null) {
+ throw InvalidInput.exception("columnSchemas must not contain null
entries");
+ }
+ if (customSchemas.put(schema.getName(), schema) != null) {
+ throw InvalidInput.exception("columnSchemas contains duplicate entries
for column [%s]", schema.getName());
+ }
+ }
+ return customSchemas;
+ }
+
+ /**
+ * Derives the {@link DimensionSchema} used during segment creation for one
declared catalog column: the
+ * customization when one is supplied (validated against the declared type),
else the default schema for the
+ * declared type.
+ */
+ static DimensionSchema toDimensionSchema(ColumnSpec column, @Nullable
DimensionSchema customSchema, String typeName)
+ {
+ ColumnType druidType = Columns.druidType(column);
+ if (druidType == null) {
+ // A column declared without a type defaults to STRING (mirroring
Columns.convertSignature), but a declared
+ // type that does not parse must be rejected rather than silently
defaulted: the declared type is the physical
+ // segment schema here.
+ if (column.dataType() != null) {
+ throw InvalidInput.exception(
+ "column [%s] has an unrecognized type [%s]; declare a SQL type
(such as [%s]) or a Druid type string"
+ + " (such as [%s] or [%s])",
+ column.name(),
+ column.dataType(),
+ Columns.SQL_BIGINT,
+ ColumnType.LONG_ARRAY.asTypeString(),
+ ColumnType.NESTED_DATA.asTypeString()
+ );
+ }
+ druidType = ColumnType.STRING;
+ }
+ if (customSchema != null) {
+ validateColumnSchemaCustomization(column, customSchema, druidType);
+ return customSchema;
+ }
+ 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 (druidType.is(ValueType.COMPLEX)) {
+ return DimensionHandlerUtils.getComplexDimensionSchema(column.name(),
druidType);
+ }
+ throw InvalidInput.exception(
+ "column [%s] has unsupported type [%s] for a [%s] base table",
+ column.name(),
+ druidType,
+ typeName
+ );
+ }
+
+ private static void validateColumnSchemaCustomization(
+ ColumnSpec column,
+ DimensionSchema customSchema,
+ ColumnType declaredType
+ )
+ {
+ if (ColumnHolder.TIME_COLUMN_NAME.equals(column.name())) {
+ throw InvalidInput.exception(
+ "columnSchemas cannot customize [%s]: the time column is always
stored as a long",
+ ColumnHolder.TIME_COLUMN_NAME
+ );
+ }
+ // The schema's type must match the declared logical type, so the physical
schema cannot silently contradict the
+ // SQL schema that INSERT/REPLACE queries are validated and coerced
against.
+ ColumnType expectedType = declaredType;
+ if (customSchema instanceof AutoTypeColumnSchema) {
+ // An uncast auto column stores values as they are ingested (inferring
the physical type) rather than coercing
+ // them to the declared type; only a column declared COMPLEX<json> may
store arbitrary shapes.
+ if (((AutoTypeColumnSchema) customSchema).getCastToType() == null
+ && !ColumnType.NESTED_DATA.equals(declaredType)) {
+ throw InvalidInput.exception(
+ "columnSchemas entry [%s] is an auto column schema without a
castToType; an uncast auto column stores"
+ + " values as they are ingested rather than coercing them to the
column's declared type [%s], set"
+ + " castToType to match the declared type",
+ column.name(),
+ declaredType
+ );
+ }
+ // The auto schema stores FLOAT as DOUBLE.
+ expectedType = autoColumnType(declaredType);
+ }
+ if (!expectedType.equals(customSchema.getColumnType())) {
+ throw InvalidInput.exception(
+ "columnSchemas entry [%s] of type [%s] does not match the column's
declared type [%s]; column schemas"
+ + " customize the physical representation of a declared column, not
its type",
+ column.name(),
+ customSchema.getColumnType(),
+ declaredType
+ );
+ }
+ }
+
+ /**
+ * The type the auto schema stores for a declared type: {@link
AutoTypeColumnSchema} coerces FLOAT to DOUBLE (the
+ * default derivation for declared FLOAT ARRAY columns relies on the same
coercion).
+ */
+ private static ColumnType autoColumnType(ColumnType declaredType)
+ {
+ if (ColumnType.FLOAT.equals(declaredType)) {
+ return ColumnType.DOUBLE;
+ }
+ if (ColumnType.FLOAT_ARRAY.equals(declaredType)) {
+ return ColumnType.DOUBLE_ARRAY;
+ }
+ return declaredType;
+ }
+
+ private BaseTableColumns()
+ {
+ }
+}
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
index dd0dca87794..40b4bc99bb2 100644
---
a/server/src/main/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadata.java
+++
b/server/src/main/java/org/apache/druid/catalog/model/ClusteredValueGroupsBaseTableMetadata.java
@@ -26,18 +26,12 @@ 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.AutoTypeColumnSchema;
-import org.apache.druid.segment.DimensionHandlerUtils;
import org.apache.druid.segment.VirtualColumns;
-import org.apache.druid.segment.column.ColumnHolder;
-import org.apache.druid.segment.column.ColumnType;
-import org.apache.druid.segment.column.ValueType;
import org.apache.druid.utils.CollectionUtils;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
-import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -132,12 +126,21 @@ public class ClusteredValueGroupsBaseTableMetadata
implements DatasourceBaseTabl
TYPE_NAME
);
}
- final Map<String, DimensionSchema> customSchemas = indexColumnSchemas();
+ final Map<String, DimensionSchema> customSchemas =
BaseTableColumns.indexColumnSchemas(columnSchemas);
+ for (String customized : customSchemas.keySet()) {
+ if (clusteringColumns.contains(customized)) {
+ throw InvalidInput.exception(
+ "columnSchemas cannot customize clustering column [%s]: the
physical representation of clustering columns"
+ + " is fixed by the clustered segment format",
+ customized
+ );
+ }
+ }
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,
customSchemas.get(column.name())));
+ specColumns.add(BaseTableColumns.toDimensionSchema(column,
customSchemas.get(column.name()), TYPE_NAME));
}
for (String clusteringColumn : clusteringColumns) {
if (!declaredNames.contains(clusteringColumn)) {
@@ -166,124 +169,6 @@ public class ClusteredValueGroupsBaseTableMetadata
implements DatasourceBaseTabl
.build();
}
- private Map<String, DimensionSchema> indexColumnSchemas()
- {
- final Map<String, DimensionSchema> customSchemas = new HashMap<>();
- for (DimensionSchema schema : columnSchemas) {
- if (schema == null) {
- throw InvalidInput.exception("columnSchemas must not contain null
entries");
- }
- if (customSchemas.put(schema.getName(), schema) != null) {
- throw InvalidInput.exception("columnSchemas contains duplicate entries
for column [%s]", schema.getName());
- }
- }
- return customSchemas;
- }
-
- private DimensionSchema toDimensionSchema(ColumnSpec column, @Nullable
DimensionSchema customSchema)
- {
- ColumnType druidType = Columns.druidType(column);
- if (druidType == null) {
- // A column declared without a type defaults to STRING (mirroring
Columns.convertSignature), but a declared
- // type that does not parse must be rejected rather than silently
defaulted: the declared type is the physical
- // segment schema here.
- if (column.dataType() != null) {
- throw InvalidInput.exception(
- "column [%s] has an unrecognized type [%s]; declare a SQL type
(such as [%s]) or a Druid type string"
- + " (such as [%s] or [%s])",
- column.name(),
- column.dataType(),
- Columns.SQL_BIGINT,
- ColumnType.LONG_ARRAY.asTypeString(),
- ColumnType.NESTED_DATA.asTypeString()
- );
- }
- druidType = ColumnType.STRING;
- }
- if (customSchema != null) {
- validateColumnSchemaCustomization(column, customSchema, druidType);
- return customSchema;
- }
- 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 (druidType.is(ValueType.COMPLEX)) {
- return DimensionHandlerUtils.getComplexDimensionSchema(column.name(),
druidType);
- }
- throw InvalidInput.exception(
- "column [%s] has unsupported type [%s] for a clustered base table",
- column.name(),
- druidType
- );
- }
-
- private void validateColumnSchemaCustomization(
- ColumnSpec column,
- DimensionSchema customSchema,
- ColumnType declaredType
- )
- {
- if (ColumnHolder.TIME_COLUMN_NAME.equals(column.name())) {
- throw InvalidInput.exception(
- "columnSchemas cannot customize [%s]: the time column is always
stored as a long",
- ColumnHolder.TIME_COLUMN_NAME
- );
- }
- if (clusteringColumns.contains(column.name())) {
- throw InvalidInput.exception(
- "columnSchemas cannot customize clustering column [%s]: the physical
representation of clustering columns"
- + " is fixed by the clustered segment format",
- column.name()
- );
- }
- // The schema's type must match the declared logical type, so the physical
schema cannot silently contradict the
- // SQL schema that INSERT/REPLACE queries are validated and coerced
against.
- ColumnType expectedType = declaredType;
- if (customSchema instanceof AutoTypeColumnSchema) {
- // An uncast auto column stores values as they are ingested (inferring
the physical type) rather than coercing
- // them to the declared type; only a column declared COMPLEX<json> may
store arbitrary shapes.
- if (((AutoTypeColumnSchema) customSchema).getCastToType() == null
- && !ColumnType.NESTED_DATA.equals(declaredType)) {
- throw InvalidInput.exception(
- "columnSchemas entry [%s] is an auto column schema without a
castToType; an uncast auto column stores"
- + " values as they are ingested rather than coercing them to the
column's declared type [%s], set"
- + " castToType to match the declared type",
- column.name(),
- declaredType
- );
- }
- // The auto schema stores FLOAT as DOUBLE.
- expectedType = autoColumnType(declaredType);
- }
- if (!expectedType.equals(customSchema.getColumnType())) {
- throw InvalidInput.exception(
- "columnSchemas entry [%s] of type [%s] does not match the column's
declared type [%s]; column schemas"
- + " customize the physical representation of a declared column, not
its type",
- column.name(),
- customSchema.getColumnType(),
- declaredType
- );
- }
- }
-
- /**
- * The type the auto schema stores for a declared type: {@link
AutoTypeColumnSchema} coerces FLOAT to DOUBLE (the
- * default derivation for declared FLOAT ARRAY columns relies on the same
coercion).
- */
- private static ColumnType autoColumnType(ColumnType declaredType)
- {
- if (ColumnType.FLOAT.equals(declaredType)) {
- return ColumnType.DOUBLE;
- }
- if (ColumnType.FLOAT_ARRAY.equals(declaredType)) {
- return ColumnType.DOUBLE_ARRAY;
- }
- return declaredType;
- }
-
@Override
public boolean equals(Object o)
{
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
index b73cfd7f4b4..17b2de20020 100644
---
a/server/src/main/java/org/apache/druid/catalog/model/DatasourceBaseTableMetadata.java
+++
b/server/src/main/java/org/apache/druid/catalog/model/DatasourceBaseTableMetadata.java
@@ -36,6 +36,7 @@ import java.util.List;
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include =
JsonTypeInfo.As.EXISTING_PROPERTY, property = "type")
@JsonSubTypes(value = {
+ @JsonSubTypes.Type(name = TableBaseTableMetadata.TYPE_NAME, value =
TableBaseTableMetadata.class),
@JsonSubTypes.Type(
name = ClusteredValueGroupsBaseTableMetadata.TYPE_NAME,
value = ClusteredValueGroupsBaseTableMetadata.class
diff --git
a/server/src/main/java/org/apache/druid/catalog/model/TableBaseTableMetadata.java
b/server/src/main/java/org/apache/druid/catalog/model/TableBaseTableMetadata.java
new file mode 100644
index 00000000000..ae0048d2382
--- /dev/null
+++
b/server/src/main/java/org/apache/druid/catalog/model/TableBaseTableMetadata.java
@@ -0,0 +1,164 @@
+/*
+ * 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.DimensionSchema;
+import org.apache.druid.data.input.impl.TableProjectionSpec;
+import org.apache.druid.error.InvalidInput;
+import org.apache.druid.segment.VirtualColumns;
+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.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Catalog layout metadata for plain {@link TableProjectionSpec} base tables.
The layout has no shape of its own beyond
+ * the catalog column list (declared column order is the physical segment
storage and sort order), so this metadata
+ * carries only the optional {@link #virtualColumns},which the plain spec
restricts to the query-granularity carrier,
+ * and optional per-column {@link #columnSchemas} customizations; {@link
#createSpec(List)} combines them with the
+ * declared columns into the physical spec used to generate segments.
+ * <p>
+ * The optional {@link #columnSchemas} do NOT define columns, the catalog
column list remains the logical schema,
+ * they are per-column customizations of the exact {@link DimensionSchema}
used to create segments for a declared
+ * column, replacing the default schema derived from the declared column type.
+ */
+@JsonTypeName(TableBaseTableMetadata.TYPE_NAME)
+public class TableBaseTableMetadata implements DatasourceBaseTableMetadata
+{
+ public static final String TYPE_NAME = TableProjectionSpec.TYPE_NAME;
+
+ private final VirtualColumns virtualColumns;
+ private final List<DimensionSchema> columnSchemas;
+
+ @JsonCreator
+ public TableBaseTableMetadata(
+ @JsonProperty("virtualColumns") @Nullable VirtualColumns virtualColumns,
+ @JsonProperty("columnSchemas") @Nullable List<DimensionSchema>
columnSchemas
+ )
+ {
+ this.virtualColumns = virtualColumns == null ? VirtualColumns.EMPTY :
virtualColumns;
+ this.columnSchemas = columnSchemas == null ? Collections.emptyList() :
columnSchemas;
+ }
+
+ @Override
+ @JsonProperty("type")
+ public String getType()
+ {
+ return TYPE_NAME;
+ }
+
+ @Override
+ @JsonProperty("virtualColumns")
+ @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+ public VirtualColumns getVirtualColumns()
+ {
+ return virtualColumns;
+ }
+
+ /**
+ * Per-column customizations of the {@link DimensionSchema} used during
segment creation, keyed by
+ * {@link DimensionSchema#getName()}; empty when every declared column uses
the schema derived from its declared
+ * type. These do not define columns: every entry must customize a declared
column.
+ */
+ @JsonProperty("columnSchemas")
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ public List<DimensionSchema> getColumnSchemas()
+ {
+ return columnSchemas;
+ }
+
+ /**
+ * Creates the physical spec from the declared catalog columns, used
verbatim: the declared column order is the
+ * physical segment order and sort order. All layout rules — the explicit
{@code __time} position, and the
+ * restriction of {@link #virtualColumns} to the query-granularity carrier —
are enforced by the spec itself.
+ * <p>
+ * A column with an entry in {@link #columnSchemas} uses that {@link
DimensionSchema} verbatim in place of the
+ * default derived from its declared type. Customizations may only target
declared columns ({@code __time} is always
+ * a long), and the schema's type must match the declared logical type so
the physical schema cannot silently
+ * contradict the SQL schema.
+ */
+ @Override
+ public TableProjectionSpec 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 Map<String, DimensionSchema> customSchemas =
BaseTableColumns.indexColumnSchemas(columnSchemas);
+ final Set<String> declaredNames = new HashSet<>();
+ final List<DimensionSchema> specColumns = new ArrayList<>(columns.size());
+ for (ColumnSpec column : columns) {
+ declaredNames.add(column.name());
+ specColumns.add(BaseTableColumns.toDimensionSchema(column,
customSchemas.get(column.name()), TYPE_NAME));
+ }
+ for (String customized : customSchemas.keySet()) {
+ if (!declaredNames.contains(customized)) {
+ throw InvalidInput.exception(
+ "columnSchemas entry [%s] does not customize a declared column;
column schemas do not define columns,"
+ + " declare [%s] in the table's column list",
+ customized,
+ customized
+ );
+ }
+ }
+ return TableProjectionSpec.builder()
+ .virtualColumns(virtualColumns)
+ .columns(specColumns)
+ .build();
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ TableBaseTableMetadata that = (TableBaseTableMetadata) o;
+ return Objects.equals(virtualColumns, that.virtualColumns)
+ && Objects.equals(columnSchemas, that.columnSchemas);
+ }
+
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(virtualColumns, columnSchemas);
+ }
+
+ @Override
+ public String toString()
+ {
+ return "TableBaseTableMetadata{" +
+ "virtualColumns=" + virtualColumns +
+ ", columnSchemas=" + columnSchemas +
+ '}';
+ }
+}
diff --git
a/server/src/main/java/org/apache/druid/segment/indexing/DataSchema.java
b/server/src/main/java/org/apache/druid/segment/indexing/DataSchema.java
index 741fadf9406..51051337a3a 100644
--- a/server/src/main/java/org/apache/druid/segment/indexing/DataSchema.java
+++ b/server/src/main/java/org/apache/druid/segment/indexing/DataSchema.java
@@ -247,10 +247,7 @@ public class DataSchema
private static Granularity queryGranularityFromSpec(BaseTableProjectionSpec
spec)
{
final VirtualColumn vc =
spec.getVirtualColumns().getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME);
- if (vc == null) {
- return Granularities.NONE;
- }
- final Granularity granularity = Granularities.fromVirtualColumn(vc);
+ final Granularity granularity = Granularities.fromTimeVirtualColumn(vc);
return granularity == null ? Granularities.NONE : granularity;
}
diff --git
a/server/src/test/java/org/apache/druid/catalog/model/TableBaseTableMetadataTest.java
b/server/src/test/java/org/apache/druid/catalog/model/TableBaseTableMetadataTest.java
new file mode 100644
index 00000000000..fd05651b1aa
--- /dev/null
+++
b/server/src/test/java/org/apache/druid/catalog/model/TableBaseTableMetadataTest.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.catalog.model;
+
+import com.fasterxml.jackson.databind.InjectableValues;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.druid.data.input.impl.DimensionSchema;
+import org.apache.druid.data.input.impl.StringDimensionSchema;
+import org.apache.druid.data.input.impl.TableProjectionSpec;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.guice.BuiltInTypesModule;
+import org.apache.druid.jackson.DefaultObjectMapper;
+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.segment.DefaultColumnFormatConfig;
+import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.testing.InitializedNullHandlingTest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * The column-derivation rules shared with the clustered layout (type
retention, complex handler resolution,
+ * columnSchemas customization validation) are covered exhaustively in {@link
ClusteredValueGroupsBaseTableMetadataTest}
+ * and route through the same {@code BaseTableColumns} helper; this covers
what is specific to the plain layout.
+ */
+public class TableBaseTableMetadataTest extends InitializedNullHandlingTest
+{
+ static {
+ BuiltInTypesModule.registerHandlersAndSerde();
+ }
+
+ // The granularity macro table (not nil) so the carrier's timestamp_floor
expression can be parsed back.
+ private final ObjectMapper mapper = new
DefaultObjectMapper().setInjectableValues(
+ new InjectableValues.Std()
+ .addValue(ExprMacroTable.class, ExprMacroTable.granularity())
+ .addValue(DefaultColumnFormatConfig.class, new
DefaultColumnFormatConfig(null, null, null, null))
+ );
+
+ 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("delta", Columns.SQL_BIGINT, null)
+ );
+
+ private static VirtualColumns hourCarrier()
+ {
+ return VirtualColumns.create(
+ Granularities.toVirtualColumn(Granularities.HOUR,
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
+ );
+ }
+
+ @Test
+ public void testSerde() throws Exception
+ {
+ final DatasourceBaseTableMetadata metadata = new
TableBaseTableMetadata(hourCarrier(), null);
+ final String json = mapper.writeValueAsString(metadata);
+ Assertions.assertTrue(json.contains("\"type\":\"table\""), json);
+ final DatasourceBaseTableMetadata fromJson = mapper.readValue(json,
DatasourceBaseTableMetadata.class);
+ Assertions.assertEquals(metadata, fromJson);
+ }
+
+ @Test
+ public void testSerdeEmpty() throws Exception
+ {
+ final DatasourceBaseTableMetadata metadata = new
TableBaseTableMetadata(null, null);
+ final String json = mapper.writeValueAsString(metadata);
+ Assertions.assertFalse(json.contains("virtualColumns"));
+ Assertions.assertFalse(json.contains("columnSchemas"));
+ final DatasourceBaseTableMetadata fromJson = mapper.readValue(json,
DatasourceBaseTableMetadata.class);
+ Assertions.assertEquals(metadata, fromJson);
+ }
+
+ @Test
+ public void testCreateSpec()
+ {
+ final TableProjectionSpec spec = new TableBaseTableMetadata(hourCarrier(),
null).createSpec(COLUMNS);
+
+ // Declared catalog order is both storage and sort order, and the carrier
rides through as the query granularity.
+ Assertions.assertEquals(
+ List.of("tenant", Columns.TIME_COLUMN, "delta"),
+
spec.getColumns().stream().map(DimensionSchema::getName).collect(Collectors.toList())
+ );
+ Assertions.assertEquals(
+ List.of(
+ OrderBy.ascending("tenant"),
+ OrderBy.ascending(Columns.TIME_COLUMN),
+ OrderBy.ascending("delta")
+ ),
+ spec.getOrdering()
+ );
+ Assertions.assertEquals(Granularities.HOUR, spec.getQueryGranularity());
+ Assertions.assertEquals(0, spec.getMetrics().length);
+ }
+
+ @Test
+ public void testCreateSpecWithColumnSchemas()
+ {
+ final TableProjectionSpec spec = new TableBaseTableMetadata(
+ null,
+ Collections.singletonList(
+ new StringDimensionSchema("tenant",
DimensionSchema.MultiValueHandling.ARRAY, false)
+ )
+ ).createSpec(COLUMNS);
+
+ Assertions.assertEquals(
+ new StringDimensionSchema("tenant",
DimensionSchema.MultiValueHandling.ARRAY, false),
+ spec.getColumns().get(0)
+ );
+ }
+
+ @Test
+ public void testCreateSpecColumnSchemaForUndeclaredColumnFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new TableBaseTableMetadata(
+ null,
+ Collections.singletonList(new StringDimensionSchema("nope"))
+ ).createSpec(COLUMNS)
+ );
+ Assertions.assertTrue(e.getMessage().contains("columnSchemas entry [nope]
does not customize a declared column"));
+ }
+
+ /**
+ * The plain spec accepts only the granularity carrier, so any other virtual
column in the metadata surfaces as the
+ * spec's rejection when the layout is validated.
+ */
+ @Test
+ public void testCreateSpecNonCarrierVirtualColumnFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new TableBaseTableMetadata(
+ VirtualColumns.create(
+ Granularities.toVirtualColumn(Granularities.HOUR,
"not_the_carrier")
+ ),
+ null
+ ).createSpec(COLUMNS)
+ );
+ Assertions.assertTrue(e.getMessage().contains("virtual column
[not_the_carrier] is not supported"), e.getMessage());
+ }
+
+ @Test
+ public void testCreateSpecMissingTimeColumnFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new TableBaseTableMetadata(null, null).createSpec(
+ Collections.singletonList(new ColumnSpec("tenant",
Columns.SQL_VARCHAR, null))
+ )
+ );
+ Assertions.assertTrue(e.getMessage().contains(Columns.TIME_COLUMN),
e.getMessage());
+ }
+
+ @Test
+ public void testCreateSpecNoDeclaredColumnsFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new TableBaseTableMetadata(null, null).createSpec(null)
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("Cannot define a [table] base table without
declared columns"),
+ e.getMessage()
+ );
+ }
+}
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 0bf965fe5b0..7836f057d32 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
@@ -35,6 +35,7 @@ import
org.apache.druid.data.input.impl.ClusteredValueGroupsBaseTableProjectionS
import org.apache.druid.data.input.impl.DimensionsSpec;
import org.apache.druid.data.input.impl.LongDimensionSchema;
import org.apache.druid.data.input.impl.StringDimensionSchema;
+import org.apache.druid.data.input.impl.TableProjectionSpec;
import org.apache.druid.data.input.impl.TimestampSpec;
import org.apache.druid.error.DruidException;
import org.apache.druid.error.DruidExceptionMatcher;
@@ -927,6 +928,47 @@ class DataSchemaTest extends InitializedNullHandlingTest
Assertions.assertEquals(spec, deserialized.getBaseTable());
}
+ /**
+ * The generic baseTable-mode machinery is covered above with the clustered
spec; this covers what is specific to a
+ * plain {@link TableProjectionSpec}: it rides the same {@code baseTable}
property (JSON type {@code table}), and it
+ * lowers into the standard ingest inputs — a dimensionsSpec with the
explicit {@code __time} position and
+ * {@code forceSegmentSortByTime=false}, no metrics, and a recombined
granularity with rollup off and query
+ * granularity read from the spec's carrier virtual column.
+ */
+ @Test
+ void testBaseTableModeWithTableProjectionSpec() throws IOException
+ {
+ final TableProjectionSpec spec = TableProjectionSpec.builder()
+ .columns(
+ new StringDimensionSchema("page"),
+ new LongDimensionSchema("__time"),
+ new LongDimensionSchema("cnt")
+ )
+ .build()
+ .withQueryGranularity(Granularities.HOUR);
+ final DataSchema original = DataSchema.builder()
+ .withDataSource("datasource")
+ .withTimestamp(TIMESTAMP_SPEC)
+ .withSegmentGranularity(new
SegmentGranularitySpec(Granularities.DAY, null))
+ .withBaseTable(spec)
+ .build();
+
+ final String serialized = jsonMapper.writeValueAsString(original);
+ final JsonNode root = jsonMapper.readTree(serialized);
+ Assertions.assertEquals("table",
root.get("baseTable").get("type").asText());
+ final DataSchema deserialized = jsonMapper.readValue(serialized,
DataSchema.class);
+ Assertions.assertEquals(original, deserialized);
+ Assertions.assertEquals(spec, deserialized.getBaseTable());
+
+ Assertions.assertEquals(spec.getDimensionsSpec(),
original.getDimensionsSpec());
+
Assertions.assertFalse(original.getDimensionsSpec().isForceSegmentSortByTime());
+ Assertions.assertEquals(0, original.getAggregators().length);
+ final GranularitySpec effectiveGranularity = original.getGranularitySpec();
+ Assertions.assertEquals(Granularities.DAY,
effectiveGranularity.getSegmentGranularity());
+ Assertions.assertEquals(Granularities.HOUR,
effectiveGranularity.getQueryGranularity());
+ Assertions.assertFalse(effectiveGranularity.isRollup());
+ }
+
@Test
void testBaseTableModeLegacyAccessorsDelegateToSpec()
{
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/CatalogDdlHandler.java
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/CatalogDdlHandler.java
index b4f315f0213..a59aaa730d0 100644
---
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/CatalogDdlHandler.java
+++
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/CatalogDdlHandler.java
@@ -30,9 +30,9 @@ import org.apache.calcite.sql.SqlLiteral;
import org.apache.calcite.sql.SqlNode;
import org.apache.calcite.sql.SqlNodeList;
import org.apache.calcite.sql.type.SqlTypeName;
-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.DatasourceProjectionMetadata;
import org.apache.druid.catalog.model.TableId;
import org.apache.druid.catalog.model.TableMetadata;
@@ -275,7 +275,7 @@ public abstract class CatalogDdlHandler extends
SqlStatementHandler.BaseStatemen
* Translate the reserved {@code __base} projection, which describes the
physical layout of the table rather than an
* additional aggregate, and so becomes the {@code baseTable} property
instead of one of the projections.
*/
- protected static ClusteredValueGroupsBaseTableMetadata translateBaseTable(
+ protected static DatasourceBaseTableMetadata translateBaseTable(
final SqlStatementHandler.HandlerContext handlerContext,
final String tableName,
final List<ColumnSpec> columns,
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/ProjectionSpecTranslator.java
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/ProjectionSpecTranslator.java
index c401639f620..dbb48b78f82 100644
---
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/ProjectionSpecTranslator.java
+++
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/ProjectionSpecTranslator.java
@@ -30,13 +30,16 @@ import org.apache.calcite.sql.util.SqlBasicVisitor;
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.TableBaseTableMetadata;
import org.apache.druid.data.input.impl.AggregateProjectionSpec;
import org.apache.druid.data.input.impl.DimensionSchema;
import org.apache.druid.error.DruidException;
import org.apache.druid.error.InvalidSqlInput;
import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.StringUtils;
-import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.java.util.common.granularity.Granularity;
import org.apache.druid.query.DataSource;
import org.apache.druid.query.Query;
import org.apache.druid.query.QueryContexts;
@@ -52,9 +55,9 @@ import org.apache.druid.query.scan.ScanQuery;
import org.apache.druid.query.timeseries.TimeseriesQuery;
import org.apache.druid.segment.VirtualColumn;
import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.segment.column.ColumnHolder;
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.server.security.AuthorizationResult;
import org.apache.druid.server.security.NoopEscalator;
import org.apache.druid.sql.calcite.rel.DruidQuery;
@@ -68,8 +71,11 @@ import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* Turns the SQL body of a projection definition into the {@link
AggregateProjectionSpec} the catalog stores.
@@ -139,13 +145,17 @@ public class ProjectionSpecTranslator
* than an additional aggregate.
* <p>
* The body enumerates the table's columns in the order segments store them,
so it must name every declared column,
- * in declared order. An item written as {@code <expr> AS <name>} makes that
column computed at ingest time: the
- * expression becomes a virtual column materializing the declared column,
which is why the declared type has to
- * match what the expression produces.
+ * in declared order. With {@code CLUSTERED BY} the layout is a clustered
base table, and an item written as
+ * {@code <expr> AS <name>} makes that column computed at ingest time: the
expression becomes a virtual column
+ * materializing the declared column, which is why the declared type has to
match what the expression produces.
+ * Without {@code CLUSTERED BY} the layout is a plain table, which stores
columns as they arrive; the only
+ * expression it accepts is {@code TIME_FLOOR(__time, <period>)} selected as
{@code __time}, which becomes the
+ * table's query granularity (carried as the granularity virtual column of
the spec).
*
- * @param clusteredBy the columns segments are clustered on, which must be
the leading prefix of the column list
+ * @param clusteredBy the columns segments are clustered on, which must be
the leading prefix of the column list;
+ * null selects the plain-table layout
*/
- public ClusteredValueGroupsBaseTableMetadata translateBaseTable(
+ public DatasourceBaseTableMetadata translateBaseTable(
final String tableName,
final List<ColumnSpec> columns,
final SqlSelect body,
@@ -161,11 +171,19 @@ public class ProjectionSpecTranslator
rejectSubqueries(BASE_PROJECTION_NAME, body);
final DruidQuery druidQuery = planBody(tableName, columns,
BASE_PROJECTION_NAME, body);
- final ClusteredValueGroupsBaseTableMetadata metadata = new
ClusteredValueGroupsBaseTableMetadata(
- clusteringColumns(clusteredBy),
- liftComputedColumns(columns, druidQuery),
- null
- );
+ final List<ComputedColumn> computed = liftComputedColumns(columns,
druidQuery);
+ final DatasourceBaseTableMetadata metadata;
+ if (clusteredBy == null) {
+ metadata = new
TableBaseTableMetadata(plainTableVirtualColumns(computed), null);
+ } else {
+ // liftComputedColumns already verified that the body plans to a scan.
+ final VirtualColumns planned = druidQuery.getQuery().getVirtualColumns();
+ metadata = new ClusteredValueGroupsBaseTableMetadata(
+ clusteringColumns(clusteredBy),
+ materializedVirtualColumns(computed, planned, columns),
+ null
+ );
+ }
// Derive the physical spec now. The catalog does this too when the write
lands, but doing it here attributes
// layout problems to the statement that caused them rather than to a
Coordinator round trip.
@@ -178,6 +196,98 @@ public class ProjectionSpecTranslator
return metadata;
}
+ /**
+ * A computed column of a plain (non-clustered) base table can only be the
table's query granularity.
+ */
+ private static VirtualColumns plainTableVirtualColumns(final
List<ComputedColumn> computed)
+ {
+ final List<VirtualColumn> virtualColumns = new ArrayList<>();
+ for (ComputedColumn column : computed) {
+ if (!ColumnHolder.TIME_COLUMN_NAME.equals(column.declaredName)) {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ StringUtils.format(
+ "its column [%s] is computed by an expression. Computed
columns are not supported; compute the column"
+ + " at ingestion time instead",
+ column.declaredName
+ )
+ );
+ }
+ final Granularity granularity =
Granularities.fromTimeVirtualColumn(column.virtualColumn);
+ if (granularity == null) {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ StringUtils.format(
+ "it computes [%s] with an expression that is not a
granularity. A base table computes [%s] only as"
+ + " TIME_FLOOR(%s, <period>), which declares the table's query
granularity",
+ ColumnHolder.TIME_COLUMN_NAME,
+ ColumnHolder.TIME_COLUMN_NAME,
+ ColumnHolder.TIME_COLUMN_NAME
+ )
+ );
+ }
+ virtualColumns.add(Granularities.toVirtualColumn(granularity,
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME));
+ }
+ return VirtualColumns.create(virtualColumns);
+ }
+
+ /**
+ * The clustered write path materializes computed columns: each planned
virtual column is stored under the name of
+ * the declared column it fills, together with its dependency closure. The
planner composes specialized virtual
+ * columns by reference so the intermediaries a materialized column reads
are carried into the spec under those
+ * synthetic names, as unstored inputs of the columns they feed. Only the
materialized roots are renamed.
+ */
+ private static VirtualColumns materializedVirtualColumns(
+ final List<ComputedColumn> computed,
+ final VirtualColumns planned,
+ final List<ColumnSpec> columns
+ )
+ {
+ final Set<String> declared = new HashSet<>();
+ for (ColumnSpec column : columns) {
+ declared.add(column.name());
+ }
+ final List<VirtualColumn> materialized = new ArrayList<>(computed.size());
+ final Map<String, VirtualColumn> intermediaries = new LinkedHashMap<>();
+ for (ComputedColumn column : computed) {
+ if (!column.virtualColumn.supportsOutputNameRewrite()) {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ "column [" + column.declaredName + "] is computed by an expression
the base table cannot store"
+ );
+ }
+
materialized.add(column.virtualColumn.withOutputName(column.declaredName));
+ collectDependencies(column.virtualColumn, planned, declared,
intermediaries);
+ }
+ materialized.addAll(intermediaries.values());
+ return VirtualColumns.create(materialized);
+ }
+
+ /**
+ * Collects the planned virtual columns that {@code root} (transitively)
reads by synthetic name. A required column
+ * that is declared is a stored column reference and needs nothing; one that
names a planned virtual column is an
+ * intermediary to carry along; anything else is left for the spec's own
validation to report.
+ */
+ private static void collectDependencies(
+ final VirtualColumn root,
+ final VirtualColumns planned,
+ final Set<String> declared,
+ final Map<String, VirtualColumn> intermediaries
+ )
+ {
+ for (String input : root.requiredColumns()) {
+ if (declared.contains(input) || intermediaries.containsKey(input)) {
+ continue;
+ }
+ final VirtualColumn dependency = planned.getVirtualColumn(input);
+ if (dependency == null) {
+ continue;
+ }
+ intermediaries.put(input, dependency);
+ collectDependencies(dependency, planned, declared, intermediaries);
+ }
+ }
+
private static List<String> clusteringColumns(@Nullable final SqlNodeList
clusteredBy)
{
if (clusteredBy == null) {
@@ -198,12 +308,30 @@ public class ProjectionSpecTranslator
}
/**
- * Pair the planned output with the declared columns and lift the virtual
columns behind the computed ones.
+ * A body item written as {@code <expr> AS <name>}: the declared column it
fills, paired with the planned virtual
+ * column that computes it. The virtual column is exactly what the planner
would generate for the same expression in
+ * a query — including specializations such as the nested-field virtual
column — so a layout can interpret it before
+ * deciding how (or whether) to store it, and what it stores matches what
queries plan.
+ */
+ private static final class ComputedColumn
+ {
+ private final String declaredName;
+ private final VirtualColumn virtualColumn;
+
+ private ComputedColumn(String declaredName, VirtualColumn virtualColumn)
+ {
+ this.declaredName = declaredName;
+ this.virtualColumn = virtualColumn;
+ }
+ }
+
+ /**
+ * Pair the planned output with the declared columns and lift the
expressions behind the computed ones.
* <p>
- * The planner names its virtual columns {@code v0}, {@code v1}, ...; each
is renamed to the declared column it
+ * The planner names its virtual columns {@code v0}, {@code v1}, ...; each
is paired with the declared column it
* fills, which is what makes it a materialized column rather than an
anonymous intermediate.
*/
- private static VirtualColumns liftComputedColumns(
+ private static List<ComputedColumn> liftComputedColumns(
final List<ColumnSpec> columns,
final DruidQuery druidQuery
)
@@ -230,8 +358,8 @@ public class ProjectionSpecTranslator
);
}
- final VirtualColumns planned = ((ScanQuery) query).getVirtualColumns();
- final List<VirtualColumn> materialized = new ArrayList<>();
+ final VirtualColumns planned = query.getVirtualColumns();
+ final List<ComputedColumn> computed = new ArrayList<>();
for (int i = 0; i < columns.size(); i++) {
final String declared = columns.get(i).name();
if (!declared.equals(outputNames.get(i))) {
@@ -265,23 +393,9 @@ public class ProjectionSpecTranslator
// A plain reference: the column is ingested as it arrives.
continue;
}
- if (!(virtualColumn instanceof ExpressionVirtualColumn)) {
- throw invalid(
- BASE_PROJECTION_NAME,
- "column [" + declared + "] is computed by an expression the base
table cannot store"
- );
- }
- final ExpressionVirtualColumn expression = (ExpressionVirtualColumn)
virtualColumn;
- materialized.add(
- new ExpressionVirtualColumn(
- declared,
- expression.getExpression(),
- expression.getOutputType(),
- ExprMacroTable.nil()
- )
- );
+ computed.add(new ComputedColumn(declared, virtualColumn));
}
- return VirtualColumns.create(materialized);
+ return computed;
}
/**
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteCatalogDdlTest.java
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteCatalogDdlTest.java
index d57b1c34092..f6d65b5f4d7 100644
--- a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteCatalogDdlTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteCatalogDdlTest.java
@@ -31,6 +31,7 @@ import
org.apache.druid.catalog.model.ClusteredValueGroupsBaseTableMetadata;
import org.apache.druid.catalog.model.ColumnSpec;
import org.apache.druid.catalog.model.DatasourceBaseTableMetadata;
import org.apache.druid.catalog.model.DatasourceProjectionMetadata;
+import org.apache.druid.catalog.model.TableBaseTableMetadata;
import org.apache.druid.catalog.model.TableId;
import org.apache.druid.catalog.model.TableMetadata;
import org.apache.druid.catalog.model.TableSpec;
@@ -38,10 +39,15 @@ import org.apache.druid.catalog.model.table.ClusterKeySpec;
import org.apache.druid.catalog.model.table.DatasourceDefn;
import org.apache.druid.data.input.impl.AggregateProjectionSpec;
import org.apache.druid.error.DruidException;
+import org.apache.druid.guice.BuiltInTypesModule;
import org.apache.druid.java.util.common.JodaUtils;
import org.apache.druid.java.util.common.granularity.Granularities;
import org.apache.druid.query.filter.RangeFilter;
+import org.apache.druid.segment.VirtualColumn;
+import org.apache.druid.segment.VirtualColumns;
import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.virtual.NestedFieldVirtualColumn;
+import org.apache.druid.segment.virtual.NestedMergeVirtualColumn;
import org.apache.druid.server.security.Action;
import org.apache.druid.server.security.AuthConfig;
import org.apache.druid.server.security.AuthenticationResult;
@@ -82,6 +88,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
@SqlTestFrameworkConfig.ComponentSupplier(CatalogDdlComponentSupplier.class)
public class CalciteCatalogDdlTest extends BaseCalciteQueryTest
{
+ static {
+ // Deriving a base-table spec resolves complex columns through the
dimension-handler registry, which the SQL test
+ // framework does not populate on its own.
+ BuiltInTypesModule.registerHandlersAndSerde();
+ }
+
private static final RecordingCatalogTableWriter WRITER = new
RecordingCatalogTableWriter();
public static class CatalogDdlComponentSupplier extends
StandardComponentSupplier
@@ -760,6 +772,62 @@ public class CalciteCatalogDdlTest extends
BaseCalciteQueryTest
);
}
+ /**
+ * A computed column keeps the virtual column type the planner produced for
it: {@code JSON_VALUE} plans to a
+ * nested-field virtual column, not a generic expression, and the stored
layout keeps that specialization (renamed to
+ * the declared column) so it matches the virtual columns the planner
generates for equivalent query expressions.
+ */
+ @Test
+ public void testBaseProjectionComputedColumnKeepsPlannedVirtualColumnType()
+ {
+ execute(
+ "CREATE TABLE tbl (tenant VARCHAR, payload TYPE('COMPLEX<json>'), v
BIGINT, __time TIMESTAMP,"
+ + " PROJECTION __base AS ("
+ + " SELECT tenant, payload, JSON_VALUE(payload, '$.v' RETURNING
BIGINT) AS v, __time"
+ + " CLUSTERED BY tenant"
+ + " ))"
+ );
+ assertEquals(
+ new ClusteredValueGroupsBaseTableMetadata(
+ ImmutableList.of("tenant"),
+ VirtualColumns.create(new NestedFieldVirtualColumn("payload",
"$.v", "v", ColumnType.LONG)),
+ null
+ ),
+
WRITER.calls.get(0).spec.properties().get(DatasourceDefn.BASE_TABLE_PROPERTY)
+ );
+ }
+
+ /**
+ * A computed column composed of specialized expressions lifts with its
dependency closure: the planner composes
+ * specializations by reference (the inner JSON_MERGE becomes its own
virtual column that the outer nested-field
+ * virtual column reads by synthetic name), so the intermediary rides into
the spec under its synthetic name as an
+ * unstored input of the materialized column, and both keep their planned
specialized types.
+ */
+ @Test
+ public void testBaseProjectionComposedExpressionLiftsDependencyClosure()
+ {
+ execute(
+ "CREATE TABLE tbl (tenant VARCHAR, payload TYPE('COMPLEX<json>'), v
BIGINT, __time TIMESTAMP,"
+ + " PROJECTION __base AS ("
+ + " SELECT tenant, payload, JSON_VALUE(JSON_MERGE(payload, payload),
'$.v' RETURNING BIGINT) AS v, __time"
+ + " CLUSTERED BY tenant"
+ + " ))"
+ );
+ final ClusteredValueGroupsBaseTableMetadata metadata =
+ (ClusteredValueGroupsBaseTableMetadata)
+
WRITER.calls.get(0).spec.properties().get(DatasourceDefn.BASE_TABLE_PROPERTY);
+ final VirtualColumns virtualColumns = metadata.getVirtualColumns();
+ assertEquals(2, virtualColumns.getVirtualColumns().length);
+
+ final VirtualColumn materialized = virtualColumns.getVirtualColumn("v");
+ assertTrue(materialized instanceof NestedFieldVirtualColumn,
String.valueOf(materialized));
+ assertEquals(1, materialized.requiredColumns().size());
+
+ final VirtualColumn intermediary =
virtualColumns.getVirtualColumn(materialized.requiredColumns().get(0));
+ assertTrue(intermediary instanceof NestedMergeVirtualColumn,
String.valueOf(intermediary));
+ assertEquals(ImmutableList.of("payload"), intermediary.requiredColumns());
+ }
+
/**
* A base table column is stored under the name it declares, so a body item
that renames another column is rejected:
* only a virtual column materializes a name the body did not select, and a
bare reference produces none.
@@ -835,6 +903,118 @@ public class CalciteCatalogDdlTest extends
BaseCalciteQueryTest
assertNull(spec.properties().get(DatasourceDefn.SEALED_PROPERTY));
}
+ /**
+ * A {@code __base} projection without {@code CLUSTERED BY} declares the
plain-table layout: declared column order is
+ * the physical storage and sort order, and no clustering structure is built.
+ */
+ @Test
+ public void testCreateTableWithPlainBaseProjection()
+ {
+ execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP, v BIGINT,"
+ + " PROJECTION __base AS (SELECT tenant, __time, v))"
+ );
+ final TableSpec spec = WRITER.calls.get(0).spec;
+ assertEquals(
+ new TableBaseTableMetadata(null, null),
+ spec.properties().get(DatasourceDefn.BASE_TABLE_PROPERTY)
+ );
+ }
+
+ /**
+ * The one expression a plain base table accepts: {@code TIME_FLOOR(__time,
<period>)} selected as {@code __time}
+ * declares the table's query granularity, stored as the spec's canonical
granularity-carrier virtual column rather
+ * than as a materialized column.
+ */
+ @Test
+ public void testPlainBaseProjectionEncodesQueryGranularity()
+ {
+ execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP, v BIGINT,"
+ + " PROJECTION __base AS (SELECT tenant, TIME_FLOOR(__time, 'PT1H') AS
__time, v))"
+ );
+ final TableSpec spec = WRITER.calls.get(0).spec;
+ assertEquals(
+ new TableBaseTableMetadata(
+ VirtualColumns.create(
+ Granularities.toVirtualColumn(Granularities.HOUR,
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
+ ),
+ null
+ ),
+ spec.properties().get(DatasourceDefn.BASE_TABLE_PROPERTY)
+ );
+ }
+
+ /**
+ * {@code __time} is stored in UTC, so a base table's query granularity must
be a UTC period: a session time zone
+ * reaching the body's TIME_FLOOR (the same way it reaches an aggregate
projection's) makes the granularity
+ * non-UTC, and the statement is rejected rather than storing buckets that
do not align with the stored values.
+ */
+ @Test
+ public void testPlainBaseProjectionRejectsNonUtcQueryGranularity()
+ {
+ final DruidException e = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "SET sqlTimeZone = 'America/Los_Angeles';\n"
+ + "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP,"
+ + " PROJECTION __base AS (SELECT tenant, TIME_FLOOR(__time, 'P1D')
AS __time))"
+ )
+ );
+ assertTrue(
+ e.getMessage().contains("only period granularities in the UTC time
zone without an origin are supported"),
+ e.getMessage()
+ );
+ assertTrue(WRITER.calls.isEmpty());
+ }
+
+ /**
+ * The plain write path stores columns as they arrive, so a plain base table
rejects computed columns.
+ */
+ @Test
+ public void testPlainBaseProjectionRejectsComputedColumn()
+ {
+ final DruidException e = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl (tenant VARCHAR, bucket BIGINT, __time
TIMESTAMP,"
+ + " PROJECTION __base AS (SELECT tenant, ABS(bucket) AS bucket,
__time))"
+ )
+ );
+ assertTrue(
+ e.getMessage().contains(
+ "its column [bucket] is computed by an expression. Computed
columns are not supported; compute the column"
+ + " at ingestion time instead"
+ ),
+ e.getMessage()
+ );
+ assertTrue(WRITER.calls.isEmpty());
+ }
+
+ /**
+ * A computed {@code __time} is only meaningful as a granularity
declaration, so an expression the granularity layer
+ * cannot recover is rejected rather than silently stored as something else.
+ */
+ @Test
+ public void testPlainBaseProjectionRejectsNonGranularityTimeExpression()
+ {
+ final DruidException e = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP,"
+ + " PROJECTION __base AS (SELECT tenant, TIME_SHIFT(__time,
'PT1H', 1) AS __time))"
+ )
+ );
+ assertTrue(
+ e.getMessage().contains(
+ "it computes [__time] with an expression that is not a
granularity. A base table computes [__time] only"
+ + " as TIME_FLOOR(__time, <period>), which declares the table's
query granularity"
+ ),
+ e.getMessage()
+ );
+ assertTrue(WRITER.calls.isEmpty());
+ }
+
/**
* The body lists the columns in the order segments store them, so it must
match the declaration exactly.
*/
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlDdlParserTest.java
b/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlDdlParserTest.java
index 6f3afcc2cd5..cb2b24d43fc 100644
---
a/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlDdlParserTest.java
+++
b/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlDdlParserTest.java
@@ -456,6 +456,11 @@ public class DruidSqlDdlParserTest
"CREATE TABLE \"tbl\" (\"a\" VARCHAR, \"__time\" TIMESTAMP,"
+ " PROJECTION \"__base\" AS (SELECT \"a\", \"__time\" CLUSTERED BY
\"a\"))"
);
+ // Without CLUSTERED BY, the base projection declares the plain-table
layout.
+ assertUnparseRoundTrips(
+ "CREATE TABLE \"tbl\" (\"a\" VARCHAR, \"__time\" TIMESTAMP,"
+ + " PROJECTION \"__base\" AS (SELECT \"a\", \"__time\"))"
+ );
assertUnparseRoundTrips("ALTER TABLE \"tbl\" DROP PROJECTION \"p\"");
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]