This is an automated email from the ASF dual-hosted git repository.
yujun777 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 0ded66a27bf [feature](ivm) Support incremental refresh for array_agg
and collect_list aggregates (#67575)
0ded66a27bf is described below
commit 0ded66a27bf9112a006999420380e6076af5c1f2
Author: yujun <[email protected]>
AuthorDate: Thu Sep 10 14:52:20 2026 +0800
[feature](ivm) Support incremental refresh for array_agg and collect_list
aggregates (#67575)
MVs containing `ARRAY_AGG` or `COLLECT_LIST` can now be refreshed
incrementally (IVM): the visible array is the full aggregate state, so
no hidden state column is needed. The delta aggregate emits insert-side
and delete-side arrays and apply merges them as a multiset
`except_all(concat(coalesce(old, []), ins), del)`, which keeps duplicate
elements and NULLs correct.
`COLLECT_LIST` skips NULL rows, so its polarity columns use the
conditional-argument idiom. `ARRAY_AGG` keeps NULL elements, so
NULL-filtering cannot build its polarity arrays; instead every change
row is packed into a single `array_agg(struct(dml_factor, elem))`
aggregate output and the delta top project derives the insert/delete
polarity columns above the aggregate with `array_filter`/`array_map`,
keeping the same two transient polarity slots for the multiset apply.
`ARRAY_AGG` over JSONB/VARIANT elements (struct/map/array constructors
all reject those types) is rejected during analysis with a precise
reason, surfaced to the user at `CREATE MATERIALIZED VIEW` time.
DISTINCT and the LIMIT variant of `collect_list` fall back to complete
refresh.
## Key changes
- add ARRAY_AGG and COLLECT_LIST aggregate kinds sharing one
`IvmAggArrayProcessor` (two transient polarity delta slots, multiset
apply, no hidden state); ARRAY_AGG derives its polarity columns above
the delta aggregate through a new per-processor top-project hook
- centralize polarity condition expressions and the typed empty array
literal in `IvmAggExpressionBuilder`
- regression suite `test_ivm_agg_array_1` covering grouped, scalar and
mixed (array_agg + count + sum) MVs over insert/update/delete windows
with NULL and non-NULL values
Tracked in https://github.com/apache/doris/issues/65418
---
.../apache/doris/mtmv/ivm/IvmAggDeltaHandler.java | 11 +
.../mtmv/ivm/agg/IvmAggArrayAggProcessor.java | 148 ++++++++++
.../doris/mtmv/ivm/agg/IvmAggArrayProcessor.java | 97 +++++++
.../mtmv/ivm/agg/IvmAggCollectListProcessor.java | 66 +++++
.../mtmv/ivm/agg/IvmAggExpressionBuilder.java | 22 +-
.../doris/mtmv/ivm/agg/IvmAggFunctionKind.java | 4 +-
.../mtmv/ivm/agg/IvmAggFunctionProcessor.java | 13 +
.../doris/mtmv/ivm/agg/IvmAggFunctionRegistry.java | 13 +-
.../mtmv/ivm/agg/IvmAggArrayAggProcessorTest.java | 120 +++++++++
.../ivm/agg/IvmAggCollectListProcessorTest.java | 67 +++++
.../nereids/trees/plans/CreateMTMVCommandTest.java | 23 ++
.../data/mtmv_p0/ivm/test_ivm_agg_array_1.out | 199 ++++++++++++++
.../suites/mtmv_p0/ivm/test_ivm_agg_array_1.groovy | 299 +++++++++++++++++++++
13 files changed, 1078 insertions(+), 4 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmAggDeltaHandler.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmAggDeltaHandler.java
index a72e073413f..d12c94a15f6 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmAggDeltaHandler.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmAggDeltaHandler.java
@@ -228,6 +228,17 @@ class IvmAggDeltaHandler {
IvmUtil.buildRowIdHash(deltaAgg.getOutput().subList(0,
groupKeySize)), Column.IVM_ROW_ID_COL);
topOutputs.add(rowIdAlias);
+ // Processors that pack every change row into one aggregate column
(ARRAY_AGG packs
+ // (dml_factor, elem) into a struct array) derive their polarity
columns here, above the
+ // aggregate: an aggregate output cannot reference a sibling aggregate
output, but a column
+ // of the top delta project can. Derived outputs keep the same
transient names the apply
+ // stage resolves below.
+ Map<String, Slot> deltaAggOutputByName =
indexSlotsByName(deltaAgg.getOutput());
+ for (IvmAggTarget target : aggMeta.getAggTargets()) {
+ aggFunctionRegistry.appendDeltaTopProjectOutputs(
+ target, deltaAggOutputByName, topOutputs,
aggExpressionBuilder);
+ }
+
Set<String> zeroDefaultDeltaOutputNames =
collectZeroDefaultDeltaOutputNames(aggMeta);
for (Slot slot : deltaAgg.getOutput()) {
if (zeroDefaultDeltaOutputNames.contains(slot.getName())) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggArrayAggProcessor.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggArrayAggProcessor.java
new file mode 100644
index 00000000000..9e362b8a5ce
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggArrayAggProcessor.java
@@ -0,0 +1,148 @@
+// 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.doris.mtmv.ivm.agg;
+
+import org.apache.doris.mtmv.ivm.IvmException;
+import org.apache.doris.mtmv.ivm.IvmFailureReason;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.ArrayItemReference;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.GreaterThan;
+import org.apache.doris.nereids.trees.expressions.LessThan;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
+import org.apache.doris.nereids.trees.expressions.functions.agg.ArrayAgg;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayFilter;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayMap;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.CreateStruct;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ElementAt;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Lambda;
+import org.apache.doris.nereids.trees.expressions.literal.StringLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.StructLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral;
+import org.apache.doris.nereids.types.DataType;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Processor for ARRAY_AGG(expr).
+ *
+ * <p>The visible array keeps NULL elements, so polarity columns cannot use
the NULL-filtering
+ * conditional-argument idiom (rows of the other polarity would leak into the
array as NULL
+ * elements). Instead every change row is packed into one struct field pair
and aggregated once:
+ *
+ * <pre>array_agg(struct(dml_factor, elem))</pre>
+ *
+ * <p>The insert/delete polarity columns are then derived above the delta
aggregate (see
+ * {@link #appendDeltaTopProjectOutputs}) by splitting the packed pair array
on the sign of the
+ * factor field and projecting the elem field, one scalar pass per side. Both
derived columns keep
+ * the transient names the apply stage resolves, so the multiset merge is
unchanged.
+ */
+class IvmAggArrayAggProcessor extends IvmAggArrayProcessor {
+ private static final String PAIRS_SLOT = "ARRAY_PAIRS";
+ /**
+ * Field name of {@code struct(dml_factor, elem)} carrying the dml factor.
{@link CreateStruct}
+ * names unnamed fields {@code StructLiteral.COL_PREFIX + (i + 1)}, so the
first field is "col1";
+ * keep the string composed from the shared prefix so a rename cannot
silently desync the
+ * {@code element_at(x, ...)} field lookups below.
+ */
+ private static final String FACTOR_FIELD = StructLiteral.COL_PREFIX + "1";
+ /**
+ * Field name of {@code struct(dml_factor, elem)} carrying the packed
element (second field).
+ */
+ private static final String ELEM_FIELD = StructLiteral.COL_PREFIX + "2";
+
+ @Override
+ public boolean supportsOriginalFunction(AggregateFunction function) {
+ if (!(function instanceof ArrayAgg)) {
+ return false;
+ }
+ // The packed delta aggregates struct(dml_factor, elem) per change
row, and struct fields
+ // cannot carry JSONB/VARIANT (CreateStruct rejects them). ARRAY_AGG
over such element types
+ // is not incrementally maintainable. Throw with the precise reason
instead of returning
+ // false: false would surface only the registry's generic "unsupported
aggregate for IVM:
+ // array_agg" error, which misleadingly blames the whole function
while ARRAY_AGG itself is
+ // supported. This check also runs while CREATE MATERIALIZED VIEW
analyzes the query, so the
+ // user sees the exact cause at create time.
+ DataType elemType = function.child(0).getDataType();
+ if (elemType.isJsonType() || elemType.isVariantType()) {
+ throw new IvmException(IvmFailureReason.AGG_UNSUPPORTED,
+ "IVM: ARRAY_AGG over JSONB/VARIANT element type " +
elemType.toSql()
+ + " is not incrementally maintainable (the delta
packs struct(dml_factor,"
+ + " elem) and struct fields cannot carry
JSONB/VARIANT), create the MV"
+ + " with a COMPLETE refresh instead: " +
function.toSql());
+ }
+ return true;
+ }
+
+ @Override
+ public IvmAggFunctionKind handledFunctionKind() {
+ return IvmAggFunctionKind.ARRAY_AGG;
+ }
+
+ @Override
+ void appendDeltaAggregateOutputs(IvmAggTarget target, Slot dmlFactorSlot,
+ List<NamedExpression> outputs, IvmAggExpressionBuilder ctx) {
+ Expression elem = target.getExprArgs().get(0);
+ outputs.add(new Alias(new ArrayAgg(new CreateStruct(dmlFactorSlot,
elem)),
+ ctx.transientDeltaColumnName(target, PAIRS_SLOT)));
+ }
+
+ @Override
+ void appendDeltaTopProjectOutputs(IvmAggTarget target, Map<String, Slot>
deltaAggOutputByName,
+ List<NamedExpression> topOutputs, IvmAggExpressionBuilder ctx) {
+ String pairsColumnName = ctx.transientDeltaColumnName(target,
PAIRS_SLOT);
+ Slot pairs = deltaAggOutputByName.get(pairsColumnName);
+ if (pairs == null) {
+ throw new IvmException(IvmFailureReason.PLAN_REWRITE_FAILED,
+ "IVM agg delta rewrite failed to resolve packed array
pairs output: "
+ + pairsColumnName + " for target " + target);
+ }
+ topOutputs.add(new Alias(splitPolarityPairs(true, pairs),
+ ctx.transientDeltaColumnName(target, polaritySlotName(true))));
+ topOutputs.add(new Alias(splitPolarityPairs(false, pairs),
+ ctx.transientDeltaColumnName(target,
polaritySlotName(false))));
+ }
+
+ /**
+ * Splits the packed {@code array<struct<dml_factor, elem>>} into the
elements of one polarity
+ * side, mirroring a conditional aggregate without filtering NULL elements
first:
+ *
+ * <pre>array_map(x -> element_at(x, 'col2'),
+ * array_filter(x -> element_at(x, 'col1') > 0, pairs)) //
insert side
+ * array_filter(x -> element_at(x, 'col1') < 0, pairs)) //
delete side</pre>
+ *
+ * <p>The factor field of a change row is never NULL, so every pair is
classified exactly once
+ * and rows with factor zero are dropped on both sides, as before.
+ */
+ private Expression splitPolarityPairs(boolean insertSide, Slot pairs) {
+ ArrayItemReference pair = new ArrayItemReference("x", pairs);
+ Expression factor = new ElementAt(pair.toSlot(), new
StringLiteral(FACTOR_FIELD));
+ Expression condition = insertSide ? new GreaterThan(factor, new
TinyIntLiteral((byte) 0))
+ : new LessThan(factor, new TinyIntLiteral((byte) 0));
+ Expression filtered = new ArrayFilter(new Lambda(
+ ImmutableList.of("x"), condition, ImmutableList.of(pair)));
+ ArrayItemReference kept = new ArrayItemReference("y", filtered);
+ Expression value = new ElementAt(kept.toSlot(), new
StringLiteral(ELEM_FIELD));
+ return new ArrayMap(new Lambda(ImmutableList.of("y"), value,
ImmutableList.of(kept)));
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggArrayProcessor.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggArrayProcessor.java
new file mode 100644
index 00000000000..269f298069d
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggArrayProcessor.java
@@ -0,0 +1,97 @@
+// 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.doris.mtmv.ivm.agg;
+
+import org.apache.doris.mtmv.ivm.IvmException;
+import org.apache.doris.mtmv.ivm.IvmFailureReason;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayConcat;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayExceptAll;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Coalesce;
+
+import java.util.Map;
+
+/**
+ * Shared delta/apply logic for array-valued aggregate functions (ARRAY_AGG /
COLLECT_LIST).
+ *
+ * <p>The stored visible array is the full aggregate state, so no hidden state
column is needed: empty
+ * groups and fully-deleted groups are handled by the central group-count
machinery. Each subclass
+ * decides how the delta aggregate emits its polarity columns, one over insert
rows and one over delete
+ * rows: COLLECT_LIST emits two conditional aggregates (NULL-filtering is safe
because it skips NULL
+ * rows), while ARRAY_AGG keeps NULL elements and therefore packs every change
row into a single
+ * {@code struct(dml_factor, elem)} array aggregate, deriving the two polarity
columns above the delta
+ * aggregate with scalar array functions. Apply merges them into the new
visible array as a multiset:
+ *
+ * <pre>new = except_all(concat(coalesce(old, []), coalesce(ins, [])),
coalesce(del, []))</pre>
+ *
+ * <p>The merge is a multiset identity: element order is irrelevant and
duplicate elements cancel by
+ * occurrence, which keeps updates and repeated values correct. NULL handling
is delegated to each
+ * underlying aggregate (ARRAY_AGG keeps NULL elements; COLLECT_LIST skips
NULL rows).
+ */
+abstract class IvmAggArrayProcessor extends IvmAggFunctionProcessor {
+ private static final String INS_SLOT = "ARRAY_INS";
+ private static final String DEL_SLOT = "ARRAY_DEL";
+
+ /** Transient output-name suffix of the insert/delete polarity column for
one side. */
+ String polaritySlotName(boolean insertSide) {
+ return insertSide ? INS_SLOT : DEL_SLOT;
+ }
+
+ @Override
+ void mapApplyDeltaSlots(IvmAggTarget target, Map<String, Slot>
outputByName,
+ Map<IvmAggDeltaSlotRef, Slot> applyDeltaSlots, Slot
deltaGroupCountSlot,
+ IvmAggExpressionBuilder ctx) {
+ super.mapApplyDeltaSlots(target, outputByName, applyDeltaSlots,
deltaGroupCountSlot, ctx);
+ resolveDeltaSlot(target, INS_SLOT, outputByName, applyDeltaSlots, ctx);
+ resolveDeltaSlot(target, DEL_SLOT, outputByName, applyDeltaSlots, ctx);
+ }
+
+ @Override
+ public void appendApplyExpressions(IvmAggTarget target, IvmAggApplyContext
applyContext) {
+ IvmAggExpressionBuilder ctx = applyContext.expressions();
+ Slot oldArray =
applyContext.rawMvSlot(target.getVisibleSlot().getName());
+ Expression empty =
ctx.emptyArrayLiteral(target.getVisibleSlot().getDataType());
+ // The old MV side is genuinely NULL for groups that appear only in
the delta (new groups).
+ // The ins/del transient columns can never be NULL -- every delta
group holds at least one
+ // change row, and array_agg/collect_list yield an empty array (not
NULL) for zero kept
+ // elements -- so their COALESCE is defensive only.
+ Expression insArray = applyContext.deltaSlotValue(target,
deltaSlotRef(target, INS_SLOT));
+ Expression delArray = applyContext.deltaSlotValue(target,
deltaSlotRef(target, DEL_SLOT));
+ applyContext.putFinalExpression(target,
target.getVisibleSlot().getName(),
+ new ArrayExceptAll(
+ new ArrayConcat(new Coalesce(oldArray, empty), new
Coalesce(insArray, empty)),
+ new Coalesce(delArray, empty)));
+ }
+
+ private void resolveDeltaSlot(IvmAggTarget target, String slotName,
Map<String, Slot> outputByName,
+ Map<IvmAggDeltaSlotRef, Slot> applyDeltaSlots,
IvmAggExpressionBuilder ctx) {
+ String columnName = ctx.transientDeltaColumnName(target, slotName);
+ Slot slot = outputByName.get(columnName);
+ if (slot == null) {
+ throw new IvmException(IvmFailureReason.PLAN_REWRITE_FAILED,
+ "IVM agg delta rewrite failed to resolve delta output
slot: "
+ + columnName + " for target " + target);
+ }
+ applyDeltaSlots.put(deltaSlotRef(target, slotName), slot);
+ }
+
+ private IvmAggDeltaSlotRef deltaSlotRef(IvmAggTarget target, String
slotName) {
+ return new IvmAggDeltaSlotRef(target.getOrdinal(), slotName);
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggCollectListProcessor.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggCollectListProcessor.java
new file mode 100644
index 00000000000..fe618f89e1d
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggCollectListProcessor.java
@@ -0,0 +1,66 @@
+// 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.doris.mtmv.ivm.agg;
+
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
+import org.apache.doris.nereids.trees.expressions.functions.agg.CollectList;
+
+import java.util.List;
+
+/**
+ * Processor for COLLECT_LIST(expr) with exactly one argument.
+ *
+ * <p>COLLECT_LIST skips NULL rows, so the polarity columns use the plain
conditional-argument idiom:
+ * rows of the other polarity become NULL and are skipped by the aggregate
itself.
+ *
+ * <p>The two-argument LIMIT variant is not incrementally maintainable and is
intentionally not
+ * supported (falls back to complete refresh).
+ */
+class IvmAggCollectListProcessor extends IvmAggArrayProcessor {
+ @Override
+ public boolean supportsOriginalFunction(AggregateFunction function) {
+ return function instanceof CollectList && function.children().size()
== 1;
+ }
+
+ @Override
+ public IvmAggFunctionKind handledFunctionKind() {
+ return IvmAggFunctionKind.COLLECT_LIST;
+ }
+
+ @Override
+ void appendDeltaAggregateOutputs(IvmAggTarget target, Slot dmlFactorSlot,
+ List<NamedExpression> outputs, IvmAggExpressionBuilder ctx) {
+ Expression arg = target.getExprArgs().get(0);
+ outputs.add(new Alias(buildPolarityAggregate(true, arg, dmlFactorSlot,
ctx),
+ ctx.transientDeltaColumnName(target, polaritySlotName(true))));
+ outputs.add(new Alias(buildPolarityAggregate(false, arg,
dmlFactorSlot, ctx),
+ ctx.transientDeltaColumnName(target,
polaritySlotName(false))));
+ }
+
+ private AggregateFunction buildPolarityAggregate(boolean insertSide,
Expression elem, Slot dmlFactorSlot,
+ IvmAggExpressionBuilder ctx) {
+ Expression filtered = insertSide
+ ? ctx.insertOnlyValue(elem, dmlFactorSlot)
+ : ctx.deleteOnlyValue(elem, dmlFactorSlot);
+ return new CollectList(filtered);
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggExpressionBuilder.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggExpressionBuilder.java
index 82cc99e06df..24990b7a72b 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggExpressionBuilder.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggExpressionBuilder.java
@@ -31,6 +31,7 @@ import org.apache.doris.nereids.trees.expressions.Subtract;
import org.apache.doris.nereids.trees.expressions.functions.scalar.AssertTrue;
import org.apache.doris.nereids.trees.expressions.functions.scalar.Coalesce;
import org.apache.doris.nereids.trees.expressions.functions.scalar.If;
+import org.apache.doris.nereids.trees.expressions.literal.ArrayLiteral;
import org.apache.doris.nereids.trees.expressions.literal.BigIntLiteral;
import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
import org.apache.doris.nereids.trees.expressions.literal.StringLiteral;
@@ -38,6 +39,8 @@ import
org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral;
import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
import org.apache.doris.nereids.types.DataType;
+import com.google.common.collect.ImmutableList;
+
/**
* Stateless expression builder shared by aggregate processors.
*
@@ -52,8 +55,18 @@ public class IvmAggExpressionBuilder {
/** Returns {@code expr} for inserts and {@code -expr} for deletes. */
Expression signedDeltaValue(Expression expr, Slot dmlFactorSlot) {
- return new If(new GreaterThan(dmlFactorSlot, new TinyIntLiteral((byte)
0)),
- expr, new Subtract(zeroOf(expr.getDataType()), expr));
+ return new If(factorPositive(dmlFactorSlot), expr,
+ new Subtract(zeroOf(expr.getDataType()), expr));
+ }
+
+ /** Returns {@code dmlFactorSlot > 0}; selects insert rows of the delta
stream. */
+ Expression factorPositive(Slot dmlFactorSlot) {
+ return new GreaterThan(dmlFactorSlot, new TinyIntLiteral((byte) 0));
+ }
+
+ /** Returns {@code dmlFactorSlot < 0}; selects delete rows of the delta
stream. */
+ Expression factorNegative(Slot dmlFactorSlot) {
+ return new LessThan(dmlFactorSlot, new TinyIntLiteral((byte) 0));
}
/** Returns {@code dml_factor} for non-NULL input values and zero for NULL
input values. */
@@ -99,6 +112,11 @@ public class IvmAggExpressionBuilder {
return new TinyIntLiteral((byte) 0).checkedCastTo(dataType);
}
+ /** Builds a typed empty array literal used to merge possibly-NULL array
sides. */
+ public Expression emptyArrayLiteral(DataType arrayDataType) {
+ return new ArrayLiteral(ImmutableList.of(), arrayDataType);
+ }
+
/** Keeps an expression only for inserted rows; deleted rows become NULL
and are ignored by MIN/MAX. */
Expression insertOnlyValue(Expression expr, Slot dmlFactorSlot) {
return new If(new GreaterThan(dmlFactorSlot, new TinyIntLiteral((byte)
0)),
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionKind.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionKind.java
index 42324242bb1..b7f216b2a32 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionKind.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionKind.java
@@ -30,5 +30,7 @@ public enum IvmAggFunctionKind {
MIN,
MAX,
BITMAP_UNION,
- BITMAP_UNION_COUNT
+ BITMAP_UNION_COUNT,
+ ARRAY_AGG,
+ COLLECT_LIST
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionProcessor.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionProcessor.java
index e5bd1336ff6..8042b852286 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionProcessor.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionProcessor.java
@@ -86,6 +86,19 @@ public abstract class IvmAggFunctionProcessor {
abstract void appendDeltaAggregateOutputs(IvmAggTarget target, Slot
dmlFactorSlot, List<NamedExpression> outputs,
IvmAggExpressionBuilder ctx);
+ /**
+ * Appends processor-owned derived (non-aggregate) delta columns on top of
the delta aggregate.
+ *
+ * <p>Some processors pack every change row into one aggregate column and
then derive the polarity
+ * columns (for example ARRAY_AGG's insert/delete views) with scalar
functions over that packed
+ * aggregate output. Such derived columns cannot be aggregate outputs of
the same node, so this
+ * hook appends them to the delta top project's output list, where {@code
deltaAggOutputByName}
+ * exposes the delta aggregate's own output slots.
+ */
+ void appendDeltaTopProjectOutputs(IvmAggTarget target, Map<String, Slot>
deltaAggOutputByName,
+ List<NamedExpression> topOutputs, IvmAggExpressionBuilder ctx) {
+ }
+
/** Resolves delta output slots and registers the slots that apply
expressions will read later. */
void mapApplyDeltaSlots(IvmAggTarget target, Map<String, Slot>
outputByName,
Map<IvmAggDeltaSlotRef, Slot> applyDeltaSlots, Slot
deltaGroupCountSlot, IvmAggExpressionBuilder ctx) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionRegistry.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionRegistry.java
index d7a4b08d09f..f10a23c934f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionRegistry.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/agg/IvmAggFunctionRegistry.java
@@ -64,7 +64,9 @@ public class IvmAggFunctionRegistry {
new IvmAggMinProcessor(),
new IvmAggMaxProcessor(),
new IvmAggBitmapUnionProcessor(),
- new IvmAggBitmapUnionCountProcessor());
+ new IvmAggBitmapUnionCountProcessor(),
+ new IvmAggArrayAggProcessor(),
+ new IvmAggCollectListProcessor());
processorByKind = new EnumMap<>(IvmAggFunctionKind.class);
for (IvmAggFunctionProcessor processor : processors) {
processorByKind.put(processor.handledFunctionKind(), processor);
@@ -97,6 +99,15 @@ public class IvmAggFunctionRegistry {
}
}
+ /**
+ * Appends processor-owned derived (non-aggregate) columns to the delta
top project, computed
+ * over the delta aggregate's own output slots.
+ */
+ public void appendDeltaTopProjectOutputs(IvmAggTarget target, Map<String,
Slot> deltaAggOutputByName,
+ List<NamedExpression> topOutputs, IvmAggExpressionBuilder ctx) {
+ processorFor(target).appendDeltaTopProjectOutputs(target,
deltaAggOutputByName, topOutputs, ctx);
+ }
+
/**
* Maps the delta plan output slots to stable logical keys consumed by the
apply expressions.
*/
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/agg/IvmAggArrayAggProcessorTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/agg/IvmAggArrayAggProcessorTest.java
new file mode 100644
index 00000000000..28ba8142abf
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/agg/IvmAggArrayAggProcessorTest.java
@@ -0,0 +1,120 @@
+// 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.doris.mtmv.ivm.agg;
+
+import org.apache.doris.mtmv.ivm.IvmException;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.functions.agg.ArrayAgg;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayConcat;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayExceptAll;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayFilter;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayMap;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Coalesce;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.CreateStruct;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ElementAt;
+import org.apache.doris.nereids.types.ArrayType;
+import org.apache.doris.nereids.types.IntegerType;
+import org.apache.doris.nereids.types.JsonType;
+import org.apache.doris.nereids.types.VariantType;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+class IvmAggArrayAggProcessorTest extends IvmAggProcessorTestBase {
+ @Test
+ void testArrayAggPacksStructPairsAndSplitsPolarityViews() {
+ IvmAggArrayAggProcessor processor = new IvmAggArrayAggProcessor();
+ Assertions.assertTrue(processor.supportsOriginalFunction(new
ArrayAgg(value)));
+ Assertions.assertEquals(IvmAggFunctionKind.ARRAY_AGG,
processor.handledFunctionKind());
+ Assertions.assertTrue(processor.hiddenStateKeys(new
ArrayAgg(value)).isEmpty());
+
+ IvmAggTarget target = target(0, IvmAggFunctionKind.ARRAY_AGG, "arr",
ArrayType.of(IntegerType.INSTANCE),
+ ImmutableMap.of(), valueArg());
+
+ // The delta aggregate packs every change row into one struct array;
NULL elements stay packed
+ // inside the structs, so no conditional (NULL-filtering) aggregate is
involved.
+ List<NamedExpression> aggOutputs = deltaOutputs(processor, target);
+ Assertions.assertEquals(1, aggOutputs.size());
+ Assertions.assertTrue(aggOutputs.get(0).child(0) instanceof ArrayAgg);
+ Assertions.assertTrue(aggOutputs.get(0).child(0).anyMatch(node -> node
instanceof CreateStruct));
+ Assertions.assertFalse(aggOutputs.get(0).anyMatch(node -> node
instanceof ElementAt));
+
+ // The insert/delete polarity columns are derived above the aggregate,
mirroring
+ // IvmAggDeltaHandler.buildDeltaSubPlan: the handler builds the top
project from the delta
+ // aggregate's output slots, then asks every processor to append its
derived outputs -- so
+ // here the agg outputs plus the derived ones must be resolvable by
name exactly like the
+ // top project's columns, each polarity view a map over a filter on
the factor field.
+ Map<String, Slot> aggOutputByName = new HashMap<>();
+ for (NamedExpression output : aggOutputs) {
+ aggOutputByName.put(output.getName(), output.toSlot());
+ }
+ List<NamedExpression> topOutputs = new ArrayList<>(aggOutputs);
+ processor.appendDeltaTopProjectOutputs(target, aggOutputByName,
topOutputs,
+ IvmAggExpressionBuilder.INSTANCE);
+ Assertions.assertEquals(3, topOutputs.size());
+ NamedExpression ins = topOutputs.get(1);
+ NamedExpression del = topOutputs.get(2);
+ Assertions.assertTrue(ins.getName().contains("ARRAY_INS"));
+ Assertions.assertTrue(del.getName().contains("ARRAY_DEL"));
+ Assertions.assertNotEquals(ins.getName(), del.getName());
+ for (NamedExpression polarity : ImmutableList.of(ins, del)) {
+ Assertions.assertTrue(polarity.child(0) instanceof ArrayMap);
+ Assertions.assertTrue(polarity.child(0).anyMatch(node -> node
instanceof ArrayFilter));
+ Assertions.assertTrue(polarity.child(0).anyMatch(node -> node
instanceof ElementAt));
+ }
+
+ Map<String, Expression> finalByName = apply(processor, target,
+ ImmutableList.of(slot("arr",
ArrayType.of(IntegerType.INSTANCE))),
+ mappedDeltaSlots(processor, target, topOutputs),
+ slot("delta_group_count", IntegerType.INSTANCE));
+ Expression visible = finalByName.get("arr");
+ Assertions.assertNotNull(visible);
+ Assertions.assertTrue(visible instanceof ArrayExceptAll);
+ Assertions.assertTrue(visible.anyMatch(node -> node instanceof
ArrayConcat));
+ // old, ins and del sides each merge NULL into an empty array
+ Assertions.assertEquals(3, visible.collect(node -> node instanceof
Coalesce).size());
+ }
+
+ @Test
+ void testArrayAggRejectsVariantAndJsonbElementsWithPreciseReason() {
+ IvmAggArrayAggProcessor processor = new IvmAggArrayAggProcessor();
+ Assertions.assertTrue(processor.supportsOriginalFunction(new
ArrayAgg(value)));
+ // struct(dml_factor, elem) cannot carry JSONB/VARIANT fields, so
ARRAY_AGG over those
+ // element types must be rejected with the precise reason (and that
reason must reach the
+ // user when CREATE MATERIALIZED VIEW analyzes the query) instead of
returning false, which
+ // would only produce the registry's generic "unsupported aggregate"
error.
+ for (Slot variantSlot : ImmutableList.of(
+ new SlotReference("jv", VariantType.INSTANCE, true),
+ new SlotReference("jb", JsonType.INSTANCE, true))) {
+ IvmException ex = Assertions.assertThrows(IvmException.class,
+ () -> processor.supportsOriginalFunction(new
ArrayAgg(variantSlot)));
+ Assertions.assertTrue(ex.getMessage().contains("not incrementally
maintainable"),
+ "unexpected message: " + ex.getMessage());
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/agg/IvmAggCollectListProcessorTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/agg/IvmAggCollectListProcessorTest.java
new file mode 100644
index 00000000000..5f31ea814f6
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/agg/IvmAggCollectListProcessorTest.java
@@ -0,0 +1,67 @@
+// 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.doris.mtmv.ivm.agg;
+
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.functions.agg.CollectList;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayConcat;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayExceptAll;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.If;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.types.ArrayType;
+import org.apache.doris.nereids.types.IntegerType;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+class IvmAggCollectListProcessorTest extends IvmAggProcessorTestBase {
+ @Test
+ void testCollectListUsesNullFilteringIdiomAndMultisetApply() {
+ IvmAggCollectListProcessor processor = new
IvmAggCollectListProcessor();
+ Assertions.assertTrue(processor.supportsOriginalFunction(new
CollectList(value)));
+ // two-argument LIMIT variant is not incrementally maintainable
+ Assertions.assertFalse(processor.supportsOriginalFunction(
+ new CollectList(value, new IntegerLiteral(10))));
+ Assertions.assertEquals(IvmAggFunctionKind.COLLECT_LIST,
processor.handledFunctionKind());
+ Assertions.assertTrue(processor.hiddenStateKeys(new
CollectList(value)).isEmpty());
+
+ IvmAggTarget target = target(0, IvmAggFunctionKind.COLLECT_LIST, "arr",
+ ArrayType.of(IntegerType.INSTANCE), ImmutableMap.of(),
valueArg());
+ List<NamedExpression> outputs = deltaOutputs(processor, target);
+ Assertions.assertEquals(2, outputs.size());
+ Assertions.assertTrue(outputs.get(0).child(0) instanceof CollectList);
+ Assertions.assertTrue(outputs.get(1).child(0) instanceof CollectList);
+ Assertions.assertTrue(outputs.get(0).child(0).anyMatch(node -> node
instanceof If));
+ Assertions.assertTrue(outputs.get(1).child(0).anyMatch(node -> node
instanceof If));
+
+ Map<String, Expression> finalByName = apply(processor, target,
+ ImmutableList.of(slot("arr",
ArrayType.of(IntegerType.INSTANCE))),
+ mappedDeltaSlots(processor, target, outputs),
+ slot("delta_group_count", IntegerType.INSTANCE));
+ Expression visible = finalByName.get("arr");
+ Assertions.assertNotNull(visible);
+ Assertions.assertTrue(visible instanceof ArrayExceptAll);
+ Assertions.assertTrue(visible.anyMatch(node -> node instanceof
ArrayConcat));
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/CreateMTMVCommandTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/CreateMTMVCommandTest.java
index 112b4aafe71..33c29674863 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/CreateMTMVCommandTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/CreateMTMVCommandTest.java
@@ -369,6 +369,29 @@ public class CreateMTMVCommandTest extends
TestWithFeService {
assertCreateMtmvFails(explicitColumnMv, "column name can't start with
'__DORIS_'");
}
+ @Test
+ public void testCreateIvmMvRejectsArrayAggOverUnsupportedElementTypes()
throws Exception {
+ // ARRAY_AGG incremental maintenance packs struct(dml_factor, elem)
per change row, and
+ // struct fields cannot carry JSONB/VARIANT (CreateStruct rejects
both, as does CreateMap,
+ // and the array constructor rejects them too). CREATE MATERIALIZED
VIEW analyzes the
+ // query with the IVM normalize rewrite, so the unsupported element
type must surface here
+ // with the precise reason (not a generic "unsupported aggregate" that
blames ARRAY_AGG
+ // itself).
+ // JSONB (not VARIANT) is used as the element type because variant
columns cannot be
+ // created on tables with ROW binlog, which IVM base tables require.
+ createTable("create table test.mtmv_arr_agg_jsonb_base (k1 int, v1
jsonb)\n"
+ + "duplicate key(k1)\n"
+ + "distributed by hash(k1) buckets 1\n"
+ + "properties('replication_num' = '1', 'binlog.enable' =
'true',"
+ + " 'binlog.format' = 'ROW');");
+ String mv = "CREATE MATERIALIZED VIEW mtmv_arr_agg_jsonb\n"
+ + " BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL\n"
+ + " PROPERTIES ('replication_num' = '1')\n"
+ + " AS SELECT k1, array_agg(v1) AS arr FROM
mtmv_arr_agg_jsonb_base GROUP BY k1;";
+ assertCreateMtmvFails(mv, "ARRAY_AGG over JSONB/VARIANT element type
JSON"
+ + " is not incrementally maintainable");
+ }
+
@Test
public void testCreateMTMVWithIncrementalFallback() throws Exception {
String mv = "CREATE MATERIALIZED VIEW mtmv_increment_fallback\n"
diff --git a/regression-test/data/mtmv_p0/ivm/test_ivm_agg_array_1.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_agg_array_1.out
new file mode 100644
index 00000000000..855297ac3a1
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_agg_array_1.out
@@ -0,0 +1,199 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !agg_initial --
+1 [10, 20]
+2 [30, 40]
+3 [null]
+
+-- !agg_initial_source --
+1 [10, 20]
+2 [30, 40]
+3 [null]
+
+-- !agg_after_insert --
+1 [10, 15, 20]
+2 [30, 40]
+3 [null]
+4 [70]
+
+-- !agg_after_insert_source --
+1 [10, 15, 20]
+2 [30, 40]
+3 [null]
+4 [70]
+
+-- !agg_after_update --
+1 [15, 15, 20]
+2 [30, 40]
+3 [null]
+4 [70]
+
+-- !agg_after_update_source --
+1 [15, 15, 20]
+2 [30, 40]
+3 [null]
+4 [70]
+
+-- !agg_after_delete --
+1 [15, 15]
+2 [null, 30]
+4 [70]
+5 [50]
+
+-- !agg_after_delete_source --
+1 [15, 15]
+2 [null, 30]
+4 [70]
+5 [50]
+
+-- !agg_after_complete --
+1 [15, 15]
+2 [null, 30]
+4 [70]
+5 [50]
+
+-- !agg_after_complete_source --
+1 [15, 15]
+2 [null, 30]
+4 [70]
+5 [50]
+
+-- !list_initial --
+1 [15, 15]
+2 [30]
+4 [70]
+5 [50]
+
+-- !list_initial_source --
+1 [15, 15]
+2 [30]
+4 [70]
+5 [50]
+
+-- !list_after_insert --
+1 [15, 15]
+2 [30, 55]
+4 [70]
+5 [50]
+6 []
+
+-- !list_after_insert_source --
+1 [15, 15]
+2 [30, 55]
+4 [70]
+5 [50]
+6 []
+
+-- !list_after_delete --
+1 [15, 15]
+2 [60]
+4 [70]
+5 [50]
+6 []
+
+-- !list_after_delete_source --
+1 [15, 15]
+2 [60]
+4 [70]
+5 [50]
+6 []
+
+-- !list_size_after_delete --
+1 2
+2 1
+4 1
+5 1
+6 0
+
+-- !list_size_after_delete_source --
+1 2
+2 1
+4 1
+5 1
+6 0
+
+-- !list_after_group_delete --
+1 [15, 15]
+2 [60, 70]
+4 [70]
+5 [50]
+
+-- !list_after_group_delete_source --
+1 [15, 15]
+2 [60, 70]
+4 [70]
+5 [50]
+
+-- !list_after_complete --
+1 [15, 15]
+2 [60, 70]
+4 [70]
+5 [50]
+
+-- !list_after_complete_source --
+1 [15, 15]
+2 [60, 70]
+4 [70]
+5 [50]
+
+-- !scalar_after_refresh --
+[null, null, 15, 15, 50, 60, 70, 70]
+
+-- !scalar_after_refresh_source --
+[null, null, 15, 15, 50, 60, 70, 70]
+
+-- !scalar_empty_table --
+[]
+
+-- !scalar_empty_table_source --
+[]
+
+-- !scalar_back_to_null_row --
+[null]
+
+-- !scalar_back_to_null_row_source --
+[null]
+
+-- !scalar_after_final_complete --
+[null]
+
+-- !scalar_after_final_complete_source --
+[null]
+
+-- !mixed_initial --
+1 [10, 20] 2 30
+2 [null, 30] 2 30
+
+-- !mixed_initial_source --
+1 [10, 20] 2 30
+2 [null, 30] 2 30
+
+-- !mixed_after_update --
+1 [10, 15] 2 25
+2 [null, 30] 2 30
+3 [40] 1 40
+
+-- !mixed_after_update_source --
+1 [10, 15] 2 25
+2 [null, 30] 2 30
+3 [40] 1 40
+
+-- !mixed_after_delete --
+1 [10, 15] 2 25
+2 [30] 1 30
+3 [40, 50] 2 90
+
+-- !mixed_after_delete_source --
+1 [10, 15] 2 25
+2 [30] 1 30
+3 [40, 50] 2 90
+
+-- !mixed_after_complete --
+1 [10, 15] 2 25
+2 [30] 1 30
+3 [40, 50] 2 90
+
+-- !mixed_after_complete_source --
+1 [10, 15] 2 25
+2 [30] 1 30
+3 [40, 50] 2 90
+
diff --git a/regression-test/suites/mtmv_p0/ivm/test_ivm_agg_array_1.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_agg_array_1.groovy
new file mode 100644
index 00000000000..6e109fb5289
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_agg_array_1.groovy
@@ -0,0 +1,299 @@
+// 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.
+
+// IVM incremental refresh for ARRAY_AGG / COLLECT_LIST aggregate MVs.
+// Reading the MV array columns is wrapped in array_sort because element order
is not guaranteed;
+// array_sort keeps the .out output stable.
+
+suite("test_ivm_agg_array_1") {
+
+ def base = "test_ivm_array_agg_collect_base"
+ def aggMv = "test_ivm_array_agg_collect_agg_mv"
+ def listMv = "test_ivm_array_agg_collect_list_mv"
+ def scalarMv = "test_ivm_array_agg_collect_scalar_mv"
+
+ def refresh = { mv ->
+ sql """REFRESH MATERIALIZED VIEW ${mv} INCREMENTAL"""
+ waitingMTMVTaskFinishedByMvName(mv)
+ }
+
+ // =========================================================
+ // Setup: MOW base table with one row per unique id
+ // =========================================================
+
+ sql """drop materialized view if exists ${scalarMv};"""
+ sql """drop materialized view if exists ${listMv};"""
+ sql """drop materialized view if exists ${aggMv};"""
+ sql """drop table if exists ${base};"""
+
+ sql """
+ CREATE TABLE ${base} (
+ id INT,
+ k INT,
+ v INT
+ )
+ UNIQUE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 2
+ PROPERTIES (
+ "replication_num" = "1",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW", "binlog.need_historical_value" = "true",
+ "enable_unique_key_merge_on_write" = "true"
+ );
+ """
+
+ // Initial rows: k=1 has two values, k=2 has two values, k=3 has one NULL
value.
+ sql """
+ INSERT INTO ${base} VALUES
+ (1, 1, 10),
+ (2, 1, 20),
+ (3, 2, 30),
+ (4, 2, 40),
+ (5, 3, NULL);
+ """
+
+ // =========================================================
+ // Part 1: grouped ARRAY_AGG (keeps NULL elements)
+ // =========================================================
+
+ sql """
+ CREATE MATERIALIZED VIEW ${aggMv}
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 2
+ PROPERTIES (
+ 'replication_num' = '1'
+ )
+ AS SELECT k, array_agg(v) AS arr FROM ${base} GROUP BY k;
+ """
+
+ refresh(aggMv)
+ order_qt_agg_initial """SELECT k, array_sort(arr) FROM ${aggMv} ORDER BY
k"""
+ order_qt_agg_initial_source """SELECT k, array_sort(array_agg(v)) FROM
${base} GROUP BY k ORDER BY k"""
+
+ // Insert a new row into k=1 and a new group k=4.
+ sql """INSERT INTO ${base} VALUES (6, 1, 15), (7, 4, 70);"""
+
+ refresh(aggMv)
+ order_qt_agg_after_insert """SELECT k, array_sort(arr) FROM ${aggMv} ORDER
BY k"""
+ order_qt_agg_after_insert_source """SELECT k, array_sort(array_agg(v))
FROM ${base} GROUP BY k ORDER BY k"""
+
+ // Update row id=1 (k=1: 10 -> 15): MOW emits delete + insert for the same
id.
+ sql """INSERT INTO ${base} VALUES (1, 1, 15);"""
+
+ refresh(aggMv)
+ order_qt_agg_after_update """SELECT k, array_sort(arr) FROM ${aggMv} ORDER
BY k"""
+ order_qt_agg_after_update_source """SELECT k, array_sort(array_agg(v))
FROM ${base} GROUP BY k ORDER BY k"""
+
+ // Delete one row of k=1 and the whole group k=3 (its only row had a NULL
value).
+ sql """DELETE FROM ${base} WHERE id = 2;"""
+ sql """DELETE FROM ${base} WHERE k = 3;"""
+ // Update (MOW upsert) id=4's value to NULL in the same window: ARRAY_AGG
must drop the old
+ // element 40 and keep a NULL element for the new row value.
+ sql """INSERT INTO ${base} VALUES (4, 2, NULL);"""
+ // Dirty another partition so the incremental refresh picks up the deletes.
+ sql """INSERT INTO ${base} VALUES (8, 5, 50);"""
+
+ refresh(aggMv)
+ order_qt_agg_after_delete """SELECT k, array_sort(arr) FROM ${aggMv} ORDER
BY k"""
+ order_qt_agg_after_delete_source """SELECT k, array_sort(array_agg(v))
FROM ${base} GROUP BY k ORDER BY k"""
+
+ // Complete refresh must agree with the incremental result.
+ sql """REFRESH MATERIALIZED VIEW ${aggMv} COMPLETE"""
+ waitingMTMVTaskFinishedByMvName(aggMv)
+ order_qt_agg_after_complete """SELECT k, array_sort(arr) FROM ${aggMv}
ORDER BY k"""
+ order_qt_agg_after_complete_source """SELECT k, array_sort(array_agg(v))
FROM ${base} GROUP BY k ORDER BY k"""
+
+ // =========================================================
+ // Part 2: grouped COLLECT_LIST (skips NULL rows)
+ // =========================================================
+
+ sql """
+ CREATE MATERIALIZED VIEW ${listMv}
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 2
+ PROPERTIES (
+ 'replication_num' = '1'
+ )
+ AS SELECT k, collect_list(v) AS lst FROM ${base} GROUP BY k;
+ """
+
+ refresh(listMv)
+ order_qt_list_initial """SELECT k, array_sort(lst) FROM ${listMv} ORDER BY
k"""
+ order_qt_list_initial_source """SELECT k, array_sort(collect_list(v)) FROM
${base} GROUP BY k ORDER BY k"""
+
+ // Same change windows as Part 1 (base is currently at the "after delete"
state).
+ // Inserts a non-NULL value into k=2 and a NULL row into the new group
k=6: COLLECT_LIST must
+ // skip the NULL row (group k=6 stays an empty list), and the group itself
stays alive.
+ sql """INSERT INTO ${base} VALUES (9, 2, 55), (10, 6, NULL);"""
+
+ refresh(listMv)
+ order_qt_list_after_insert """SELECT k, array_sort(lst) FROM ${listMv}
ORDER BY k"""
+ order_qt_list_after_insert_source """SELECT k, array_sort(collect_list(v))
FROM ${base} GROUP BY k ORDER BY k"""
+
+ // Delete a non-NULL row of k=2, insert a new non-NULL value, and update
(MOW upsert) the
+ // remaining row id=9 from 55 to NULL in the same window: COLLECT_LIST
must drop 55 and ignore
+ // the NULL replacement, keeping the other non-NULL elements.
+ sql """DELETE FROM ${base} WHERE id = 3;"""
+ sql """INSERT INTO ${base} VALUES (11, 2, 60);"""
+ sql """INSERT INTO ${base} VALUES (9, 2, NULL);"""
+
+ refresh(listMv)
+ order_qt_list_after_delete """SELECT k, array_sort(lst) FROM ${listMv}
ORDER BY k"""
+ order_qt_list_after_delete_source """SELECT k, array_sort(collect_list(v))
FROM ${base} GROUP BY k ORDER BY k"""
+
+ // Element-count sanity independent of ordering assumptions.
+ order_qt_list_size_after_delete """SELECT k, array_size(lst) FROM
${listMv} ORDER BY k"""
+ order_qt_list_size_after_delete_source """
+ SELECT k, array_size(collect_list(v)) FROM ${base} GROUP BY k ORDER BY
k"""
+
+ // Delete the only row of k=6 (a NULL row, so COLLECT_LIST never saw it)
together with an
+ // insert on k=2 in the same window to dirty the partition: the empty
group disappears while
+ // the other lists keep their non-NULL elements.
+ sql """DELETE FROM ${base} WHERE id = 10;"""
+ sql """INSERT INTO ${base} VALUES (12, 2, 70);"""
+
+ refresh(listMv)
+ order_qt_list_after_group_delete """SELECT k, array_sort(lst) FROM
${listMv} ORDER BY k"""
+ order_qt_list_after_group_delete_source """
+ SELECT k, array_sort(collect_list(v)) FROM ${base} GROUP BY k ORDER BY
k"""
+
+ sql """REFRESH MATERIALIZED VIEW ${listMv} COMPLETE"""
+ waitingMTMVTaskFinishedByMvName(listMv)
+ order_qt_list_after_complete """SELECT k, array_sort(lst) FROM ${listMv}
ORDER BY k"""
+ order_qt_list_after_complete_source """SELECT k,
array_sort(collect_list(v)) FROM ${base} GROUP BY k ORDER BY k"""
+
+ // =========================================================
+ // Part 3: scalar ARRAY_AGG over the whole table
+ // =========================================================
+
+ sql """
+ CREATE MATERIALIZED VIEW ${scalarMv}
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 1
+ PROPERTIES (
+ 'replication_num' = '1'
+ )
+ AS SELECT array_agg(v) AS arr FROM ${base};
+ """
+
+ refresh(scalarMv)
+ order_qt_scalar_after_refresh """SELECT array_sort(arr) FROM ${scalarMv}"""
+ order_qt_scalar_after_refresh_source """SELECT array_sort(array_agg(v))
FROM ${base}"""
+
+ // A change window that contains both the last inserts and their deletes
leaves the base
+ // table empty; the scalar MV must collapse to an empty array (not a NULL).
+ sql """INSERT INTO ${base} VALUES (12, 7, 1), (13, 8, 2);"""
+ sql """DELETE FROM ${base} WHERE id > 0;"""
+
+ refresh(scalarMv)
+ order_qt_scalar_empty_table """SELECT array_sort(arr) FROM ${scalarMv}"""
+ order_qt_scalar_empty_table_source """SELECT array_sort(array_agg(v)) FROM
${base}"""
+
+ // Sanity: a later non-empty window still upserts a real array.
+ sql """INSERT INTO ${base} VALUES (14, 9, NULL);"""
+ refresh(scalarMv)
+ order_qt_scalar_back_to_null_row """SELECT array_sort(arr) FROM
${scalarMv}"""
+ order_qt_scalar_back_to_null_row_source """SELECT array_sort(array_agg(v))
FROM ${base}"""
+
+ // Final COMPLETE refresh of every part (waitingMTMVTaskFinishedByMvName
asserts the task
+ // status is SUCCESS) must reproduce the incremental result.
+ sql """REFRESH MATERIALIZED VIEW ${scalarMv} COMPLETE"""
+ waitingMTMVTaskFinishedByMvName(scalarMv)
+ order_qt_scalar_after_final_complete """SELECT array_sort(arr) FROM
${scalarMv}"""
+ order_qt_scalar_after_final_complete_source """SELECT
array_sort(array_agg(v)) FROM ${base}"""
+
+ // =========================================================
+ // Part 4: mixed aggregate MV -- ARRAY_AGG next to COUNT(*) / SUM in one MV
+ // =========================================================
+
+ sql """drop materialized view if exists test_ivm_agg_array_mixed_mv;"""
+ sql """drop table if exists test_ivm_agg_array_mixed_base;"""
+
+ sql """
+ CREATE TABLE test_ivm_agg_array_mixed_base (
+ id INT,
+ k INT,
+ v INT
+ )
+ UNIQUE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 2
+ PROPERTIES (
+ "replication_num" = "1",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW", "binlog.need_historical_value" = "true",
+ "enable_unique_key_merge_on_write" = "true"
+ );
+ """
+
+ sql """INSERT INTO test_ivm_agg_array_mixed_base VALUES (1, 1, 10), (2, 1,
20), (3, 2, 30), (4, 2, NULL);"""
+
+ sql """
+ CREATE MATERIALIZED VIEW test_ivm_agg_array_mixed_mv
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 2
+ PROPERTIES (
+ 'replication_num' = '1'
+ )
+ AS SELECT k, array_agg(v) AS arr, COUNT(*) AS cnt, SUM(v) AS sv
+ FROM test_ivm_agg_array_mixed_base GROUP BY k;
+ """
+
+ refresh("test_ivm_agg_array_mixed_mv")
+ order_qt_mixed_initial """
+ SELECT k, array_sort(arr), cnt, sv FROM test_ivm_agg_array_mixed_mv
ORDER BY k"""
+ order_qt_mixed_initial_source """
+ SELECT k, array_sort(array_agg(v)), COUNT(*), SUM(v)
+ FROM test_ivm_agg_array_mixed_base GROUP BY k ORDER BY k"""
+
+ // Update id=2 (k=1: 20 -> 15) and insert a new group k=3 in one window:
array multiset,
+ // COUNT and SUM signed deltas must all merge consistently in the same
refresh.
+ sql """INSERT INTO test_ivm_agg_array_mixed_base VALUES (2, 1, 15), (5, 3,
40);"""
+
+ refresh("test_ivm_agg_array_mixed_mv")
+ order_qt_mixed_after_update """
+ SELECT k, array_sort(arr), cnt, sv FROM test_ivm_agg_array_mixed_mv
ORDER BY k"""
+ order_qt_mixed_after_update_source """
+ SELECT k, array_sort(array_agg(v)), COUNT(*), SUM(v)
+ FROM test_ivm_agg_array_mixed_base GROUP BY k ORDER BY k"""
+
+ // Delete the NULL row of k=2 in a delete-plus-insert window.
+ sql """DELETE FROM test_ivm_agg_array_mixed_base WHERE id = 4;"""
+ sql """INSERT INTO test_ivm_agg_array_mixed_base VALUES (6, 3, 50);"""
+
+ refresh("test_ivm_agg_array_mixed_mv")
+ order_qt_mixed_after_delete """
+ SELECT k, array_sort(arr), cnt, sv FROM test_ivm_agg_array_mixed_mv
ORDER BY k"""
+ order_qt_mixed_after_delete_source """
+ SELECT k, array_sort(array_agg(v)), COUNT(*), SUM(v)
+ FROM test_ivm_agg_array_mixed_base GROUP BY k ORDER BY k"""
+
+ sql """REFRESH MATERIALIZED VIEW test_ivm_agg_array_mixed_mv COMPLETE"""
+ waitingMTMVTaskFinishedByMvName("test_ivm_agg_array_mixed_mv")
+ order_qt_mixed_after_complete """
+ SELECT k, array_sort(arr), cnt, sv FROM test_ivm_agg_array_mixed_mv
ORDER BY k"""
+ order_qt_mixed_after_complete_source """
+ SELECT k, array_sort(array_agg(v)), COUNT(*), SUM(v)
+ FROM test_ivm_agg_array_mixed_base GROUP BY k ORDER BY k"""
+
+ sql """drop materialized view if exists test_ivm_agg_array_mixed_mv;"""
+ sql """drop table if exists test_ivm_agg_array_mixed_base;"""
+
+ sql """drop materialized view if exists ${scalarMv};"""
+ sql """drop materialized view if exists ${listMv};"""
+ sql """drop materialized view if exists ${aggMv};"""
+ sql """drop table if exists ${base};"""
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]