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 98bbcb4c7f7 feat: add rollup table base table spec for catalog wiring
(#20452)
98bbcb4c7f7 is described below
commit 98bbcb4c7f783342f670f71cb2ee7910f100f45f
Author: Clint Wylie <[email protected]>
AuthorDate: Fri Oct 9 15:36:31 2026 -0700
feat: add rollup table base table spec for catalog wiring (#20452)
---
docs/development/extensions-core/catalog.md | 77 ++++-
.../embedded/catalog/CatalogDdlAndIngestTest.java | 95 ++++++
.../testing/embedded/indexing/IndexTaskTest.java | 52 +++
.../input/impl/AdaptedBaseTableProjectionSpec.java | 6 +
.../data/input/impl/BaseTableProjectionSpec.java | 95 ++++++
.../data/input/impl/RollupTableProjectionSpec.java | 354 +++++++++++++++++++++
.../druid/data/input/impl/TableProjectionSpec.java | 76 +----
.../input/impl/RollupTableProjectionSpecTest.java | 301 ++++++++++++++++++
.../catalog/model/DatasourceBaseTableMetadata.java | 1 +
.../model/RollupTableBaseTableMetadata.java | 232 ++++++++++++++
.../apache/druid/segment/indexing/DataSchema.java | 5 +-
.../model/RollupTableBaseTableMetadataTest.java | 267 ++++++++++++++++
.../druid/segment/indexing/DataSchemaTest.java | 42 +++
.../calcite/planner/ProjectionSpecTranslator.java | 254 ++++++++++++---
.../druid/sql/calcite/CalciteCatalogDdlTest.java | 198 +++++++++++-
15 files changed, 1918 insertions(+), 137 deletions(-)
diff --git a/docs/development/extensions-core/catalog.md
b/docs/development/extensions-core/catalog.md
index 20842c927ae..01e6202d59c 100644
--- a/docs/development/extensions-core/catalog.md
+++ b/docs/development/extensions-core/catalog.md
@@ -196,11 +196,68 @@ dropping a projection does not rewrite data.
#### The base table
The reserved projection name `__base` describes the table's own physical
layout rather than an additional
-pre-aggregation. Defining it makes the table a 'clustered' table: rows of
segments are stored grouped by the clustering
-columns.
+pre-aggregation. Its body lists the columns in the order segments store them,
so it must name every declared column.
+The shape of the body chooses the layout: a bare select declares a plain
table, `GROUP BY` declares a
+[rollup](../../ingestion/rollup.md) table, and `CLUSTERED BY` declares a
clustered table. Whatever the layout, a
+`__base` body cannot filter as the base table represents every ingested row.
-Its body lists the columns in the order segments store them, so it must name
every declared column, in declared
-order. An item written as `<expr> AS <name>` makes that column computed at
ingest time, from the columns it reads:
+##### Plain tables
+
+By default, a table stores rows as they arrive, sorted by `__time`. Declaring
a plain `__base` projection makes the
+declared column order the segment sort order, and allows declaring a query
granularity. The only expression it
+accepts is `TIME_FLOOR(__time, <period>)` selected as `__time`, which declares
the table's query granularity (the
+granularity ingested timestamps are floored to). A query granularity must be a
period granularity in the UTC time zone,
+matching the UTC-stored `__time` column:
+
+```sql
+CREATE TABLE "druid"."visits" (
+ user_id VARCHAR,
+ __time TIMESTAMP,
+ pages_visited BIGINT,
+ PROJECTION __base AS (
+ SELECT user_id, TIME_FLOOR(__time, 'PT1H') AS __time, pages_visited
+ )
+)
+PARTITIONED BY DAY
+```
+
+##### Rollup tables
+
+A rollup table aggregates rows with identical grouping values into a single
row at ingest time, expressed as a
+`GROUP BY` query. The query divides the table's declared columns into grouping
columns and metric columns. A column
+written as an aggregate is a metric column, and every other column is a
grouping column, selected as-is. The time
+column may instead be grouped as `TIME_FLOOR(__time, <period>)`, which floors
rows to that granularity before grouping
+and declares the table's query granularity. Each aggregate is named for the
metric column it fills, and reads that
+same column. In the example below, ingestion supplies `total` values and the
table sums them:
+
+```sql
+CREATE TABLE "druid"."visits" (
+ user_id VARCHAR,
+ __time TIMESTAMP,
+ total BIGINT,
+ PROJECTION __base AS (
+ SELECT user_id, TIME_FLOOR(__time, 'PT1H') AS __time, SUM(total) AS total
+ GROUP BY 1, 2
+ )
+)
+PARTITIONED BY DAY
+```
+
+The metric columns must be declared after the grouping columns, because the
declared order is the physical order. A
+rollup body accepts the same
+[aggregation functions that are supported for rollup at ingestion
time](../../multi-stage-query/concepts.md#rollup)
+and, like an aggregate projection, cannot compute expressions over aggregates.
The body describes a transformation of
+the table's own columns (the query that rolls the table up onto itself) not of
whatever input first produced the
+rows. This is what lets the table aggregate its rows again later, for example
when compaction merges segments. An
+aggregate whose output it cannot re-aggregate, such as `COUNT(*)`, is
rejected: two counts combine by adding, so store
+a count by summing a count column, as in `SUM(cnt) AS cnt`. A body that groups
without any
+aggregates collapses duplicate rows. A rollup table cannot also declare
`CLUSTERED BY`.
+
+##### Clustered tables
+
+A clustered table stores the rows of each segment internally partitioned by
the clustering columns, which must be the
+leading columns of the table. An item written as `<expr> AS <name>` makes that
column computed at ingest time, from the
+columns it reads:
```sql
CREATE TABLE "druid"."events" SEALED (
@@ -217,15 +274,13 @@ CREATE TABLE "druid"."events" SEALED (
PARTITIONED BY DAY
```
-The clustering columns must be the leading columns of the table, because the
declared order is the physical order.
-`SEALED` is optional: a column the query produces but the table does not
declare is stored after the declared
-layout, in the order it arrives. Declare `SEALED` to reject such columns
instead.
-
Computed columns, like `bucket` in the example above, are computed based on
inputs provided by the `INSERT` or
-`REPLACE`. In terms of the example, the `INSERT` or `REPLACE` command should
provide `user_id`, not `bucket`.
+`REPLACE`. In terms of the example, the `INSERT` or `REPLACE` command should
provide `user_id`, not `bucket`. Only a
+clustered table materializes computed columns.
-Unlike an aggregate projection, a `__base` body cannot filter or group: the
base table stores every ingested row.
-It is the only projection that chooses a clustering.
+For every layout, `SEALED` is optional: a column the ingestion query produces
but the table does not declare is
+stored after the declared layout, in the order it arrives (for a rollup table,
as an additional grouping column).
+Declare `SEALED` to reject such columns instead.
`ALTER TABLE ... ADD PROJECTION __base AS ( ... )` gives an existing table a
layout, and
`ALTER TABLE ... DROP PROJECTION __base` removes it. Both affect future
segments only.
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 b280a7a15a0..d20893891e6 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
@@ -456,6 +456,101 @@ public class CatalogDdlAndIngestTest extends
CatalogTestBase
);
}
+ /**
+ * A {@code __base} projection with {@code GROUP BY} declares the rollup
layout: the declared columns split into
+ * grouping columns and (by aggregator name) metric columns, {@code
TIME_FLOOR(__time, <period>)} declares the query
+ * granularity, and rows with identical grouping values after flooring are
aggregated into one at ingest time.
+ */
+ @Test
+ public void testCreateRollupBaseTableThenIngestAndQuery()
+ {
+ final String tableName = dataSource;
+
+ cluster.callApi().runSql(
+ "CREATE TABLE \"%s\" (\n"
+ + " tenant VARCHAR,\n"
+ + " __time TIMESTAMP,\n"
+ + " total BIGINT,\n"
+ + " PROJECTION __base AS (\n"
+ + " SELECT tenant, TIME_FLOOR(__time, 'PT1H') AS __time, SUM(total)
AS total\n"
+ + " GROUP BY 1, 2\n"
+ + " )\n"
+ + ")\n"
+ + "PARTITIONED BY DAY",
+ tableName
+ );
+
+ ingest(
+ "INSERT INTO \"%s\"\n"
+ + "SELECT b AS tenant, TIME_FLOOR(TIME_PARSE(a), 'PT1H') AS __time,
SUM(c) AS total\n"
+ + "FROM TABLE(\n"
+ + " EXTERN(\n"
+ + " '{\"type\":\"inline\",\"data\":\"2022-12-26T05:10:00,bbb,4"
+ +
"\\n2022-12-26T05:20:00,aaa,1\\n2022-12-26T05:40:00,aaa,2\\n2022-12-26T09:10:00,aaa,8\"}',\n"
+ + "
'{\"type\":\"csv\",\"findColumnsFromHeader\":false,\"columns\":[\"a\",\"b\",\"c\"]}'\n"
+ + " )\n"
+ + ") EXTEND (a VARCHAR, b VARCHAR, c BIGINT)\n"
+ + "GROUP BY 1, 2",
+ tableName
+ );
+
+ // The two same-hour 'aaa' rows collapsed into one with total summed; rows
come back in grouping order.
+ cluster.callApi().verifySqlQuery(
+ "SELECT * FROM %s",
+ tableName,
+ "aaa,2022-12-26T05:00:00.000Z,3\n"
+ + "aaa,2022-12-26T09:00:00.000Z,8\n"
+ + "bbb,2022-12-26T05:00:00.000Z,4"
+ );
+ }
+
+ /**
+ * The canonical count workflow for a rollup table: the table cannot declare
{@code COUNT(*)} (an aggregator must
+ * combine its own output), so it declares {@code SUM(cnt) AS cnt} and the
ingestion query computes the raw count —
+ * {@code COUNT(*) AS cnt} — which the table then sums on any re-aggregation.
+ */
+ @Test
+ public void testCreateRollupBaseTableThenIngestCounts()
+ {
+ final String tableName = dataSource;
+
+ cluster.callApi().runSql(
+ "CREATE TABLE \"%s\" (\n"
+ + " tenant VARCHAR,\n"
+ + " __time TIMESTAMP,\n"
+ + " cnt BIGINT,\n"
+ + " PROJECTION __base AS (\n"
+ + " SELECT tenant, TIME_FLOOR(__time, 'PT1H') AS __time, SUM(cnt)
AS cnt\n"
+ + " GROUP BY 1, 2\n"
+ + " )\n"
+ + ")\n"
+ + "PARTITIONED BY DAY",
+ tableName
+ );
+
+ ingest(
+ "INSERT INTO \"%s\"\n"
+ + "SELECT b AS tenant, TIME_FLOOR(TIME_PARSE(a), 'PT1H') AS __time,
COUNT(*) AS cnt\n"
+ + "FROM TABLE(\n"
+ + " EXTERN(\n"
+ + " '{\"type\":\"inline\",\"data\":\"2022-12-26T05:10:00,bbb"
+ +
"\\n2022-12-26T05:20:00,aaa\\n2022-12-26T05:40:00,aaa\\n2022-12-26T09:10:00,aaa\"}',\n"
+ + "
'{\"type\":\"csv\",\"findColumnsFromHeader\":false,\"columns\":[\"a\",\"b\"]}'\n"
+ + " )\n"
+ + ") EXTEND (a VARCHAR, b VARCHAR)\n"
+ + "GROUP BY 1, 2",
+ tableName
+ );
+
+ cluster.callApi().verifySqlQuery(
+ "SELECT * FROM %s",
+ tableName,
+ "aaa,2022-12-26T05:00:00.000Z,2\n"
+ + "aaa,2022-12-26T09:00:00.000Z,1\n"
+ + "bbb,2022-12-26T05:00:00.000Z,1"
+ );
+ }
+
/**
* 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
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 2b63fde274a..617dc30dac6 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
@@ -20,6 +20,7 @@
package org.apache.druid.testing.embedded.indexing;
import org.apache.druid.data.input.impl.LongDimensionSchema;
+import org.apache.druid.data.input.impl.RollupTableProjectionSpec;
import org.apache.druid.data.input.impl.StringDimensionSchema;
import org.apache.druid.data.input.impl.TableProjectionSpec;
import org.apache.druid.indexer.granularity.SegmentGranularitySpec;
@@ -29,6 +30,7 @@ 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.query.aggregation.LongSumAggregatorFactory;
import org.apache.druid.testing.embedded.EmbeddedBroker;
import org.apache.druid.testing.embedded.EmbeddedClusterApis;
import org.apache.druid.testing.embedded.EmbeddedCoordinator;
@@ -162,6 +164,56 @@ public class IndexTaskTest extends EmbeddedClusterTestBase
);
}
+ /**
+ * Batch ingestion driven by a {@link RollupTableProjectionSpec} base table:
the declared columns are the grouping
+ * columns (item before __time — declared order is the sort order), the
query-granularity carrier floors
+ * {@code __time} to the hour, and rows with identical grouping values after
flooring are aggregated into one by the
+ * spec's aggregators.
+ */
+ @Test
+ @Timeout(60)
+ public void test_runIndexTask_withRollupBaseTableSpec()
+ {
+ final String taskId = EmbeddedClusterApis.newTaskId(dataSource);
+ final RollupTableProjectionSpec baseTable = RollupTableProjectionSpec
+ .builder()
+ .groupingColumns(
+ new StringDimensionSchema("item"),
+ new LongDimensionSchema("__time")
+ )
+ .aggregators(new LongSumAggregatorFactory("total", "total"))
+ .build()
+ .withQueryGranularity(Granularities.HOUR);
+ final IndexTask task = TaskBuilder
+ .ofTypeIndex()
+ .isoTimestampColumn("time")
+ .csvInputFormatWithColumns("time", "item", "total")
+ .inlineInputSourceWithData(
+ "2025-06-01T05:10:00.000Z,apple,1"
+ + "\n2025-06-01T05:20:00.000Z,apple,2"
+ + "\n2025-06-01T05:30:00.000Z,banana,4"
+ + "\n2025-06-01T09:10:00.000Z,apple,8"
+ )
+ .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);
+
+ // The two 5-o'clock apple rows collapsed into one with total summed; scan
order is the declared grouping order.
+ Assertions.assertEquals(
+ "apple,2025-06-01T05:00:00.000Z,3"
+ + "\napple,2025-06-01T09:00:00.000Z,8"
+ + "\nbanana,2025-06-01T05:00:00.000Z,4",
+ cluster.runSql("SELECT item, __time, total FROM %s", dataSource)
+ );
+ }
+
@Test
@Timeout(20)
public void test_run10Tasks_concurrently()
diff --git
a/processing/src/main/java/org/apache/druid/data/input/impl/AdaptedBaseTableProjectionSpec.java
b/processing/src/main/java/org/apache/druid/data/input/impl/AdaptedBaseTableProjectionSpec.java
index 2828d2cda53..f2f47492a12 100644
---
a/processing/src/main/java/org/apache/druid/data/input/impl/AdaptedBaseTableProjectionSpec.java
+++
b/processing/src/main/java/org/apache/druid/data/input/impl/AdaptedBaseTableProjectionSpec.java
@@ -112,6 +112,12 @@ public final class AdaptedBaseTableProjectionSpec
implements BaseTableProjection
return granularitySpec.getQueryGranularity();
}
+ @Override
+ public boolean isRollup()
+ {
+ return granularitySpec.isRollup();
+ }
+
@Override
public BaseTableProjectionSpec withQueryGranularity(@Nullable Granularity
queryGranularity)
{
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 e9d42175e4c..6f488bd69ed 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,6 +21,7 @@ package org.apache.druid.data.input.impl;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
+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;
@@ -32,8 +33,10 @@ import org.apache.druid.segment.column.ColumnHolder;
import org.apache.druid.segment.projections.BaseTableProjectionSchema;
import javax.annotation.Nullable;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Set;
/**
* Spec describing the shape of the 'base' table schema for a {@link
org.apache.druid.segment.indexing.DataSchema}. This
@@ -51,6 +54,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 = RollupTableProjectionSpec.TYPE_NAME, value =
RollupTableProjectionSpec.class),
@JsonSubTypes.Type(
name = ClusteredValueGroupsBaseTableProjectionSpec.TYPE_NAME,
value = ClusteredValueGroupsBaseTableProjectionSpec.class
@@ -80,6 +84,14 @@ public interface BaseTableProjectionSpec
return granularity == null ? Granularities.NONE : granularity;
}
+ /**
+ * Returns true if rows with identical grouping values are aggregated into
one at ingest time.
+ */
+ default boolean isRollup()
+ {
+ return false;
+ }
+
/**
* Returns a copy of this spec with the given query granularity applied
(implementation-defined representation). Used
* by the ingestion and compaction config paths to attach the query-derived
granularity to the operator-supplied spec.
@@ -146,4 +158,87 @@ public interface BaseTableProjectionSpec
}
validateQueryGranularity(granularity, typeName);
}
+
+ /**
+ * Validates a declared column list: non-empty, unique names, an explicit
{@link ColumnHolder#TIME_COLUMN_NAME} entry
+ * to define the time position, and no entry named for the query-granularity
carrier (which is a virtual column, not
+ * a stored column).
+ */
+ static void validateDeclaredColumns(
+ @Nullable List<DimensionSchema> columns,
+ String columnsProperty,
+ String typeName
+ )
+ {
+ if (columns == null || columns.isEmpty()) {
+ throw InvalidInput.exception("'%s' must be non-empty for [%s] base
table", columnsProperty, typeName);
+ }
+ final Set<String> seen = Sets.newHashSetWithExpectedSize(columns.size());
+ for (DimensionSchema d : columns) {
+ if (!seen.add(d.getName())) {
+ throw InvalidInput.exception("'%s' contains duplicate name [%s]",
columnsProperty, 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
as one.
+ 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 '%s'",
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME,
+ ColumnHolder.TIME_COLUMN_NAME,
+ columnsProperty
+ );
+ }
+ if (ColumnHolder.TIME_COLUMN_NAME.equals(name)) {
+ foundTime = true;
+ }
+ }
+ if (!foundTime) {
+ throw InvalidInput.exception(
+ "[%s] base table must include [%s] in '%s' to define the time
position",
+ typeName,
+ ColumnHolder.TIME_COLUMN_NAME,
+ columnsProperty
+ );
+ }
+ }
+
+ /**
+ * Validates that {@code virtualColumns} holds nothing but the {@link
Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME}
+ * carrier, for the layouts whose write path does not evaluate spec virtual
columns (any other virtual column would
+ * be dead metadata whose column never gets materialized; computed columns
belong in a {@code transformSpec}). The
+ * carrier itself is validated by {@link #validateGranularity}.
+ */
+ static void validateGranularityOnlyVirtualColumns(VirtualColumns
virtualColumns, String typeName)
+ {
+ 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(),
+ typeName,
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME
+ );
+ }
+ validateGranularity(virtualColumn, typeName);
+ }
+ }
+
+ /**
+ * Convert a list of {@link DimensionSchema} into ascending {@link OrderBy},
preserving declared order.
+ */
+ static List<OrderBy> declaredOrderAscending(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);
+ }
}
diff --git
a/processing/src/main/java/org/apache/druid/data/input/impl/RollupTableProjectionSpec.java
b/processing/src/main/java/org/apache/druid/data/input/impl/RollupTableProjectionSpec.java
new file mode 100644
index 00000000000..a5a2b81d5f8
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/data/input/impl/RollupTableProjectionSpec.java
@@ -0,0 +1,354 @@
+/*
+ * 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 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.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * {@link BaseTableProjectionSpec} for a rollup table: the plain {@link
TableProjectionSpec} shape plus
+ * {@link #aggregators}. The declared {@link #groupingColumns} are the
grouping columns, rows with identical grouping values
+ * (after query-granularity flooring of {@code __time}) are aggregated into
one at ingest time, with each aggregator
+ * producing a metric column after the grouping columns. As in the plain
layout, the time position is an explicit
+ * positional entry in {@link #groupingColumns} named {@code __time}, declared
column order is the segment storage and sort
+ * order, and query granularity rides as the {@link
Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME} carrier virtual
+ * column (the only virtual column accepted).
+ * <p>
+ * Operator-facing counterpart of the segment metadata-side
+ * {@link org.apache.druid.segment.projections.RollupTableProjectionSchema}.
+ * <p>
+ * Aggregators may be empty: a rollup table without metrics collapses rows
with identical grouping values, mirroring
+ * the legacy {@code rollup=true} granularity spec without a {@code
metricsSpec}.
+ */
+@JsonTypeName(RollupTableProjectionSpec.TYPE_NAME)
+public final class RollupTableProjectionSpec implements BaseTableProjectionSpec
+{
+ public static final String TYPE_NAME = "rollupTable";
+
+ private final VirtualColumns virtualColumns;
+ private final List<DimensionSchema> groupingColumns;
+ private final AggregatorFactory[] aggregators;
+ private final DimensionsSpec dimensionsSpec;
+ private final List<OrderBy> ordering;
+
+ public static Builder builder()
+ {
+ return new Builder();
+ }
+
+ @JsonCreator
+ public RollupTableProjectionSpec(
+ @JsonProperty("virtualColumns") @Nullable VirtualColumns virtualColumns,
+ @JsonProperty("groupingColumns") List<DimensionSchema> groupingColumns,
+ @JsonProperty("aggregators") @Nullable AggregatorFactory[] aggregators
+ )
+ {
+ BaseTableProjectionSpec.validateDeclaredColumns(groupingColumns,
"groupingColumns", TYPE_NAME);
+ this.virtualColumns = virtualColumns == null ? VirtualColumns.EMPTY :
virtualColumns;
+
BaseTableProjectionSpec.validateGranularityOnlyVirtualColumns(this.virtualColumns,
TYPE_NAME);
+ this.groupingColumns = Collections.unmodifiableList(new
ArrayList<>(groupingColumns));
+ this.aggregators = aggregators == null ? new AggregatorFactory[0] :
aggregators;
+ validateAggregators(this.groupingColumns, this.aggregators);
+ this.dimensionsSpec = DimensionsSpec.builder()
+ .setDimensions(this.groupingColumns)
+ .setForceSegmentSortByTime(false)
+ .build();
+ this.ordering =
BaseTableProjectionSpec.declaredOrderAscending(this.groupingColumns);
+ }
+
+ @Override
+ @JsonProperty
+ @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+ public VirtualColumns getVirtualColumns()
+ {
+ return virtualColumns;
+ }
+
+ /**
+ * The full, ordered list of grouping columns in segment order, with the
explicit {@code __time} (or
+ * query-granularity) marker at its declared position.
+ */
+ @JsonProperty("groupingColumns")
+ public List<DimensionSchema> getGroupingColumns()
+ {
+ return groupingColumns;
+ }
+
+ /**
+ * The aggregators computed over the rows collapsed into each grouping
tuple, each producing a metric column stored
+ * after the grouping columns.
+ */
+ @Override
+ @JsonProperty("aggregators")
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ public AggregatorFactory[] getMetrics()
+ {
+ return aggregators;
+ }
+
+ @Override
+ @JsonIgnore
+ public List<OrderBy> getOrdering()
+ {
+ return ordering;
+ }
+
+ @Override
+ @JsonIgnore
+ public DimensionsSpec getDimensionsSpec()
+ {
+ return dimensionsSpec;
+ }
+
+ @Override
+ public boolean isRollup()
+ {
+ return true;
+ }
+
+ /**
+ * 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 RollupTableProjectionSpec 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 RollupTableProjectionSpec(VirtualColumns.create(merged),
groupingColumns, aggregators);
+ }
+
+ /**
+ * Compares rollup-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 grouping columns, the
+ * aggregators, 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 RollupTableProjectionSpec)) {
+ return false;
+ }
+ return withoutQueryGranularity().equals(((RollupTableProjectionSpec)
other).withoutQueryGranularity());
+ }
+
+ @Override
+ public RollupTableProjectionSpec withAdditionalColumns(@Nullable
List<DimensionSchema> additionalColumns)
+ {
+ if (additionalColumns == null || additionalColumns.isEmpty()) {
+ return this;
+ }
+ final List<DimensionSchema> revised = new
ArrayList<>(groupingColumns.size() + additionalColumns.size());
+ revised.addAll(groupingColumns);
+ 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 grouping column list",
+ ColumnHolder.TIME_COLUMN_NAME,
+ TYPE_NAME
+ );
+ }
+ revised.add(additionalColumn);
+ }
+ // Duplicates of a declared column or aggregator, and a column named for
the query-granularity carrier, are
+ // rejected by the constructor's validation.
+ return new RollupTableProjectionSpec(virtualColumns, revised, aggregators);
+ }
+
+ /**
+ * 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 RollupTableProjectionSpec 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 RollupTableProjectionSpec(VirtualColumns.create(remaining),
groupingColumns, aggregators);
+ }
+
+ /**
+ * Aggregators produce the metric columns, so their names share a namespace
with the grouping columns — a collision
+ * means one column with two definitions — and with the query-granularity
carrier: virtual columns shadow stored
+ * columns when selectors resolve, so a metric named for the carrier would
be unreadable once a query granularity is
+ * attached.
+ */
+ private static void validateAggregators(List<DimensionSchema>
groupingColumns, AggregatorFactory[] aggregators)
+ {
+ final Set<String> names = new HashSet<>();
+ for (DimensionSchema column : groupingColumns) {
+ names.add(column.getName());
+ }
+ for (AggregatorFactory aggregator : aggregators) {
+ if (aggregator == null) {
+ throw InvalidInput.exception("aggregators must not contain null
entries");
+ }
+ if
(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME.equals(aggregator.getName())) {
+ throw InvalidInput.exception(
+ "aggregator cannot be named [%s]; it is the query-granularity
virtual column, not a metric column",
+ Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME
+ );
+ }
+ if (!names.add(aggregator.getName())) {
+ throw InvalidInput.exception(
+ "aggregator [%s] duplicates the name of a column or another
aggregator",
+ aggregator.getName()
+ );
+ }
+ // The spec's aggregators are applied uniformly by every consumer:
ingestion combines whatever arrives under the
+ // metric column's name, and compaction re-aggregates the rows the table
has already stored. Both are only
+ // correct for an aggregator that combines its own output, so anything
else (a COUNT, a sketch build, an input
+ // field that differs from the output) is rejected rather than silently
changing results on re-aggregation.
+ final AggregatorFactory combining =
aggregator.getCombiningFactory().withName(aggregator.getName());
+ if (!aggregator.equals(combining)) {
+ throw InvalidInput.exception(
+ "aggregator [%s] is not its own combining form: a rollup table
re-aggregates the rows it has stored, so"
+ + " its aggregators must combine their own output. Declare the
combining form instead, for example [%s]",
+ aggregator.getName(),
+ combining
+ );
+ }
+ }
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ RollupTableProjectionSpec that = (RollupTableProjectionSpec) o;
+ return Objects.equals(virtualColumns, that.virtualColumns)
+ && Objects.equals(groupingColumns, that.groupingColumns)
+ && Arrays.equals(aggregators, that.aggregators);
+ }
+
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(virtualColumns, groupingColumns,
Arrays.hashCode(aggregators));
+ }
+
+ @Override
+ public String toString()
+ {
+ return "RollupTableProjectionSpec{" +
+ "virtualColumns=" + virtualColumns +
+ ", groupingColumns=" + groupingColumns +
+ ", aggregators=" + Arrays.toString(aggregators) +
+ '}';
+ }
+
+ /**
+ * Fluent builder for {@link RollupTableProjectionSpec}, avoiding the
constructor's positional nullable leading
+ * {@code virtualColumns} argument. {@link #groupingColumns} (the full
ordered grouping column list) is required.
+ */
+ public static final class Builder
+ {
+ @Nullable
+ private VirtualColumns virtualColumns;
+ private List<DimensionSchema> groupingColumns = Collections.emptyList();
+ @Nullable
+ private AggregatorFactory[] aggregators;
+
+ public Builder virtualColumns(@Nullable VirtualColumns virtualColumns)
+ {
+ this.virtualColumns = virtualColumns;
+ return this;
+ }
+
+ /**
+ * The full, ordered list of grouping columns in segment order, including
the explicit time marker.
+ */
+ public Builder groupingColumns(List<DimensionSchema> groupingColumns)
+ {
+ this.groupingColumns = groupingColumns;
+ return this;
+ }
+
+ public Builder groupingColumns(DimensionSchema... groupingColumns)
+ {
+ return groupingColumns(Arrays.asList(groupingColumns));
+ }
+
+ public Builder aggregators(AggregatorFactory... aggregators)
+ {
+ this.aggregators = aggregators;
+ return this;
+ }
+
+ public RollupTableProjectionSpec build()
+ {
+ return new RollupTableProjectionSpec(virtualColumns, groupingColumns,
aggregators);
+ }
+ }
+}
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
index 18545e45736..5f36f13eca3 100644
---
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
@@ -24,7 +24,6 @@ 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;
@@ -40,7 +39,6 @@ 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
@@ -86,15 +84,15 @@ public final class TableProjectionSpec implements
BaseTableProjectionSpec
@JsonProperty("columns") List<DimensionSchema> columns
)
{
- validate(columns);
+ BaseTableProjectionSpec.validateDeclaredColumns(columns, "columns",
TYPE_NAME);
this.virtualColumns = virtualColumns == null ? VirtualColumns.EMPTY :
virtualColumns;
- validateVirtualColumns(this.virtualColumns);
+
BaseTableProjectionSpec.validateGranularityOnlyVirtualColumns(this.virtualColumns,
TYPE_NAME);
this.columns = Collections.unmodifiableList(new ArrayList<>(columns));
this.dimensionsSpec = DimensionsSpec.builder()
.setDimensions(this.columns)
.setForceSegmentSortByTime(false)
.build();
- this.ordering = computeOrdering(this.columns);
+ this.ordering =
BaseTableProjectionSpec.declaredOrderAscending(this.columns);
}
@Override
@@ -225,74 +223,6 @@ public final class TableProjectionSpec implements
BaseTableProjectionSpec
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)
{
diff --git
a/processing/src/test/java/org/apache/druid/data/input/impl/RollupTableProjectionSpecTest.java
b/processing/src/test/java/org/apache/druid/data/input/impl/RollupTableProjectionSpecTest.java
new file mode 100644
index 00000000000..e37876719eb
--- /dev/null
+++
b/processing/src/test/java/org/apache/druid/data/input/impl/RollupTableProjectionSpecTest.java
@@ -0,0 +1,301 @@
+/*
+ * 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.Granularities;
+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.aggregation.CountAggregatorFactory;
+import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
+import org.apache.druid.segment.TestHelper;
+import org.apache.druid.segment.VirtualColumn;
+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;
+
+/**
+ * The column and granularity-carrier rules shared with {@link
TableProjectionSpec} live in
+ * {@link BaseTableProjectionSpec} statics and are covered exhaustively in
{@link TableProjectionSpecTest}; this covers
+ * what the rollup layout adds — the aggregators, and advertising rollup.
+ */
+class RollupTableProjectionSpecTest extends InitializedNullHandlingTest
+{
+ private static RollupTableProjectionSpec pagesSpec()
+ {
+ return RollupTableProjectionSpec.builder()
+ .groupingColumns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .aggregators(new LongSumAggregatorFactory("total", "total"), new
LongSumAggregatorFactory("rows", "rows"))
+ .build();
+ }
+
+ @Test
+ void testShapeFollowsDeclaredOrderAndCarriesAggregators()
+ {
+ final RollupTableProjectionSpec spec = pagesSpec();
+
+ // Declared grouping order is both storage and sort order, with __time at
its declared position; the aggregators
+ // become the metric columns and advertise the rollup layout.
+ Assertions.assertEquals(
+ ImmutableList.of(OrderBy.ascending("page"),
OrderBy.ascending("__time")),
+ spec.getOrdering()
+ );
+ Assertions.assertEquals(spec.getGroupingColumns(),
spec.getDimensionsSpec().getDimensions());
+
Assertions.assertFalse(spec.getDimensionsSpec().isForceSegmentSortByTime());
+ Assertions.assertArrayEquals(
+ new AggregatorFactory[]{new LongSumAggregatorFactory("total",
"total"), new LongSumAggregatorFactory("rows", "rows")},
+ spec.getMetrics()
+ );
+ Assertions.assertTrue(spec.isRollup());
+ }
+
+ @Test
+ void testEmptyAggregatorsAllowed()
+ {
+ // A rollup table without metrics collapses rows with identical grouping
values, mirroring the legacy rollup=true
+ // granularity spec without a metricsSpec.
+ final RollupTableProjectionSpec spec = RollupTableProjectionSpec.builder()
+ .groupingColumns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .build();
+ Assertions.assertEquals(0, spec.getMetrics().length);
+ Assertions.assertTrue(spec.isRollup());
+ }
+
+ @Test
+ void testAggregatorNameCollidingWithColumnRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> RollupTableProjectionSpec.builder()
+ .groupingColumns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .aggregators(new LongSumAggregatorFactory("page", "cnt"))
+ .build()
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("aggregator [page] duplicates the name of a
column or another aggregator"),
+ e.getMessage()
+ );
+ }
+
+ @Test
+ void testAggregatorNameCollidingWithAggregatorRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> RollupTableProjectionSpec.builder()
+ .groupingColumns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .aggregators(new LongSumAggregatorFactory("total", "total"), new
CountAggregatorFactory("total"))
+ .build()
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("aggregator [total] duplicates the name of a
column or another aggregator"),
+ e.getMessage()
+ );
+ }
+
+ /**
+ * A metric named for the query-granularity carrier would be shadowed by the
carrier virtual column once a query
+ * granularity is attached (virtual columns resolve before stored columns),
so it is rejected at construction.
+ */
+ @Test
+ void testAggregatorNamedForGranularityCarrierRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> RollupTableProjectionSpec.builder()
+ .groupingColumns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .aggregators(new
LongSumAggregatorFactory(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME, "cnt"))
+ .build()
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains(
+ "aggregator cannot be named [" +
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME + "]"
+ ),
+ e.getMessage()
+ );
+ }
+
+ /**
+ * The spec's aggregators are applied uniformly: ingestion combines what
arrives under the metric column's name, and
+ * compaction re-aggregates stored rows. An aggregator that is not its own
combining form (a COUNT, a sketch build,
+ * an input field differing from the output) would silently change results
on re-aggregation, so it is rejected — a
+ * count is stored by summing a count column.
+ */
+ @Test
+ void testNonSelfCombiningAggregatorRejected()
+ {
+ final DruidException count = Assertions.assertThrows(
+ DruidException.class,
+ () -> RollupTableProjectionSpec.builder()
+ .groupingColumns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .aggregators(new CountAggregatorFactory("rows"))
+ .build()
+ );
+ Assertions.assertTrue(count.getMessage().contains("aggregator [rows] is
not its own combining form"), count.getMessage());
+
+ final DruidException differentInput = Assertions.assertThrows(
+ DruidException.class,
+ () -> RollupTableProjectionSpec.builder()
+ .groupingColumns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .aggregators(new LongSumAggregatorFactory("total", "cnt"))
+ .build()
+ );
+ Assertions.assertTrue(
+ differentInput.getMessage().contains("aggregator [total] is not its
own combining form"),
+ differentInput.getMessage()
+ );
+ }
+
+ @Test
+ void testNullAggregatorEntryRejected()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> RollupTableProjectionSpec.builder()
+ .groupingColumns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .aggregators(new LongSumAggregatorFactory("total", "total"), null)
+ .build()
+ );
+ Assertions.assertTrue(e.getMessage().contains("aggregators must not
contain null entries"), e.getMessage());
+ }
+
+ @Test
+ void testWithQueryGranularityAddsCarrierAndKeepsAggregators()
+ {
+ final RollupTableProjectionSpec spec =
pagesSpec().withQueryGranularity(Granularities.HOUR);
+
+ final VirtualColumn vc =
spec.getVirtualColumns().getVirtualColumn(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME);
+ Assertions.assertNotNull(vc);
+ Assertions.assertEquals(Granularities.HOUR, spec.getQueryGranularity());
+ Assertions.assertEquals(pagesSpec().getGroupingColumns(),
spec.getGroupingColumns());
+ Assertions.assertArrayEquals(pagesSpec().getMetrics(), spec.getMetrics());
+
+ // Idempotent once a carrier is present.
+ Assertions.assertSame(spec, spec.withQueryGranularity(Granularities.DAY));
+ }
+
+ /**
+ * The granularity rules are the shared {@link BaseTableProjectionSpec}
validators, exercised exhaustively in
+ * {@link TableProjectionSpecTest}; this just proves the rollup spec is
wired to them.
+ */
+ @Test
+ void testWithQueryGranularityRejectionsAreWired()
+ {
+ Assertions.assertTrue(
+ Assertions.assertThrows(DruidException.class, () ->
pagesSpec().withQueryGranularity(Granularities.ALL))
+ .getMessage()
+ .contains("ALL")
+ );
+ Assertions.assertTrue(
+ Assertions.assertThrows(
+ DruidException.class,
+ () -> pagesSpec().withQueryGranularity(
+ new PeriodGranularity(new Period("P1D"), null,
DateTimes.inferTzFromString("America/Los_Angeles"))
+ )
+ ).getMessage().contains("only period granularities in the UTC time
zone")
+ );
+ }
+
+ @Test
+ void
testHasEqualCompactionStateIgnoresQueryGranularityAndComparesAggregators()
+ {
+
Assertions.assertTrue(pagesSpec().withQueryGranularity(Granularities.HOUR).hasEqualCompactionState(pagesSpec()));
+
Assertions.assertTrue(pagesSpec().hasEqualCompactionState(pagesSpec().withQueryGranularity(Granularities.HOUR)));
+
+ final RollupTableProjectionSpec differentAggregators =
RollupTableProjectionSpec.builder()
+ .groupingColumns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .aggregators(new LongSumAggregatorFactory("total", "total"))
+ .build();
+
Assertions.assertFalse(pagesSpec().hasEqualCompactionState(differentAggregators));
+ }
+
+ @Test
+ void testHasEqualCompactionStateRejectsDifferentSpecType()
+ {
+ // A plain table over the same columns is a different layout: it stores
every row, a rollup table aggregates them.
+ final TableProjectionSpec plain = TableProjectionSpec.builder()
+ .columns(new StringDimensionSchema("page"), new
LongDimensionSchema("__time"))
+ .build();
+ Assertions.assertFalse(pagesSpec().hasEqualCompactionState(plain));
+ Assertions.assertFalse(plain.hasEqualCompactionState(pagesSpec()));
+ }
+
+ @Test
+ void testWithAdditionalColumnsAppendsGroupingColumnsAndKeepsAggregators()
+ {
+ final RollupTableProjectionSpec spec = pagesSpec()
+ .withQueryGranularity(Granularities.HOUR)
+ .withAdditionalColumns(ImmutableList.of(new
StringDimensionSchema("city")));
+
+ Assertions.assertEquals(
+ ImmutableList.of("page", "__time", "city"),
+
spec.getGroupingColumns().stream().map(DimensionSchema::getName).collect(Collectors.toList())
+ );
+ Assertions.assertArrayEquals(pagesSpec().getMetrics(), spec.getMetrics());
+ Assertions.assertEquals(Granularities.HOUR, spec.getQueryGranularity());
+
+ Assertions.assertSame(spec, spec.withAdditionalColumns(null));
+ Assertions.assertSame(spec,
spec.withAdditionalColumns(Collections.emptyList()));
+ }
+
+ @Test
+ void testWithAdditionalColumnsRejectsTimeColumnAndAggregatorNames()
+ {
+ Assertions.assertTrue(
+ Assertions.assertThrows(
+ DruidException.class,
+ () -> pagesSpec().withAdditionalColumns(ImmutableList.of(new
LongDimensionSchema("__time")))
+ ).getMessage().contains("[__time]")
+ );
+ // An appended column colliding with an aggregator would be one column
with two definitions; the constructor's
+ // aggregator validation rejects it.
+ Assertions.assertTrue(
+ Assertions.assertThrows(
+ DruidException.class,
+ () -> pagesSpec().withAdditionalColumns(ImmutableList.of(new
LongDimensionSchema("total")))
+ ).getMessage().contains("aggregator [total] duplicates the name of a
column or another aggregator")
+ );
+ }
+
+ @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\":\"rollupTable\""),
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/server/src/main/java/org/apache/druid/catalog/model/DatasourceBaseTableMetadata.java
b/server/src/main/java/org/apache/druid/catalog/model/DatasourceBaseTableMetadata.java
index 17b2de20020..8587eae9741 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
@@ -37,6 +37,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 = RollupTableBaseTableMetadata.TYPE_NAME, value =
RollupTableBaseTableMetadata.class),
@JsonSubTypes.Type(
name = ClusteredValueGroupsBaseTableMetadata.TYPE_NAME,
value = ClusteredValueGroupsBaseTableMetadata.class
diff --git
a/server/src/main/java/org/apache/druid/catalog/model/RollupTableBaseTableMetadata.java
b/server/src/main/java/org/apache/druid/catalog/model/RollupTableBaseTableMetadata.java
new file mode 100644
index 00000000000..e7c05fb9184
--- /dev/null
+++
b/server/src/main/java/org/apache/druid/catalog/model/RollupTableBaseTableMetadata.java
@@ -0,0 +1,232 @@
+/*
+ * 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.RollupTableProjectionSpec;
+import org.apache.druid.error.InvalidInput;
+import org.apache.druid.query.aggregation.AggregatorFactory;
+import org.apache.druid.segment.VirtualColumns;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.utils.CollectionUtils;
+
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Catalog layout metadata for {@link RollupTableProjectionSpec} base tables.
The catalog column list declares the
+ * output schema of the rollup: the columns named by an entry of {@link
#aggregators} are the metric columns, every
+ * other declared column is a grouping column, and, mirroring the physical
layout, the grouping columns must be
+ * declared before the metric columns. {@link #createSpec(List)} combines the
declared columns with this metadata into
+ * the physical spec used to generate segments; as with the other layouts, the
optional {@link #virtualColumns} carry
+ * only the query granularity, and the optional {@link #columnSchemas}
customize the {@link DimensionSchema} used for a
+ * declared grouping column (a metric column's physical representation is
fixed by its aggregator).
+ */
+@JsonTypeName(RollupTableBaseTableMetadata.TYPE_NAME)
+public class RollupTableBaseTableMetadata implements
DatasourceBaseTableMetadata
+{
+ public static final String TYPE_NAME = RollupTableProjectionSpec.TYPE_NAME;
+
+ private final VirtualColumns virtualColumns;
+ private final AggregatorFactory[] aggregators;
+ private final List<DimensionSchema> columnSchemas;
+
+ @JsonCreator
+ public RollupTableBaseTableMetadata(
+ @JsonProperty("virtualColumns") @Nullable VirtualColumns virtualColumns,
+ @JsonProperty("aggregators") @Nullable AggregatorFactory[] aggregators,
+ @JsonProperty("columnSchemas") @Nullable List<DimensionSchema>
columnSchemas
+ )
+ {
+ this.virtualColumns = virtualColumns == null ? VirtualColumns.EMPTY :
virtualColumns;
+ this.aggregators = aggregators == null ? new AggregatorFactory[0] :
aggregators;
+ 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;
+ }
+
+ /**
+ * The aggregators computed over the rows collapsed into each grouping
tuple. Each names the declared metric column
+ * it fills.
+ */
+ @JsonProperty("aggregators")
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ public AggregatorFactory[] getAggregators()
+ {
+ return aggregators;
+ }
+
+ /**
+ * Per-column customizations of the {@link DimensionSchema} used during
segment creation, keyed by
+ * {@link DimensionSchema#getName()}; empty when every declared grouping
column uses the schema derived from its
+ * declared type. These do not define columns: every entry must customize a
declared grouping column.
+ */
+ @JsonProperty("columnSchemas")
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ public List<DimensionSchema> getColumnSchemas()
+ {
+ return columnSchemas;
+ }
+
+ /**
+ * Creates the physical spec from the declared catalog columns: a declared
column named by an aggregator is a metric
+ * column, whose declared type must match what its aggregator produces;
every other declared column is a grouping
+ * column, derived like the other layouts derive theirs. Declared column
order is the physical segment order, so the
+ * grouping columns must be declared before the metric columns; the metric
columns' declared order becomes the
+ * aggregator order of the spec.
+ */
+ @Override
+ public RollupTableProjectionSpec 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, AggregatorFactory> aggregatorsByName = new HashMap<>();
+ for (AggregatorFactory aggregator : aggregators) {
+ if (aggregator == null) {
+ throw InvalidInput.exception("aggregators must not contain null
entries");
+ }
+ if (aggregatorsByName.put(aggregator.getName(), aggregator) != null) {
+ throw InvalidInput.exception("aggregators contains duplicate entries
for column [%s]", aggregator.getName());
+ }
+ }
+ final Map<String, DimensionSchema> customSchemas =
BaseTableColumns.indexColumnSchemas(columnSchemas);
+
+ final Set<String> declaredNames = new HashSet<>();
+ final List<DimensionSchema> groupingColumns = new
ArrayList<>(columns.size());
+ final List<AggregatorFactory> orderedAggregators = new
ArrayList<>(aggregators.length);
+ for (ColumnSpec column : columns) {
+ declaredNames.add(column.name());
+ final AggregatorFactory aggregator =
aggregatorsByName.get(column.name());
+ if (aggregator == null) {
+ if (!orderedAggregators.isEmpty()) {
+ throw InvalidInput.exception(
+ "grouping column [%s] is declared after metric column [%s]; the
declared order is the physical segment"
+ + " order, so grouping columns must be declared before the
metric columns",
+ column.name(),
+ orderedAggregators.get(orderedAggregators.size() - 1).getName()
+ );
+ }
+ groupingColumns.add(BaseTableColumns.toDimensionSchema(column,
customSchemas.get(column.name()), TYPE_NAME));
+ } else {
+ final ColumnType declaredType = Columns.druidType(column);
+ final ColumnType producedType = aggregator.getIntermediateType();
+ if (declaredType == null || !declaredType.equals(producedType)) {
+ throw InvalidInput.exception(
+ "metric column [%s] is declared as type [%s], but its aggregator
produces [%s]; the declared type of a"
+ + " metric column must match what its aggregator stores",
+ column.name(),
+ column.dataType(),
+ producedType
+ );
+ }
+ if (customSchemas.containsKey(column.name())) {
+ throw InvalidInput.exception(
+ "columnSchemas cannot customize metric column [%s]: the physical
representation of a metric column is"
+ + " fixed by its aggregator",
+ column.name()
+ );
+ }
+ orderedAggregators.add(aggregator);
+ }
+ }
+ for (String aggregatorName : aggregatorsByName.keySet()) {
+ if (!declaredNames.contains(aggregatorName)) {
+ throw InvalidInput.exception(
+ "aggregator [%s] does not fill a declared column; every aggregator
produces a metric column, so declare"
+ + " [%s] in the table's column list",
+ aggregatorName,
+ aggregatorName
+ );
+ }
+ }
+ 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 RollupTableProjectionSpec.builder()
+ .virtualColumns(virtualColumns)
+ .groupingColumns(groupingColumns)
+
.aggregators(orderedAggregators.toArray(new AggregatorFactory[0]))
+ .build();
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ RollupTableBaseTableMetadata that = (RollupTableBaseTableMetadata) o;
+ return Objects.equals(virtualColumns, that.virtualColumns)
+ && Arrays.equals(aggregators, that.aggregators)
+ && Objects.equals(columnSchemas, that.columnSchemas);
+ }
+
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(virtualColumns, Arrays.hashCode(aggregators),
columnSchemas);
+ }
+
+ @Override
+ public String toString()
+ {
+ return "RollupTableBaseTableMetadata{" +
+ "virtualColumns=" + virtualColumns +
+ ", aggregators=" + Arrays.toString(aggregators) +
+ ", 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 51051337a3a..f2f70bbc379 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
@@ -217,7 +217,8 @@ public class DataSchema
* {@link GranularitySpec}, which is already the complete source of truth
(segment + query granularity + rollup), so
* it is returned as-is. In baseTable mode the pieces live apart and are
recombined: segment granularity + intervals
* from {@code segmentGranularitySpec} (or defaults), query granularity from
the spec's
- * {@link Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME} virtual column
(absent ⇒ {@code NONE}).
+ * {@link Granularities#GRANULARITY_VIRTUAL_COLUMN_NAME} virtual column
(absent ⇒ {@code NONE}), and rollup from the
+ * spec's layout type ({@link BaseTableProjectionSpec#isRollup()}).
*/
private static GranularitySpec computeEffectiveGranularitySpec(
BaseTableProjectionSpec effectiveSpec,
@@ -234,7 +235,7 @@ public class DataSchema
return new UniformGranularitySpec(
segmentGranularity,
queryGranularityFromSpec(effectiveSpec),
- false,
+ effectiveSpec.isRollup(),
intervals
);
}
diff --git
a/server/src/test/java/org/apache/druid/catalog/model/RollupTableBaseTableMetadataTest.java
b/server/src/test/java/org/apache/druid/catalog/model/RollupTableBaseTableMetadataTest.java
new file mode 100644
index 00000000000..9af97f9dcc8
--- /dev/null
+++
b/server/src/test/java/org/apache/druid/catalog/model/RollupTableBaseTableMetadataTest.java
@@ -0,0 +1,267 @@
+/*
+ * 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.RollupTableProjectionSpec;
+import org.apache.druid.data.input.impl.StringDimensionSchema;
+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.aggregation.AggregatorFactory;
+import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
+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 other layouts are covered
exhaustively in
+ * {@link ClusteredValueGroupsBaseTableMetadataTest} and route through the
same {@code BaseTableColumns} helper; this
+ * covers what the rollup layout adds — splitting the declared columns into
grouping and metric columns by aggregator
+ * name.
+ */
+public class RollupTableBaseTableMetadataTest 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("total", Columns.SQL_BIGINT, null)
+ );
+
+ private static VirtualColumns hourCarrier()
+ {
+ return VirtualColumns.create(
+ Granularities.toVirtualColumn(Granularities.HOUR,
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
+ );
+ }
+
+ private static AggregatorFactory[] totalSum()
+ {
+ return new AggregatorFactory[]{new LongSumAggregatorFactory("total",
"total")};
+ }
+
+ @Test
+ public void testSerde() throws Exception
+ {
+ final DatasourceBaseTableMetadata metadata = new
RollupTableBaseTableMetadata(hourCarrier(), totalSum(), null);
+ final String json = mapper.writeValueAsString(metadata);
+ Assertions.assertTrue(json.contains("\"type\":\"rollupTable\""), json);
+ final DatasourceBaseTableMetadata fromJson = mapper.readValue(json,
DatasourceBaseTableMetadata.class);
+ Assertions.assertEquals(metadata, fromJson);
+ }
+
+ @Test
+ public void testSerdeEmpty() throws Exception
+ {
+ final DatasourceBaseTableMetadata metadata = new
RollupTableBaseTableMetadata(null, null, null);
+ final String json = mapper.writeValueAsString(metadata);
+ Assertions.assertFalse(json.contains("virtualColumns"));
+ Assertions.assertFalse(json.contains("aggregators"));
+ Assertions.assertFalse(json.contains("columnSchemas"));
+ final DatasourceBaseTableMetadata fromJson = mapper.readValue(json,
DatasourceBaseTableMetadata.class);
+ Assertions.assertEquals(metadata, fromJson);
+ }
+
+ @Test
+ public void testCreateSpec()
+ {
+ final RollupTableProjectionSpec spec =
+ new RollupTableBaseTableMetadata(hourCarrier(), totalSum(),
null).createSpec(COLUMNS);
+
+ // Declared columns split by aggregator name: everything else is a
grouping column, in declared order; the carrier
+ // rides through as the query granularity, and the layout advertises
rollup.
+ Assertions.assertEquals(
+ List.of("tenant", Columns.TIME_COLUMN),
+
spec.getGroupingColumns().stream().map(DimensionSchema::getName).collect(Collectors.toList())
+ );
+ Assertions.assertArrayEquals(totalSum(), spec.getMetrics());
+ Assertions.assertEquals(Granularities.HOUR, spec.getQueryGranularity());
+ Assertions.assertTrue(spec.isRollup());
+ }
+
+ @Test
+ public void testCreateSpecWithoutAggregatorsCollapsesDuplicates()
+ {
+ // No aggregators is a dedup table: every declared column is a grouping
column.
+ final RollupTableProjectionSpec spec = new
RollupTableBaseTableMetadata(null, null, null).createSpec(
+ Arrays.asList(
+ new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+ new ColumnSpec(Columns.TIME_COLUMN, Columns.SQL_TIMESTAMP, null)
+ )
+ );
+ Assertions.assertEquals(0, spec.getMetrics().length);
+ Assertions.assertTrue(spec.isRollup());
+ }
+
+ @Test
+ public void testCreateSpecGroupingColumnAfterMetricColumnFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new RollupTableBaseTableMetadata(null, totalSum(),
null).createSpec(
+ Arrays.asList(
+ new ColumnSpec(Columns.TIME_COLUMN, Columns.SQL_TIMESTAMP,
null),
+ new ColumnSpec("total", Columns.SQL_BIGINT, null),
+ new ColumnSpec("tenant", Columns.SQL_VARCHAR, null)
+ )
+ )
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("grouping column [tenant] is declared after
metric column [total]"),
+ e.getMessage()
+ );
+ }
+
+ /**
+ * A declared metric column named for the query-granularity carrier slips
past the grouping-column carrier-name
+ * check (it is matched to its aggregator, not treated as a grouping
column), so the spec's aggregator validation is
+ * what rejects it: the carrier virtual column would shadow the metric once
a query granularity is attached.
+ */
+ @Test
+ public void testCreateSpecAggregatorNamedForGranularityCarrierFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new RollupTableBaseTableMetadata(
+ null,
+ new AggregatorFactory[]{
+ new
LongSumAggregatorFactory(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME, "cnt")
+ },
+ null
+ ).createSpec(
+ Arrays.asList(
+ new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+ new ColumnSpec(Columns.TIME_COLUMN, Columns.SQL_TIMESTAMP,
null),
+ new ColumnSpec(Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME,
Columns.SQL_BIGINT, null)
+ )
+ )
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains(
+ "aggregator cannot be named [" +
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME + "]"
+ ),
+ e.getMessage()
+ );
+ }
+
+ @Test
+ public void testCreateSpecAggregatorWithoutDeclaredColumnFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new RollupTableBaseTableMetadata(
+ null,
+ new AggregatorFactory[]{new LongSumAggregatorFactory("nope",
"nope")},
+ null
+ ).createSpec(COLUMNS)
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("aggregator [nope] does not fill a declared
column"),
+ e.getMessage()
+ );
+ }
+
+ @Test
+ public void testCreateSpecMetricTypeMismatchFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new RollupTableBaseTableMetadata(null, totalSum(),
null).createSpec(
+ Arrays.asList(
+ new ColumnSpec("tenant", Columns.SQL_VARCHAR, null),
+ new ColumnSpec(Columns.TIME_COLUMN, Columns.SQL_TIMESTAMP,
null),
+ new ColumnSpec("total", Columns.SQL_DOUBLE, null)
+ )
+ )
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("metric column [total] is declared as type
[DOUBLE], but its aggregator produces [LONG]"),
+ e.getMessage()
+ );
+ }
+
+ @Test
+ public void testCreateSpecColumnSchemaForMetricColumnFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new RollupTableBaseTableMetadata(
+ null,
+ totalSum(),
+ Collections.singletonList(new StringDimensionSchema("total"))
+ ).createSpec(COLUMNS)
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("columnSchemas cannot customize metric column
[total]"),
+ e.getMessage()
+ );
+ }
+
+ @Test
+ public void testCreateSpecColumnSchemaForGroupingColumnApplied()
+ {
+ final RollupTableProjectionSpec spec = new RollupTableBaseTableMetadata(
+ null,
+ totalSum(),
+ Collections.singletonList(
+ new StringDimensionSchema("tenant",
DimensionSchema.MultiValueHandling.ARRAY, false)
+ )
+ ).createSpec(COLUMNS);
+ Assertions.assertEquals(
+ new StringDimensionSchema("tenant",
DimensionSchema.MultiValueHandling.ARRAY, false),
+ spec.getGroupingColumns().get(0)
+ );
+ }
+
+ @Test
+ public void testCreateSpecNoDeclaredColumnsFails()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> new RollupTableBaseTableMetadata(null, totalSum(),
null).createSpec(null)
+ );
+ Assertions.assertTrue(
+ e.getMessage().contains("Cannot define a [rollupTable] 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 7836f057d32..3e1fa441aa1 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
@@ -34,6 +34,7 @@ import
org.apache.druid.data.input.impl.BaseTableProjectionSpec;
import
org.apache.druid.data.input.impl.ClusteredValueGroupsBaseTableProjectionSpec;
import org.apache.druid.data.input.impl.DimensionsSpec;
import org.apache.druid.data.input.impl.LongDimensionSchema;
+import org.apache.druid.data.input.impl.RollupTableProjectionSpec;
import org.apache.druid.data.input.impl.StringDimensionSchema;
import org.apache.druid.data.input.impl.TableProjectionSpec;
import org.apache.druid.data.input.impl.TimestampSpec;
@@ -969,6 +970,47 @@ class DataSchemaTest extends InitializedNullHandlingTest
Assertions.assertFalse(effectiveGranularity.isRollup());
}
+ /**
+ * The rollup layout is the plain shape plus aggregators, so what it adds at
this level is: aggregators delegate
+ * through {@link DataSchema#getAggregators()}, and the recombined
granularity carries {@code rollup=true} from the
+ * spec's layout type (where the plain layout recombines with {@code
rollup=false}).
+ */
+ @Test
+ void testBaseTableModeWithRollupTableProjectionSpec() throws IOException
+ {
+ final RollupTableProjectionSpec spec = RollupTableProjectionSpec.builder()
+ .groupingColumns(
+ new StringDimensionSchema("page"),
+ new LongDimensionSchema("__time")
+ )
+ .aggregators(new LongSumAggregatorFactory("total", "total"))
+ .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("rollupTable",
root.get("baseTable").get("type").asText());
+ Assertions.assertFalse(root.has("metricsSpec"));
+ final DataSchema deserialized = jsonMapper.readValue(serialized,
DataSchema.class);
+ Assertions.assertEquals(original, deserialized);
+ Assertions.assertEquals(spec, deserialized.getBaseTable());
+
+ Assertions.assertArrayEquals(
+ new AggregatorFactory[]{new LongSumAggregatorFactory("total",
"total")},
+ original.getAggregators()
+ );
+ final GranularitySpec effectiveGranularity = original.getGranularitySpec();
+ Assertions.assertEquals(Granularities.DAY,
effectiveGranularity.getSegmentGranularity());
+ Assertions.assertEquals(Granularities.HOUR,
effectiveGranularity.getQueryGranularity());
+ Assertions.assertTrue(effectiveGranularity.isRollup());
+ }
+
@Test
void testBaseTableModeLegacyAccessorsDelegateToSpec()
{
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 dbb48b78f82..85586e10a54 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
@@ -31,6 +31,7 @@ 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.RollupTableBaseTableMetadata;
import org.apache.druid.catalog.model.TableBaseTableMetadata;
import org.apache.druid.data.input.impl.AggregateProjectionSpec;
import org.apache.druid.data.input.impl.DimensionSchema;
@@ -65,6 +66,7 @@ import org.apache.druid.sql.calcite.rel.Grouping;
import org.apache.druid.sql.calcite.table.DatasourceTable;
import
org.apache.druid.sql.calcite.table.DatasourceTable.PhysicalDatasourceMetadata;
import org.apache.druid.sql.calcite.table.DruidTable;
+import org.apache.druid.utils.CollectionUtils;
import org.joda.time.Interval;
import javax.annotation.Nullable;
@@ -151,9 +153,14 @@ public class ProjectionSpecTranslator
* 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).
+ * <p>
+ * A body with {@code GROUP BY} declares the rollup layout: the body is the
query that rolls the table up onto
+ * itself. A grouping output is a declared column referenced as itself (or
the {@code TIME_FLOOR} of {@code __time}),
+ * and an aggregate output becomes the aggregator that fills the declared
metric column it is named for, reading the
+ * column it fills, since aggregating a stored rollup table is done with the
combining form of its aggregators.
*
* @param clusteredBy the columns segments are clustered on, which must be
the leading prefix of the column list;
- * null selects the plain-table layout
+ * null selects the plain-table (or, with GROUP BY, the
rollup) layout
*/
public DatasourceBaseTableMetadata translateBaseTable(
final String tableName,
@@ -162,27 +169,39 @@ public class ProjectionSpecTranslator
@Nullable final SqlNodeList clusteredBy
)
{
- if (body.getWhere() != null || body.getGroup() != null) {
+ if (body.getWhere() != null) {
throw invalid(
BASE_PROJECTION_NAME,
- "its body filters or groups. The base table stores every ingested
row, so it can do neither"
+ "its body filters. The base table stores every ingested row, so it
cannot filter"
);
}
rejectSubqueries(BASE_PROJECTION_NAME, body);
- final DruidQuery druidQuery = planBody(tableName, columns,
BASE_PROJECTION_NAME, body);
- final List<ComputedColumn> computed = liftComputedColumns(columns,
druidQuery);
final DatasourceBaseTableMetadata metadata;
- if (clusteredBy == null) {
- metadata = new
TableBaseTableMetadata(plainTableVirtualColumns(computed), null);
+ if (body.getGroup() != null) {
+ // A GROUP BY body declares the rollup layout: rows with identical
grouping values aggregate into one.
+ if (clusteredBy != null) {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ "its body groups and declares CLUSTERED BY. A clustered base table
stores every ingested row, so a base"
+ + " table is either clustered or rollup, not both"
+ );
+ }
+ metadata = liftRollupBaseTable(tableName, columns, planBody(tableName,
columns, BASE_PROJECTION_NAME, body));
} 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
- );
+ final DruidQuery druidQuery = planBody(tableName, columns,
BASE_PROJECTION_NAME, body);
+ final List<ComputedColumn> computed = liftComputedColumns(columns,
druidQuery);
+ 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
@@ -288,6 +307,128 @@ public class ProjectionSpecTranslator
}
}
+ /**
+ * Lift a GROUP BY body into the rollup layout's metadata. The body plans
like an aggregate projection body, and its
+ * outputs must name every declared column in declared order: an output
backed by a grouping dimension must reference
+ * the declared column itself (or {@code TIME_FLOOR(__time, <period>) AS
__time}, which becomes the query
+ * granularity carrier) and an output backed by an aggregate becomes the
aggregator filling the declared metric
+ * column it is named for.
+ */
+ private static RollupTableBaseTableMetadata liftRollupBaseTable(
+ final String tableName,
+ final List<ColumnSpec> columns,
+ final DruidQuery druidQuery
+ )
+ {
+ final PlannedAggregation planned =
plannedAggregation(BASE_PROJECTION_NAME, tableName, druidQuery);
+ if (planned.virtualColumnsAndFilter.filter(BASE_PROJECTION_NAME) != null) {
+ throw invalid(BASE_PROJECTION_NAME, "its body filters. The base table
stores every ingested row");
+ }
+
+ final List<String> outputNames =
druidQuery.getOutputRowType().getFieldNames();
+ if (outputNames.size() != columns.size()) {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ StringUtils.format(
+ "it selects %d column(s) but the table declares %d. The body
lists the columns in the order segments"
+ + " store them, so it must name every declared column",
+ outputNames.size(),
+ columns.size()
+ )
+ );
+ }
+
+ final Map<String, DimensionSpec> dimensionsByInternalName = new
HashMap<>();
+ for (DimensionSpec dimension : planned.dimensions) {
+ dimensionsByInternalName.put(dimension.getOutputName(), dimension);
+ }
+ final Map<String, AggregatorFactory> aggregatorsByInternalName = new
HashMap<>();
+ for (AggregatorFactory aggregator : planned.aggregators) {
+ aggregatorsByInternalName.put(aggregator.getName(), aggregator);
+ }
+
+ final RowSignature sources = druidQuery.getOutputRowSignature();
+ final List<VirtualColumn> carrier = new ArrayList<>(1);
+ final List<AggregatorFactory> aggregators = new
ArrayList<>(planned.aggregators.size());
+ for (int i = 0; i < columns.size(); i++) {
+ final String declared = columns.get(i).name();
+ if (!declared.equals(outputNames.get(i))) {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ StringUtils.format(
+ "its column %d is [%s] but the table declares [%s] there. The
body lists the columns in the order"
+ + " segments store them",
+ i + 1,
+ outputNames.get(i),
+ declared
+ )
+ );
+ }
+ final String source = sources.getColumnName(i);
+ final AggregatorFactory aggregator =
aggregatorsByInternalName.get(source);
+ if (aggregator != null) {
+ aggregators.add(aggregator.withName(declared));
+ continue;
+ }
+ final DimensionSpec dimension = dimensionsByInternalName.get(source);
+ if (!(dimension instanceof DefaultDimensionSpec)) {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ "its grouping column [" + declared + "] is not a plain column
reference"
+ );
+ }
+ final String grouped = dimension.getDimension();
+ if (grouped.equals(declared)) {
+ // A grouping column referenced as itself: derived from the declared
column by the catalog, nothing to lift.
+ continue;
+ }
+ final VirtualColumn virtualColumn =
+
planned.virtualColumnsAndFilter.virtualColumns.getVirtualColumn(grouped);
+ if (ColumnHolder.TIME_COLUMN_NAME.equals(declared) && virtualColumn !=
null) {
+ final Granularity granularity =
Granularities.fromTimeVirtualColumn(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
+ )
+ );
+ }
+ carrier.add(Granularities.toVirtualColumn(granularity,
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME));
+ } else if (virtualColumn != null) {
+ 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",
+ declared
+ )
+ );
+ } else {
+ throw invalid(
+ BASE_PROJECTION_NAME,
+ StringUtils.format(
+ "its column %d groups [%s] but declares it as [%s]. A grouping
column is stored under the name it"
+ + " groups, so nothing would fill [%s]",
+ i + 1,
+ grouped,
+ declared,
+ declared
+ )
+ );
+ }
+ }
+ return new RollupTableBaseTableMetadata(
+ VirtualColumns.create(carrier),
+ aggregators.toArray(new AggregatorFactory[0]),
+ null
+ );
+ }
+
private static List<String> clusteringColumns(@Nullable final SqlNodeList
clusteredBy)
{
if (clusteredBy == null) {
@@ -475,6 +616,44 @@ public class ProjectionSpecTranslator
final String tableName,
final DruidQuery druidQuery
)
+ {
+ final PlannedAggregation planned = plannedAggregation(projectionName,
tableName, druidQuery);
+ return AggregateProjectionSpec
+ .builder(projectionName)
+ .virtualColumns(planned.virtualColumnsAndFilter.virtualColumns)
+ .filter(planned.virtualColumnsAndFilter.filter(projectionName))
+ .groupingColumns(groupingColumns(projectionName, planned.dimensions))
+ .aggregators(renameToOutputNames(projectionName, druidQuery,
planned.aggregators))
+ .build();
+ }
+
+ /**
+ * The pieces of a planned aggregating body that both the
aggregate-projection lift and the rollup base-table lift
+ * consume: the grouping dimensions, the aggregators, and the virtual
columns / filter / intervals of the query.
+ */
+ private static final class PlannedAggregation
+ {
+ private final List<DimensionSpec> dimensions;
+ private final List<AggregatorFactory> aggregators;
+ private final VirtualColumnsAndFilter virtualColumnsAndFilter;
+
+ private PlannedAggregation(
+ List<DimensionSpec> dimensions,
+ List<AggregatorFactory> aggregators,
+ VirtualColumnsAndFilter virtualColumnsAndFilter
+ )
+ {
+ this.dimensions = dimensions;
+ this.aggregators = aggregators;
+ this.virtualColumnsAndFilter = virtualColumnsAndFilter;
+ }
+ }
+
+ private static PlannedAggregation plannedAggregation(
+ final String projectionName,
+ final String tableName,
+ final DruidQuery druidQuery
+ )
{
final DataSource dataSource = druidQuery.getDataSource();
if (!(dataSource instanceof TableDataSource) ||
!tableName.equals(((TableDataSource) dataSource).getName())) {
@@ -501,27 +680,36 @@ public class ProjectionSpecTranslator
}
final Query<?> query = druidQuery.getQuery();
- final List<DimensionSpec> dimensions;
- final List<AggregatorFactory> aggregators;
- final VirtualColumnsAndFilter virtualColumnsAndFilter;
if (query instanceof GroupByQuery) {
final GroupByQuery groupBy = (GroupByQuery) query;
- dimensions = groupBy.getDimensions();
- aggregators = groupBy.getAggregatorSpecs();
- virtualColumnsAndFilter = new VirtualColumnsAndFilter(
- groupBy.getVirtualColumns(),
- groupBy.getDimFilter(),
- groupBy.getIntervals()
+ if (!CollectionUtils.isNullOrEmpty(groupBy.getSubtotalsSpec())) {
+ // Subtotal groupings produce rows of several different grouping
shapes; a stored grouping has exactly one.
+ throw invalid(
+ projectionName,
+ "its body uses GROUPING SETS (or ROLLUP/CUBE), which a projection
cannot store: each grouping is a"
+ + " different row shape. Store each grouping as its own projection
instead"
+ );
+ }
+ return new PlannedAggregation(
+ groupBy.getDimensions(),
+ groupBy.getAggregatorSpecs(),
+ new VirtualColumnsAndFilter(
+ groupBy.getVirtualColumns(),
+ groupBy.getDimFilter(),
+ groupBy.getIntervals()
+ )
);
} else if (query instanceof TimeseriesQuery) {
// GROUP BY () plans to a timeseries over all time; it has no grouping
columns.
final TimeseriesQuery timeseries = (TimeseriesQuery) query;
- dimensions = Collections.emptyList();
- aggregators = List.of(timeseries.getAggregatorSpecs().toArray(new
AggregatorFactory[0]));
- virtualColumnsAndFilter = new VirtualColumnsAndFilter(
- timeseries.getVirtualColumns(),
- timeseries.getFilter(),
- timeseries.getIntervals()
+ return new PlannedAggregation(
+ Collections.emptyList(),
+ List.of(timeseries.getAggregatorSpecs().toArray(new
AggregatorFactory[0])),
+ new VirtualColumnsAndFilter(
+ timeseries.getVirtualColumns(),
+ timeseries.getFilter(),
+ timeseries.getIntervals()
+ )
);
} else {
throw invalid(
@@ -529,14 +717,6 @@ public class ProjectionSpecTranslator
"its body did not plan to an aggregation. Add a GROUP BY clause, or
use SELECT DISTINCT"
);
}
-
- return AggregateProjectionSpec
- .builder(projectionName)
- .virtualColumns(virtualColumnsAndFilter.virtualColumns)
- .filter(virtualColumnsAndFilter.filter(projectionName))
- .groupingColumns(groupingColumns(projectionName, dimensions))
- .aggregators(renameToOutputNames(projectionName, druidQuery,
aggregators))
- .build();
}
private static List<DimensionSchema> groupingColumns(
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 f6d65b5f4d7..d4c926bb943 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.RollupTableBaseTableMetadata;
import org.apache.druid.catalog.model.TableBaseTableMetadata;
import org.apache.druid.catalog.model.TableId;
import org.apache.druid.catalog.model.TableMetadata;
@@ -42,6 +43,8 @@ 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.aggregation.AggregatorFactory;
+import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
import org.apache.druid.query.filter.RangeFilter;
import org.apache.druid.segment.VirtualColumn;
import org.apache.druid.segment.VirtualColumns;
@@ -1058,21 +1061,188 @@ public class CalciteCatalogDdlTest extends
BaseCalciteQueryTest
}
@Test
- public void testBaseProjectionRejectsFilterOrGrouping()
+ public void testBaseProjectionRejectsFilter()
{
- for (String body : new String[]{
- "SELECT tenant, __time WHERE tenant <> 'x'",
- "SELECT tenant, __time GROUP BY tenant, __time"
- }) {
- final DruidException e = assertThrows(
- DruidException.class,
- () -> execute(
- "CREATE TABLE tbl SEALED (tenant VARCHAR, __time TIMESTAMP,
PROJECTION __base AS (" + body + "))"
- ),
- body
- );
- assertTrue(e.getMessage().contains("filters or groups"), e.getMessage());
- }
+ final DruidException e = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl SEALED (tenant VARCHAR, __time TIMESTAMP,"
+ + " PROJECTION __base AS (SELECT tenant, __time WHERE tenant <>
'x'))"
+ )
+ );
+ assertTrue(e.getMessage().contains("its body filters"), e.getMessage());
+ }
+
+ /**
+ * A {@code __base} body with {@code GROUP BY} declares the rollup layout:
the body is the query that rolls the
+ * table up onto itself. Grouping columns reference themselves, {@code
TIME_FLOOR(__time, <period>)} becomes the
+ * query granularity carrier, and each aggregate becomes the aggregator
filling the declared metric column it is
+ * named for, reading the column it fills, since aggregating a stored rollup
table is done with the combining form
+ * of its aggregators.
+ */
+ @Test
+ public void testCreateTableWithRollupBaseProjection()
+ {
+ execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP, total BIGINT,"
+ + " PROJECTION __base AS (SELECT tenant, TIME_FLOOR(__time, 'PT1H') AS
__time, SUM(total) AS total"
+ + " GROUP BY 1, 2))"
+ );
+ assertEquals(
+ new RollupTableBaseTableMetadata(
+ VirtualColumns.create(
+ Granularities.toVirtualColumn(Granularities.HOUR,
Granularities.GRANULARITY_VIRTUAL_COLUMN_NAME)
+ ),
+ new AggregatorFactory[]{new LongSumAggregatorFactory("total",
"total")},
+ null
+ ),
+
WRITER.calls.get(0).spec.properties().get(DatasourceDefn.BASE_TABLE_PROPERTY)
+ );
+ }
+
+ /**
+ * Grouping on {@code __time} directly (no TIME_FLOOR) is a rollup at {@code
NONE} granularity, and a body with no
+ * aggregates at all is a dedup table: rows with identical grouping values
collapse into one.
+ */
+ @Test
+ public void testRollupBaseProjectionWithBareTimeAndNoAggregates()
+ {
+ execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP,"
+ + " PROJECTION __base AS (SELECT tenant, __time GROUP BY tenant,
__time))"
+ );
+ assertEquals(
+ new RollupTableBaseTableMetadata(null, null, null),
+
WRITER.calls.get(0).spec.properties().get(DatasourceDefn.BASE_TABLE_PROPERTY)
+ );
+ }
+
+ @Test
+ public void testRollupBaseProjectionRejectsClusteredBy()
+ {
+ final DruidException e = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP, total BIGINT,"
+ + " PROJECTION __base AS (SELECT tenant, __time, SUM(total) AS
total GROUP BY 1, 2 CLUSTERED BY tenant))"
+ )
+ );
+ assertTrue(
+ e.getMessage().contains("a base table is either clustered or rollup,
not both"),
+ e.getMessage()
+ );
+ assertTrue(WRITER.calls.isEmpty());
+ }
+
+ @Test
+ public void testRollupBaseProjectionRejectsComputedGroupingColumn()
+ {
+ final DruidException e = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP, total BIGINT,"
+ + " PROJECTION __base AS (SELECT UPPER(tenant) AS tenant, __time,
SUM(total) AS total GROUP BY 1, 2))"
+ )
+ );
+ assertTrue(
+ e.getMessage().contains(
+ "its column [tenant] is computed by an expression. Computed
columns are not supported; compute the column"
+ + " at ingestion time instead"
+ ),
+ e.getMessage()
+ );
+ assertTrue(WRITER.calls.isEmpty());
+ }
+
+ /**
+ * The body plans against the declared columns, so an aggregate reading an
undeclared column cannot resolve: the
+ * rollup body is the table's self-rollup query, so an aggregate reads the
metric column it fills.
+ */
+ @Test
+ public void testRollupBaseProjectionRejectsAggregateOverUndeclaredColumn()
+ {
+ final DruidException e = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP, total BIGINT,"
+ + " PROJECTION __base AS (SELECT tenant, __time, SUM(cnt) AS total
GROUP BY 1, 2))"
+ )
+ );
+ assertTrue(e.getMessage().contains("cnt"), e.getMessage());
+ assertTrue(WRITER.calls.isEmpty());
+ }
+
+ /**
+ * Subtotal groupings produce rows of several different grouping shapes,
while a stored grouping has exactly one, so
+ * GROUPING SETS (and ROLLUP/CUBE, which plan to the same form) are rejected
rather than silently storing only the
+ * full grouping tuple. The guard is shared with aggregate projections, so
both spellings are covered.
+ */
+ @Test
+ public void testGroupingSetsRejected()
+ {
+ final DruidException base = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP, total BIGINT,"
+ + " PROJECTION __base AS (SELECT tenant, __time, SUM(total) AS
total"
+ + " GROUP BY GROUPING SETS ((tenant, __time), (__time))))"
+ )
+ );
+ assertTrue(base.getMessage().contains("GROUPING SETS"), base.getMessage());
+
+ final DruidException projection = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl (tenant VARCHAR, page VARCHAR, __time TIMESTAMP,
cnt BIGINT,"
+ + " PROJECTION subtotals AS (SELECT tenant, page, SUM(cnt) AS
total"
+ + " GROUP BY GROUPING SETS ((tenant, page), (tenant))))"
+ )
+ );
+ assertTrue(projection.getMessage().contains("GROUPING SETS"),
projection.getMessage());
+ assertTrue(WRITER.calls.isEmpty());
+ }
+
+ /**
+ * A rollup table's aggregators must combine their own output (the same
aggregators serve ingestion and
+ * re-aggregation of stored rows), so an aggregate that is not its own
combining form is rejected: a count is stored
+ * by summing a count column, not with {@code COUNT(*)}.
+ */
+ @Test
+ public void testRollupBaseProjectionRejectsNonSelfCombiningAggregate()
+ {
+ final DruidException e = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP, cnt BIGINT,"
+ + " PROJECTION __base AS (SELECT tenant, __time, COUNT(*) AS cnt
GROUP BY 1, 2))"
+ )
+ );
+ assertTrue(
+ e.getMessage().contains("aggregator [cnt] is not its own combining
form"),
+ e.getMessage()
+ );
+ assertTrue(WRITER.calls.isEmpty());
+ }
+
+ /**
+ * The declared type of a metric column must match what its aggregator
stores; the check runs in the metadata's
+ * createSpec, attributed to the statement.
+ */
+ @Test
+ public void testRollupBaseProjectionRejectsMetricTypeMismatch()
+ {
+ final DruidException e = assertThrows(
+ DruidException.class,
+ () -> execute(
+ "CREATE TABLE tbl (tenant VARCHAR, __time TIMESTAMP, total DOUBLE,"
+ + " PROJECTION __base AS (SELECT tenant, __time, COUNT(*) AS total
GROUP BY 1, 2))"
+ )
+ );
+ assertTrue(
+ e.getMessage().contains("metric column [total] is declared as type
[DOUBLE], but its aggregator produces [LONG]"),
+ e.getMessage()
+ );
+ assertTrue(WRITER.calls.isEmpty());
}
/**
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]