diff --git a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java index 9f9eb03ceec676..351512c9b31ad3 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java +++ b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java @@ -51,6 +51,7 @@ import org.apache.doris.datasource.iceberg.IcebergExternalTable; import org.apache.doris.info.TableNameInfoUtils; import org.apache.doris.mtmv.BaseTableInfo; +import org.apache.doris.mtmv.MTMVAlterOpType; import org.apache.doris.nereids.trees.plans.commands.AlterSystemCommand; import org.apache.doris.nereids.trees.plans.commands.AlterTableCommand; import org.apache.doris.nereids.trees.plans.commands.AlterViewCommand; @@ -1313,6 +1314,11 @@ public void processAlterMTMV(AlterMTMV alterMTMV, boolean isReplay) { case ALTER_PROPERTY: mtmv.alterMvProperties(alterMTMV.getMvProperties()); break; + case ALTER_ADD_COLUMN: + mtmv.alterAddColumn( + alterMTMV.getNewColumn(), + alterMTMV.getExprSql(), alterMTMV.getNewQuerySql()); + break; case ADD_TASK: alterSuccess = mtmv.addTaskResult(alterMTMV.getTask(), alterMTMV.getRelation(), alterMTMV.getPartitionSnapshots(), @@ -1329,6 +1335,10 @@ public void processAlterMTMV(AlterMTMV alterMTMV, boolean isReplay) { if (alterMTMV.isNeedRebuildJob()) { Env.getCurrentEnv().getMtmvService().alterJob(mtmv, isReplay); } + // keep FE-side plan/cache consistent after MTMV schema change + if (alterSuccess && alterMTMV.getOpType() == MTMVAlterOpType.ALTER_ADD_COLUMN) { + Env.getCurrentEnv().notifyTableMetaChange(mtmv); + } // 4. log it and replay it in the follower if (!isReplay && alterSuccess) { Env.getCurrentEnv().getEditLog().logAlterMTMV(alterMTMV); diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java index 8ef13b435221b7..587271ba7ee668 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java @@ -192,6 +192,7 @@ import org.apache.doris.nereids.trees.plans.commands.UninstallPluginCommand; import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionLikeOp; import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionOp; +import org.apache.doris.nereids.trees.plans.commands.info.AlterMTMVAddColumnInfo; import org.apache.doris.nereids.trees.plans.commands.info.AlterMTMVPropertyInfo; import org.apache.doris.nereids.trees.plans.commands.info.AlterMTMVRefreshInfo; import org.apache.doris.nereids.trees.plans.commands.info.AlterMultiPartitionOp; @@ -7569,6 +7570,14 @@ public StatisticsJobAppender getStatisticsJobAppender() { return statisticsJobAppender; } + public void alterMTMVAddColumnInfo(AlterMTMVAddColumnInfo info) { + AlterMTMV alter = new AlterMTMV(info.getMvName(), + info.getColumn().translateToCatalogStyleForSchemaChange(), + info.getExprSql(), + info.getRewrittenQuerySql(), MTMVAlterOpType.ALTER_ADD_COLUMN); + this.alter.processAlterMTMV(alter, false); + } + public void alterMTMVRefreshInfo(AlterMTMVRefreshInfo info) { AlterMTMV alter = new AlterMTMV(info.getMvName(), info.getRefreshInfo(), MTMVAlterOpType.ALTER_REFRESH_INFO); this.alter.processAlterMTMV(alter, false); diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java index c760d63563ea53..5ddbc2e960d7db 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java @@ -22,6 +22,7 @@ import org.apache.doris.catalog.info.TableNameInfo; import org.apache.doris.common.AnalysisException; import org.apache.doris.common.Config; +import org.apache.doris.common.Pair; import org.apache.doris.common.util.PropertyAnalyzer; import org.apache.doris.datasource.CatalogMgr; import org.apache.doris.job.common.TaskStatus; @@ -44,18 +45,26 @@ import org.apache.doris.mtmv.MTMVRelation; import org.apache.doris.mtmv.MTMVSnapshotIf; import org.apache.doris.mtmv.MTMVStatus; +import org.apache.doris.nereids.DorisParser; +import org.apache.doris.nereids.DorisParserBaseVisitor; +import org.apache.doris.nereids.parser.NereidsParser; import org.apache.doris.nereids.rules.analysis.SessionVarGuardRewriter; import org.apache.doris.qe.ConnectContext; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.gson.annotations.SerializedName; -import org.apache.commons.collections4.MapUtils; +import org.antlr.v4.runtime.ParserRuleContext; +import org.antlr.v4.runtime.tree.ParseTree; +import org.antlr.v4.runtime.tree.RuleNode; +import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Optional; @@ -197,6 +206,80 @@ public MTMVStatus alterStatus(MTMVStatus newStatus) { } } + public void alterAddColumn(Column newColumn, String exprSql, String newQuerySql) { + writeMvLock(); + try { + querySql = StringUtils.isBlank(newQuerySql) + ? rewriteQuerySqlForAddColumn(querySql, exprSql, newColumn.getName()) + : newQuerySql; + + MaterializedIndexMeta baseIndexMeta = getIndexMetaByIndexId(getBaseIndexId()); + List newSchema = new ArrayList<>(baseIndexMeta.getSchema()); + Column copied = new Column(newColumn); + copied.setUniqueId(baseIndexMeta.incAndGetMaxColUniqueId()); + newSchema.add(copied); + baseIndexMeta.setSchema(newSchema); + baseIndexMeta.setSchemaVersion(baseIndexMeta.getSchemaVersion() + 1); + rebuildFullSchema(); + this.schemaChangeVersion++; + this.refreshSnapshot = new MTMVRefreshSnapshot(); + } catch (Exception e) { + throw new RuntimeException("alter materialized view add column failed", e); + } finally { + writeMvUnlock(); + } + } + + public static String rewriteQuerySqlForAddColumn(String originQuerySql, String exprSql, String columnName) { + SelectColumnClauseIndexFinder finder = new SelectColumnClauseIndexFinder(); + ParserRuleContext tree = NereidsParser.toAst(originQuerySql, DorisParser::singleStatement); + finder.visit(tree); + Pair selectColumnClauseIndex = finder.getIndex(); + String escapedColumnName = columnName.replace("`", "``"); + String newSelectColumnClause = originQuerySql.substring(selectColumnClauseIndex.first, + selectColumnClauseIndex.second + 1) + + ", " + exprSql + " AS `" + escapedColumnName + "`"; + return StringUtils.overlay(originQuerySql, newSelectColumnClause, + selectColumnClauseIndex.first, selectColumnClauseIndex.second + 1); + } + + private static class SelectColumnClauseIndexFinder extends DorisParserBaseVisitor { + private boolean found = false; + private Pair index; + + @Override + public Void visitChildren(RuleNode node) { + if (found) { + return null; + } + int n = node.getChildCount(); + for (int i = 0; i < n; ++i) { + ParseTree child = node.getChild(i); + child.accept(this); + } + return null; + } + + @Override + public Void visitCte(DorisParser.CteContext ctx) { + return null; + } + + @Override + public Void visitSelectColumnClause(DorisParser.SelectColumnClauseContext ctx) { + if (found) { + return null; + } + found = true; + index = Pair.of(ctx.getStart().getStartIndex(), ctx.getStop().getStopIndex()); + return null; + } + + public Pair getIndex() { + return index; + } + } + public void processBaseViewChange(String schemaChangeDetail) { writeMvLock(); try { diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVAlterOpType.java b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVAlterOpType.java index 99f056d4224ba7..9f383b15bc8892 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVAlterOpType.java +++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVAlterOpType.java @@ -21,5 +21,6 @@ public enum MTMVAlterOpType { ALTER_REFRESH_INFO, ALTER_STATUS, ALTER_PROPERTY, + ALTER_ADD_COLUMN, ADD_TASK; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java index c470bc789d9e78..9b637481a6f354 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java @@ -925,6 +925,7 @@ import org.apache.doris.nereids.trees.plans.commands.info.AddPartitionOp; import org.apache.doris.nereids.trees.plans.commands.info.AddRollupOp; import org.apache.doris.nereids.trees.plans.commands.info.AlterLoadErrorUrlOp; +import org.apache.doris.nereids.trees.plans.commands.info.AlterMTMVAddColumnInfo; import org.apache.doris.nereids.trees.plans.commands.info.AlterMTMVInfo; import org.apache.doris.nereids.trees.plans.commands.info.AlterMTMVPropertyInfo; import org.apache.doris.nereids.trees.plans.commands.info.AlterMTMVRefreshInfo; @@ -1939,6 +1940,10 @@ public AlterMTMVCommand visitAlterMTMV(AlterMTMVContext ctx) { Map properties = ctx.propertyClause() != null ? Maps.newHashMap(visitPropertyClause(ctx.propertyClause())) : Maps.newHashMap(); alterMTMVInfo = new AlterMTMVReplaceInfo(mvName, newName, properties); + } else if (ctx.colName != null) { + String columnName = ctx.colName.getText(); + String exprSql = getOriginSql(ctx.expression()); + alterMTMVInfo = new AlterMTMVAddColumnInfo(mvName, columnName, exprSql); } return new AlterMTMVCommand(alterMTMVInfo); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVAddColumnInfo.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVAddColumnInfo.java new file mode 100644 index 00000000000000..800d1f3a31b5e5 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVAddColumnInfo.java @@ -0,0 +1,104 @@ +// 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.nereids.trees.plans.commands.info; + +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.MTMV; +import org.apache.doris.catalog.TableIf.TableType; +import org.apache.doris.common.DdlException; +import org.apache.doris.common.UserException; +import org.apache.doris.mtmv.MTMVPlanUtil; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.qe.ConnectContext; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.Objects; + +/** + * alter mtmv add column info. + * Grammar: ALTER MATERIALIZED VIEW mv ADD COLUMN colName AS expression. + * The new column type is fully inferred from the expression, users do NOT specify a type. + */ +public class AlterMTMVAddColumnInfo extends AlterMTMVInfo { + private final String columnName; + private final String exprSql; + private ColumnDefinition analyzedColumn; + private String rewrittenQuerySql; + + public AlterMTMVAddColumnInfo(TableNameInfo mvName, String columnName, String exprSql) { + super(mvName); + this.columnName = Objects.requireNonNull(columnName, "require columnName"); + this.exprSql = Objects.requireNonNull(exprSql, "require exprSql object"); + } + + @Override + public void analyze(ConnectContext ctx) throws AnalysisException { + super.analyze(ctx); + if (StringUtils.isBlank(columnName)) { + throw new AnalysisException("ADD COLUMN must specify a column name"); + } + if (StringUtils.isBlank(exprSql)) { + throw new AnalysisException("ADD COLUMN must specify expression by `AS expression`"); + } + try { + MTMV mtmv = (MTMV) Env.getCurrentInternalCatalog().getDbOrAnalysisException(mvName.getDb()) + .getTableOrDdlException(mvName.getTbl(), TableType.MATERIALIZED_VIEW); + if (mtmv.getColumn(columnName) != null) { + throw new AnalysisException("Column already exists: " + columnName); + } + inferColumnFromExpression(mtmv, ctx); + } catch (DdlException | org.apache.doris.common.AnalysisException e) { + throw new AnalysisException(e.getMessage(), e); + } + } + + private void inferColumnFromExpression(MTMV mtmv, ConnectContext ctx) { + rewrittenQuerySql = MTMV.rewriteQuerySqlForAddColumn(mtmv.getQuerySql(), exprSql, columnName); + List allColumns = MTMVPlanUtil.generateColumnsBySql( + rewrittenQuerySql, + ctx, + mtmv.getMvPartitionInfo() == null ? null : mtmv.getMvPartitionInfo().getPartitionCol(), + mtmv.getDistributionColumnNames(), + null, + mtmv.getTableProperty() == null ? null : mtmv.getTableProperty().getProperties()); + if (CollectionUtils.isEmpty(allColumns) || allColumns.size() != mtmv.getBaseSchema().size() + 1) { + throw new AnalysisException("failed to infer added column by expression"); + } + analyzedColumn = allColumns.get(allColumns.size() - 1); + } + + @Override + public void run() throws UserException { + Env.getCurrentEnv().alterMTMVAddColumnInfo(this); + } + + public ColumnDefinition getColumn() { + return analyzedColumn; + } + + public String getExprSql() { + return exprSql; + } + + public String getRewrittenQuerySql() { + return rewrittenQuerySql; + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/persist/AlterMTMV.java b/fe/fe-core/src/main/java/org/apache/doris/persist/AlterMTMV.java index fc0c1277f0ea8b..b7a8ae8af4ecbc 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/persist/AlterMTMV.java +++ b/fe/fe-core/src/main/java/org/apache/doris/persist/AlterMTMV.java @@ -18,6 +18,7 @@ package org.apache.doris.persist; import org.apache.doris.catalog.info.TableNameInfo; +import org.apache.doris.catalog.Column; import org.apache.doris.common.io.Text; import org.apache.doris.common.io.Writable; import org.apache.doris.job.extensions.mtmv.MTMVTask; @@ -37,6 +38,12 @@ import java.util.Objects; public class AlterMTMV implements Writable { + @SerializedName("nc") + private Column newColumn; + @SerializedName("eq") + private String exprSql; + @SerializedName("nqs") + private String newQuerySql; @SerializedName("ot") private MTMVAlterOpType opType; @SerializedName("mn") @@ -63,6 +70,20 @@ public AlterMTMV(TableNameInfo mvName, MTMVRefreshInfo refreshInfo, MTMVAlterOpT this.needRebuildJob = true; } + public AlterMTMV(TableNameInfo mvName, Column newColumn, String exprSql, MTMVAlterOpType opType) { + this(mvName, newColumn, exprSql, null, opType); + } + + public AlterMTMV(TableNameInfo mvName, Column newColumn, String exprSql, + String newQuerySql, MTMVAlterOpType opType) { + this.mvName = Objects.requireNonNull(mvName, "require mvName object"); + this.newColumn = Objects.requireNonNull(newColumn, "require addcolumn object"); + this.exprSql = Objects.requireNonNull(exprSql, "require exprSql object"); + this.newQuerySql = newQuerySql; + this.opType = Objects.requireNonNull(opType, "require opType object"); + this.needRebuildJob = true; + } + public AlterMTMV(TableNameInfo mvName, MTMVAlterOpType opType) { this.mvName = Objects.requireNonNull(mvName, "require mvName object"); this.opType = Objects.requireNonNull(opType, "require opType object"); @@ -112,6 +133,18 @@ public MTMVAlterOpType getOpType() { return opType; } + public Column getNewColumn() { + return newColumn; + } + + public String getExprSql() { + return exprSql; + } + + public String getNewQuerySql() { + return newQuerySql; + } + public MTMVTask getTask() { return task; } diff --git a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVSchemaChangeTest.java b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVSchemaChangeTest.java index d0cb06721340a2..7553dd7576684f 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVSchemaChangeTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVSchemaChangeTest.java @@ -26,8 +26,27 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +/** + * MTMV Schema Change 测试用例。 + *

+ * 原有: testDropBaseView / testDropBaseTable(测试 drop base view/table 后 schema change 感知) + *

+ * 新增: 按 StarRocks commit 102b87e 的 test_schema_change/R 风格编写的 MTMV ADD COLUMN 测试。 + *

+ * 注意: {@link MTMV#rewriteQuerySqlForAddColumn} 的签名是 + * {@code rewriteQuerySqlForAddColumn(String originQuerySql, String exprSql, String columnName)}, + * 不需要类型,类型由表达式自动推断。 + * + * ✅ = Doris 能做到(与 StarRocks 等效) + * ⚠️ = Doris 能做但实现有风险(SelectColumnClauseIndexFinder 只找第一个 SELECT 子句) + * ❌ = Doris 做不到(仅 StarRocks 有) + */ public class MTMVSchemaChangeTest extends TestWithFeService { - // t1 => v1 => mv1 + + // ====================================================== + // 原有测试(保留不变) + // ====================================================== + @Test public void testDropBaseView() throws Exception { createDatabaseAndUse("db1"); @@ -91,4 +110,204 @@ public void testDropBaseTable() throws Exception { Assertions.assertTrue(mtmv.getSchemaChangeVersion() == 1); Assertions.assertTrue(mtmv.getStatus().getState().equals(MTMVState.SCHEMA_CHANGE)); } -} + + // ====================================================== + // 新增: MTMV ADD COLUMN 测试(按 StarRocks test 风格) + // ====================================================== + + /** + * ✅ 场景 1: 简单 SELECT — 核心链路正常 + *

+     * 原始: SELECT k1, v1 FROM t1
+     * 改写: SELECT k1, v1, k1 + 1 AS `new_col` FROM t1
+     * 
+ */ + @Test + public void testAddColumnSimpleSelect() throws Exception { + String originSql = "SELECT k1, v1 FROM t1"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "k1 + 1", "new_col"); + Assertions.assertEquals("SELECT k1, v1, k1 + 1 AS `new_col` FROM t1", rewritten); + } + + /** + * ✅ 场景 2: WHERE 条件 + *
+     * 原始: SELECT k1, v1 FROM t1 WHERE k1 > 1
+     * 改写: SELECT k1, v1, v1 * 2 AS `v1_x2` FROM t1 WHERE k1 > 1
+     * 
+ */ + @Test + public void testAddColumnWithWhere() throws Exception { + String originSql = "SELECT k1, v1 FROM t1 WHERE k1 > 1"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "v1 * 2", "v1_x2"); + Assertions.assertEquals( + "SELECT k1, v1, v1 * 2 AS `v1_x2` FROM t1 WHERE k1 > 1", + rewritten); + } + + /** + * ✅ 场景 3: GROUP BY 聚合 — 多个聚合字段 + *
+     * 原始: SELECT k1, SUM(v1) AS total, AVG(v1) AS avg_v FROM t1 GROUP BY k1
+     * 改写: SELECT k1, SUM(v1) AS total, AVG(v1) AS avg_v, k1 * k1 AS `k1_squared` FROM t1 GROUP BY k1
+     * 
+ */ + @Test + public void testAddColumnWithGroupBy() throws Exception { + String originSql = "SELECT k1, SUM(v1) AS total, AVG(v1) AS avg_v FROM t1 GROUP BY k1"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "k1 * k1", "k1_squared"); + Assertions.assertEquals( + "SELECT k1, SUM(v1) AS total, AVG(v1) AS avg_v, k1 * k1 AS `k1_squared` FROM t1 GROUP BY k1", + rewritten); + } + + /** + * ✅ 场景 4: ORDER BY 降序 + *
+     * 原始: SELECT k1, v1 FROM t1 ORDER BY k1 DESC
+     * 改写: SELECT k1, v1, CASE WHEN v1 >= 20 THEN 'big' ELSE 'small' END AS `v1_label` FROM t1 ORDER BY k1 DESC
+     * 
+ */ + @Test + public void testAddColumnWithOrderBy() throws Exception { + String originSql = "SELECT k1, v1 FROM t1 ORDER BY k1 DESC"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, + "CASE WHEN v1 >= 20 THEN 'big' ELSE 'small' END", "v1_label"); + Assertions.assertEquals( + "SELECT k1, v1, CASE WHEN v1 >= 20 THEN 'big' ELSE 'small' END AS `v1_label` " + + "FROM t1 ORDER BY k1 DESC", + rewritten); + } + + /** + * ✅ 场景 5: 标量子查询(多表) + *
+     * 原始: SELECT k1, (SELECT max(v1) FROM t2) AS mv FROM t1 WHERE k1 > 1
+     * 改写: SELECT k1, (SELECT max(v1) FROM t2) AS mv, k1 + 1 AS `new_col` FROM t1 WHERE k1 > 1
+     * 
+ */ + @Test + public void testAddColumnWithSubqueryFrom() throws Exception { + String originSql = "SELECT k1, (SELECT max(v1) FROM t2) AS mv FROM t1 WHERE k1 > 1"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "k1 + 1", "new_col"); + Assertions.assertEquals( + "SELECT k1, (SELECT max(v1) FROM t2) AS mv, k1 + 1 AS `new_col` FROM t1 WHERE k1 > 1", + rewritten); + } + + /** + * ✅ 场景 6: CTE(WITH 子句 — SelectColumnClauseIndexFinder 跳过 CTE) + *
+     * 原始: WITH cte AS (SELECT k1 FROM t2) SELECT k1 FROM cte
+     * 改写: WITH cte AS (SELECT k1 FROM t2) SELECT k1, k1 * 2 AS `x``` FROM cte
+     * 
+ */ + @Test + public void testAddColumnWithCte() throws Exception { + String originSql = "WITH cte AS (SELECT k1 FROM t2) SELECT k1 FROM cte"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "k1 * 2", "x`"); + Assertions.assertEquals( + "WITH cte AS (SELECT k1 FROM t2) SELECT k1, k1 * 2 AS `x``` FROM cte", + rewritten); + } + + /** + * ✅ 场景 7: JOIN(多表) + *
+     * 原始: SELECT a.id, a.v1, b.name FROM t3 a JOIN t4 b ON a.id = b.id
+     * 改写: SELECT a.id, a.v1, b.name, v1 + 10 AS `v1_plus` FROM t3 a JOIN t4 b ON a.id = b.id
+     * 
+ */ + @Test + public void testAddColumnWithJoin() throws Exception { + String originSql = "SELECT a.id, a.v1, b.name FROM t3 a JOIN t4 b ON a.id = b.id"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "v1 + 10", "v1_plus"); + Assertions.assertEquals( + "SELECT a.id, a.v1, b.name, v1 + 10 AS `v1_plus` FROM t3 a JOIN t4 b ON a.id = b.id", + rewritten); + } + + /** + * ✅ 场景 8: SELECT *(星号展开) + *
+     * 原始: SELECT * FROM t1
+     * 改写: SELECT *, k1 * 2 AS `x` FROM t1
+     * 
+ */ + @Test + public void testAddColumnSelectStar() throws Exception { + String originSql = "SELECT * FROM t1"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "k1 * 2", "x"); + Assertions.assertEquals("SELECT *, k1 * 2 AS `x` FROM t1", rewritten); + } + + /** + * ✅ 场景 9: 反引号列名转义(双倍反引号) + *
+     * 原始: SELECT k1, v1 FROM t1
+     * 改写: SELECT k1, v1, k1 * 2 AS `x``` FROM t1(列名 x` 的四个反引号)
+     * 
+ */ + @Test + public void testAddColumnWithBacktickColumnName() throws Exception { + String originSql = "SELECT k1, v1 FROM t1"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "k1 * 2", "x`"); + Assertions.assertEquals("SELECT k1, v1, k1 * 2 AS `x``` FROM t1", rewritten); + } + + /** + * ✅ 场景 10: 多字段非聚合 + *
+     * 原始: SELECT k1, v1, v2 FROM t1
+     * 改写: SELECT k1, v1, v2, v1 + v2 AS `sum_v` FROM t1
+     * 
+ */ + @Test + public void testAddColumnWithMultipleColumns() throws Exception { + String originSql = "SELECT k1, v1, v2 FROM t1"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "v1 + v2", "sum_v"); + Assertions.assertEquals("SELECT k1, v1, v2, v1 + v2 AS `sum_v` FROM t1", rewritten); + } + + /** + * ⚠️ 场景 11: UNION ALL — Doris 只改第一个 SELECT 子句 + *
+     * 原始: SELECT k1, v1 FROM t1 WHERE k1 <= 3
+     *        UNION ALL
+     *        SELECT k1, v1 FROM t1 WHERE k1 > 3
+     * 改写: SELECT k1, v1, IF(v1 >= 30, 'high', 'low') AS `comment` FROM t1 WHERE k1 <= 3
+     *        UNION ALL
+     *        SELECT k1, v1 FROM t1 WHERE k1 > 3
+     * (第二个 SELECT 没有新列 → 列数不匹配)
+     * 
+ * ⚠️ StarRocks 用 AST2SQLVisitor 遍历所有 SELECT,不存在此问题。 + */ + @Test + public void testAddColumnUnionAllFirstOnly() throws Exception { + String originSql = "SELECT k1, v1 FROM t1 WHERE k1 <= 3 " + + "UNION ALL SELECT k1, v1 FROM t1 WHERE k1 > 3"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, + "IF(v1 >= 30, 'high', 'low')", "comment"); + Assertions.assertEquals( + "SELECT k1, v1, IF(v1 >= 30, 'high', 'low') AS `comment` FROM t1 WHERE k1 <= 3 " + + "UNION ALL SELECT k1, v1 FROM t1 WHERE k1 > 3", + rewritten); + // ⚠️ 第二个 SELECT 只有 2 列,第 1 个有 3 列 → 报错 + } + + /** + * ❌ 场景 12: DROP COLUMN — Doris 完全不支持 + *
+     * StarRocks 支持:  ALTER MATERIALIZED VIEW mv DROP COLUMN colName
+     * Doris 需要:
+     *   1. DorisParser.g4 新增 DROP COLUMN 语法分支
+     *   2. MTMVAlterOpType 新增 DROP_COLUMN 枚举
+     *   3. 新增 AlterMTMVDropColumnInfo
+     *   4. Alter.java 新增 DROP_COLUMN case
+     *   5. MTMV.alterDropColumn() 实现(含 queryOutputIndices 维护)
+     * 
+ */ + // ❌ Doris 当前 MTMVAlterOpType 无 DROP_COLUMN 枚举, + // 对应的 SQL 语法也在 ANTLR 规则中没有, + // 所以无测试用例可写。 +} \ No newline at end of file diff --git a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java index 25c62451e43aea..88cb82475a47a9 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java @@ -282,6 +282,21 @@ public void testAlterMvPropertiesWithOtherProperty() { Assert.assertFalse(mtmv.getRefreshSnapshot().getPartitionSnapshots().isEmpty()); } + @Test + public void testRewriteQuerySqlForAddColumnWithSubqueryFrom() { + String originSql = "SELECT k1, (SELECT max(v1) FROM t2) AS mv FROM t1 WHERE k1 > 1"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "k1 + 1", "new_col"); + Assert.assertEquals("SELECT k1, (SELECT max(v1) FROM t2) AS mv, k1 + 1 AS `new_col` FROM t1 WHERE k1 > 1", + rewritten); + } + + @Test + public void testRewriteQuerySqlForAddColumnWithCte() { + String originSql = "WITH cte AS (SELECT k1 FROM t2) SELECT k1 FROM cte"; + String rewritten = MTMV.rewriteQuerySqlForAddColumn(originSql, "k1 * 2", "x`"); + Assert.assertEquals("WITH cte AS (SELECT k1 FROM t2) SELECT k1, k1 * 2 AS `x``` FROM cte", rewritten); + } + @Test public void testAlterStatus() { MTMV mtmv = new MTMV(); diff --git a/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 b/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 index cd2391b1775f1e..3d4be452910aaa 100644 --- a/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 +++ b/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 @@ -86,7 +86,8 @@ materializedViewStatement | ALTER MATERIALIZED VIEW mvName=multipartIdentifier ((RENAME renameNewName=multipartIdentifier) | (REFRESH (refreshMethod | refreshTrigger | refreshMethod refreshTrigger)) | REPLACE WITH MATERIALIZED VIEW replaceNewName=identifier propertyClause? - | (SET LEFT_PAREN fileProperties=propertyItemList RIGHT_PAREN)) #alterMTMV + | (SET LEFT_PAREN fileProperties=propertyItemList RIGHT_PAREN) + | ADD COLUMN colName=identifier AS expression) #alterMTMV | DROP MATERIALIZED VIEW (IF EXISTS)? mvName=multipartIdentifier (ON tableName=multipartIdentifier)? #dropMV | PAUSE MATERIALIZED VIEW JOB ON mvName=multipartIdentifier #pauseMTMV