DorisPhysicalPlanBuilder.java

// 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.horn.horn2doris;


import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.DistributionInfo;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.HashDistributionInfo;
import org.apache.doris.catalog.OlapTable;
import org.apache.doris.common.UserException;
import org.apache.doris.horn.HornOptimizationContext;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.hint.DistributeHint;
import org.apache.doris.nereids.properties.DataTrait;
import org.apache.doris.nereids.properties.DistributionSpec;
import org.apache.doris.nereids.properties.DistributionSpecExecutionAny;
import org.apache.doris.nereids.properties.DistributionSpecGather;
import org.apache.doris.nereids.properties.DistributionSpecHash;
import org.apache.doris.nereids.properties.DistributionSpecHash.ShuffleType;
import org.apache.doris.nereids.properties.DistributionSpecReplicated;
import org.apache.doris.nereids.properties.LogicalProperties;
import org.apache.doris.nereids.properties.OrderKey;
import org.apache.doris.nereids.properties.RequireProperties;
import org.apache.doris.nereids.rules.implementation.LogicalWindowToPhysicalWindow.WindowFrameGroup;
import org.apache.doris.nereids.trees.expressions.AggregateExpression;
import org.apache.doris.nereids.trees.expressions.Alias;
import org.apache.doris.nereids.trees.expressions.ExprId;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.MarkJoinSlotReference;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.expressions.OrderExpression;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.expressions.WindowExpression;
import org.apache.doris.nereids.trees.expressions.WindowFrame;
import org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
import org.apache.doris.nereids.trees.expressions.functions.agg.AggregateParam;
import org.apache.doris.nereids.trees.expressions.functions.scalar.GroupingScalarFunction;
import org.apache.doris.nereids.trees.plans.AggMode;
import org.apache.doris.nereids.trees.plans.AggPhase;
import org.apache.doris.nereids.trees.plans.DistributeType;
import org.apache.doris.nereids.trees.plans.JoinType;
import org.apache.doris.nereids.trees.plans.LimitPhase;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.PreAggStatus;
import org.apache.doris.nereids.trees.plans.SortPhase;
import org.apache.doris.nereids.trees.plans.algebra.SetOperation.Qualifier;
import org.apache.doris.nereids.trees.plans.physical.PhysicalDistribute;
import org.apache.doris.nereids.trees.plans.physical.PhysicalEmptyRelation;
import org.apache.doris.nereids.trees.plans.physical.PhysicalExcept;
import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter;
import org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin;
import org.apache.doris.nereids.trees.plans.physical.PhysicalIntersect;
import org.apache.doris.nereids.trees.plans.physical.PhysicalLimit;
import org.apache.doris.nereids.trees.plans.physical.PhysicalNestedLoopJoin;
import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan;
import org.apache.doris.nereids.trees.plans.physical.PhysicalOneRowRelation;
import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
import org.apache.doris.nereids.trees.plans.physical.PhysicalProject;
import org.apache.doris.nereids.trees.plans.physical.PhysicalQuickSort;
import org.apache.doris.nereids.trees.plans.physical.PhysicalRepeat;
import org.apache.doris.nereids.trees.plans.physical.PhysicalResultSink;
import org.apache.doris.nereids.trees.plans.physical.PhysicalTopN;
import org.apache.doris.nereids.trees.plans.physical.PhysicalUnion;
import org.apache.doris.nereids.trees.plans.physical.PhysicalWindow;
import org.apache.doris.nereids.util.ExpressionUtils;
import org.apache.doris.nereids.util.JoinUtils;

import com.google.common.collect.ImmutableList;
import org.apache.horn4j.thrift.TDistributionSpec;
import org.apache.horn4j.thrift.TDorisHashSpec;
import org.apache.horn4j.thrift.TExpression;
import org.apache.horn4j.thrift.TFlattenedExpression;
import org.apache.horn4j.thrift.TOperator;
import org.apache.horn4j.thrift.TOperatorType;
import org.apache.horn4j.thrift.TOrderSpec;
import org.apache.horn4j.thrift.TPhysicalAgg;
import org.apache.horn4j.thrift.TPhysicalFilter;
import org.apache.horn4j.thrift.TPhysicalGrouping;
import org.apache.horn4j.thrift.TPhysicalJoin;
import org.apache.horn4j.thrift.TPhysicalLimit;
import org.apache.horn4j.thrift.TPhysicalMotion;
import org.apache.horn4j.thrift.TPhysicalProject;
import org.apache.horn4j.thrift.TPhysicalScan;
import org.apache.horn4j.thrift.TPhysicalSetOp;
import org.apache.horn4j.thrift.TPhysicalSort;
import org.apache.horn4j.thrift.TPhysicalTableScan;
import org.apache.horn4j.thrift.TPhysicalValueScan;
import org.apache.horn4j.thrift.TPhysicalWindow;
import org.apache.horn4j.thrift.TScalar;
import org.apache.horn4j.thrift.TSpecialHashSpecKind;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * Rebuild a Doris PhysicalPlan from a Horn TFlattenedExpression.
 *
 * <p>支持算子集:PhysicalTableScan / PhysicalFilter / PhysicalProject /
 * PhysicalMotion(Fragment boundary)。其它 physical 算子抛错由 caller fallback Nereids。
 *
 * <p>所有反译产生的 SlotReference 通过 {@link #scalarUidToSlot} 注册(key=horn 端
 * scalar_unique_id,全 plan tree 唯一),共享给 {@link DorisExpressionBuilder}
 * 反译 ColumnRef 时直接查表,保持上下游 plan 节点 slot 引用一致。
 */
public class DorisPhysicalPlanBuilder {

    private static final Logger LOG = LogManager.getLogger(DorisPhysicalPlanBuilder.class);

    private final HornOptimizationContext hornCtx;
    private final DorisExpressionBuilder exprTranslator;

    public DorisPhysicalPlanBuilder(HornOptimizationContext hornCtx) {
        this.hornCtx = hornCtx;
        this.exprTranslator = new DorisExpressionBuilder(hornCtx);
    }

    public PhysicalPlan build(TFlattenedExpression flatPlan) throws UserException {
        if (flatPlan == null || flatPlan.getTexprs() == null || flatPlan.getTexprs().isEmpty()) {
            throw new UserException("Horn CBO: empty optimized plan");
        }

        TExpression root = restoreTree(flatPlan, new AtomicInteger(0));

        // 默认 need_singleton=false
        PhysicalPlan hornPhysicalPlan = buildHelper(root);

        // SELECT 列序由 project_list 承载、被 horn PhysicalProject 保留,反译后
        // hornPhysicalPlan.getOutput() 即 SELECT 序,PhysicalResultSink.outputExprs 直接复用。
        List<NamedExpression> sinkOutputs = hornPhysicalPlan.getOutput().stream()
                .map(s -> (NamedExpression) s)
                .collect(Collectors.toList());
        return new PhysicalResultSink<>(
                sinkOutputs,
                Optional.empty(),
                hornPhysicalPlan.getLogicalProperties(),
                hornPhysicalPlan);
    }

    private TExpression restoreTree(TFlattenedExpression flat, AtomicInteger idx) {
        TExpression expr = flat.getTexprs().get(idx.getAndIncrement());
        long arity = expr.getArity();
        List<TExpression> children = new ArrayList<>();
        for (int i = 0; i < arity; i++) {
            children.add(restoreTree(flat, idx));
        }
        expr.setChildren(children);
        return expr;
    }

    private PhysicalPlan buildHelper(TExpression texpr) throws UserException {
        // Build children first
        List<PhysicalPlan> children = new ArrayList<>();
        if (texpr.getChildren() != null) {
            for (TExpression child : texpr.getChildren()) {
                children.add(buildHelper(child));
            }
        }

        TOperatorType opType = texpr.getOp().getOp_type();
        PhysicalPlan plan;

        switch (opType) {
            case kPhysicalTableScan:
                plan = buildScan(texpr);
                break;

            case kPhysicalValueScan:
                plan = buildValueScan(texpr);
                break;

            case kPhysicalFilter:
                plan = buildFilter(texpr, children.get(0));
                break;

            case kPhysicalProject:
                plan = buildProject(texpr, children.get(0));
                break;

            case kPhysicalSort:
                plan = buildSort(texpr, children.get(0));
                break;

            case kPhysicalLimit:
                plan = buildLimit(texpr, children.get(0));
                break;

            // group_by 非空 → kPhysicalHashAgg;空 → kPhysicalScalarAgg。同一套 buildAgg。
            case kPhysicalHashAgg:
            case kPhysicalScalarAgg:
                plan = buildAgg(texpr, children.get(0));
                break;

            // Doris PhysicalWindow 走外部排序模式:child 的 sort 由 PhysicalSort case 反译,
            // BE AnalyticSinkOperator 假设 input 已 sorted。kPhysicalInternalSortWindow
            // (window 内部自排)Doris 无对应算子,落 default 走 unsupported fallback。
            case kPhysicalWindow:
                plan = buildWindow(texpr, children.get(0));
                break;

            // grouping → Doris PhysicalRepeat。
            case kPhysicalGrouping:
                plan = buildRepeat(texpr, children.get(0));
                break;

            // Motion → PhysicalDistribute (Fragment boundary)
            case kPhysicalMotionGather:
            case kPhysicalMotionBroadcast:
            case kPhysicalMotionHashDistribute:
            case kPhysicalMotionRandom:
                plan = buildMotion(texpr, children.get(0));
                break;

            case kPhysicalInnerHashJoin:
            case kPhysicalInnerNestedLoopJoin:
            case kPhysicalLeftOuterHashJoin:
            case kPhysicalLeftOuterNestedLoopJoin:
            case kPhysicalRightOuterHashJoin:
            case kPhysicalRightOuterNestedLoopJoin:
            case kPhysicalFullOuterHashJoin:
            case kPhysicalFullOuterNestedLoopJoin:
            case kPhysicalLeftSemiHashJoin:
            case kPhysicalLeftSemiNestedLoopJoin:
            case kPhysicalRightSemiHashJoin:
            case kPhysicalRightSemiNestedLoopJoin:
            case kPhysicalLeftAntiSemiHashJoin:
            case kPhysicalLeftAntiSemiNestedLoopJoin:
            case kPhysicalRightAntiSemiHashJoin:
            case kPhysicalRightAntiSemiNestedLoopJoin:
            case kPhysicalNullAwareLeftAntiSemiHashJoin:
            case kPhysicalNullAwareLeftAntiSemiNestedLoopJoin:
            case kPhysicalCrossJoin:
                plan = buildJoin(texpr, children.get(0), children.get(1));
                break;

            // 集合算子 → PhysicalUnion/Intersect/Except。distinct 已被 BuildAggForUnion
            // 降级成 Agg+UnionAll;传整个 children 列表(N 元,非 children.get(0))。
            case kPhysicalNarrayUnionAll:
            case kPhysicalUnionAll:
            case kPhysicalIntersect:
            case kPhysicalExcept:
                plan = buildSetOp(texpr, children);
                break;

            default:
                throw new UserException("Horn output: unsupported operator " + opType);
        }

        // 统一在末尾给 plan 设 PhysicalProperties + Statistics
        PhysicalPlan finalPlan = Horn2DorisUtils.setPhysicalProperties(texpr, plan);
        // 统一同步:plan 节点 ctor(outer join / agg 等)会用 withNullable 产生新 Slot 实例
        // (ExprId 不变、nullable 变),旧 slot 在 map 里过期会导致上游按 uid 直查拿错。
        // 在此按 ExprId 把 scalarUidToSlot 覆盖成 finalPlan.getOutput() 的真值。
        syncMapWithPlanOutput(finalPlan);
        return finalPlan;
    }

    /** 按 ExprId 把 finalPlan.getOutput() 的 slot 真值同步进 scalarUidToSlot。 */
    private void syncMapWithPlanOutput(PhysicalPlan finalPlan) {
        if (hornCtx.getScalarUidToSlot().isEmpty()) {
            return;
        }
        List<Slot> output = finalPlan.getOutput();
        if (output.isEmpty()) {
            return;
        }
        Map<ExprId, Slot> byId = new HashMap<>(output.size());
        for (Slot s : output) {
            byId.put(s.getExprId(), s);
        }
        hornCtx.getScalarUidToSlot().replaceAll((uid, slot) -> {
            Slot fresh = byId.get(slot.getExprId());
            return fresh != null ? fresh : slot;
        });
    }

    /**
     * Horn TPhysicalSetOp → Doris PhysicalUnion / Intersect / Except。三字段:
     *  - output_list → outputs:union 自身输出列(plain column ref → SlotReference,注册 uid)。
     *  - children_output_list → childrenOutputs:每孩子一组按列对齐 SlotReference。
     *  - children_const_expr_list → constantExprsList:常量行(literal,可空)。
     * 分布派生由 buildHelper 末尾 setPhysicalProperties 自动接管;logicalProperties 传 null → lazy。
     */
    private PhysicalPlan buildSetOp(TExpression texpr, List<PhysicalPlan> children) throws UserException {
        TPhysicalSetOp tsetop = texpr.getOp().getOp_union().getPhysical_set_op();

        // outputs:union 自身输出列(plain column ref → SlotReference,translate 内部注册 uid→slot)。
        List<NamedExpression> outputs = new ArrayList<>();
        for (Expression e : exprTranslator.translateList(tsetop.getOutput_list())) {
            outputs.add((NamedExpression) e);
        }

        // childrenOutputs:每孩子一组 SlotReference(按列对齐 output,位置映射契约)。
        List<List<SlotReference>> childrenOutputs = new ArrayList<>();
        for (List<TOperator> oneChild : tsetop.getChildren_output_list()) {
            List<SlotReference> cols = new ArrayList<>();
            for (Expression e : exprTranslator.translateList(oneChild)) {
                cols.add((SlotReference) e);
            }
            childrenOutputs.add(cols);
        }

        List<Plan> planChildren = new ArrayList<>(children);

        // intersect/except 恒 DISTINCT、无 const-expr,分布由 cc 端 enforce 全列 hash。
        TOperatorType opType = texpr.getOp().getOp_type();
        if (opType == TOperatorType.kPhysicalIntersect || opType == TOperatorType.kPhysicalExcept) {
            List<NamedExpression> aligned = Horn2DorisUtils.alignSetOpOutputNullable(outputs, childrenOutputs);
            if (opType == TOperatorType.kPhysicalIntersect) {
                return new PhysicalIntersect(Qualifier.DISTINCT, aligned, childrenOutputs,
                        null /* logicalProperties → lazy */, planChildren);
            }
            return new PhysicalExcept(Qualifier.DISTINCT, aligned, childrenOutputs,
                    null /* logicalProperties → lazy */, planChildren);
        }

        // union all:constantExprsList(可空,透传)。常量行经 translate 后是 literal/cast
        // (Alias 已在 forward flattenPreOrder 剥掉),非 NamedExpression,故统一包 Alias。
        List<List<NamedExpression>> constExprs = new ArrayList<>();
        if (tsetop.isSetChildren_const_expr_list()) {
            for (List<TOperator> row : tsetop.getChildren_const_expr_list()) {
                List<NamedExpression> r = new ArrayList<>();
                for (Expression e : exprTranslator.translateList(row)) {
                    r.add(new Alias(e));
                }
                constExprs.add(r);
            }
        }
        return new PhysicalUnion(Qualifier.ALL, outputs, childrenOutputs, constExprs,
                null /* logicalProperties → lazy computeOutput */, planChildren);
    }

    private PhysicalPlan buildScan(TExpression texpr) throws UserException {
        TPhysicalScan physicalScan = texpr.getOp().getOp_union().getPhysical_scan();
        TPhysicalTableScan tableScan = physicalScan.getPhysical_table_scan();
        String tableName = tableScan.getTable_name();
        String dbName = tableScan.getDatabase_name();

        // horn 只处理 OlapTable
        OlapTable olapTable = (OlapTable) hornCtx.getCascadesContext().getConnectContext()
                .getCurrentCatalog()
                .getDbOrAnalysisException(dbName)
                .getTableOrAnalysisException(tableName);

        // 从 Horn output_list 构造 scan slots,并添加到 scalarUidToSlot。
        StatementContext stmtCtx = hornCtx.getCascadesContext().getStatementContext();
        ImmutableList<String> qualifier = ImmutableList.of(dbName, tableName);
        List<Slot> scanSlots = new ArrayList<>();
        if (texpr.getOutput_list() != null) {
            for (TOperator outOp : texpr.getOutput_list()) {
                TScalar scalar = Horn2DorisUtils.getRootScalar(outOp);
                String colName = scalar.getScalar_union().getScalar_column_ref().getColumn_name();
                SlotReference slotRef = SlotReference.fromColumn(
                        stmtCtx.getNextExprId(), olapTable, olapTable.getColumn(colName), qualifier);
                hornCtx.getScalarUidToSlot().put(scalar.getScalar_unique_id().getUnique_id(), slotRef);
                scanSlots.add(slotRef);
            }
        }
        // 分布列被裁剪时(如 count(*) / GROUP BY 非分布键)补回 scanSlots —— 对齐
        // Doris 原生行为(scan output 永远全列),保证 buildScanDistributionSpec 的 hash
        // 列凑齐、DataPartition 构造不空。
        DistributionInfo scanDistInfo = olapTable.getDefaultDistributionInfo();
        if (scanDistInfo instanceof HashDistributionInfo) {
            for (Column distCol : ((HashDistributionInfo) scanDistInfo).getDistributionColumns()) {
                if (scanSlots.stream().noneMatch(
                        s -> s.getName().equalsIgnoreCase(distCol.getName()))) {
                    scanSlots.add(SlotReference.fromColumn(
                            stmtCtx.getNextExprId(), olapTable, distCol, qualifier));
                }
            }
        }
        // RANDOM 分布表(无分布列可补)的 count(*) 场景 scanSlots 仍空,会 NPE:
        // 跟 Doris 原生 ColumnPruning.pruneOutput 一致,selectMinimumColumn 选最小宽度列兜底。
        if (scanSlots.isEmpty()) {
            List<SlotReference> candidates = new ArrayList<>();
            for (Column col : olapTable.getBaseSchema()) {
                candidates.add(SlotReference.fromColumn(
                        stmtCtx.getNextExprId(), olapTable, col, qualifier));
            }
            scanSlots.add(ExpressionUtils.selectMinimumColumn(candidates));
        }
        // 分区集走 external_relation_id 查 forward 登记的 prunedPartitionIds,
        // 不再从 thrift selected_partition_ids 读。查不到/未 set 都是 bug,直接抛
        // ——不兜底全分区(否则 self-join 裁剪不同时静默用错分区集)。
        if (!tableScan.isSetExternal_relation_id()) {
            throw new UserException("horn buildScan: TPhysicalTableScan missing external_relation_id (table "
                    + tableName + ")");
        }
        long extRelationId = tableScan.getExternal_relation_id();
        List<Long> partitionIds = hornCtx.getScanPrunedPartitions(extRelationId);
        if (partitionIds == null) {
            throw new UserException("horn buildScan: no pruned-partition binding for external_relation_id="
                    + extRelationId + " (table " + tableName + ")");
        }
        List<Long> tabletIds = new ArrayList<>();
        for (Long partId : partitionIds) {
            olapTable.getPartition(partId).getBaseIndex().getTablets()
                    .forEach(t -> tabletIds.add(t.getId()));
        }

        // PhysicalCatalogRelation.computeOutput() 用 table.getBaseSchema() 重 ExprId,
        // 跟我们登记 scalarUidToSlot 的 ExprId 不一致,所以必须显式传 props 锁定。
        ImmutableList<Slot> immutableScanSlots = ImmutableList.copyOf(scanSlots);
        LogicalProperties scanProps = new LogicalProperties(
                () -> immutableScanSlots,
                () -> DataTrait.EMPTY_TRAIT);

        DistributionSpec scanDistSpec = Horn2DorisUtils.buildScanDistributionSpec(
                olapTable, scanSlots, partitionIds);

        PhysicalOlapScan scan = new PhysicalOlapScan(
                hornCtx.getCascadesContext().getStatementContext().getNextRelationId(),
                olapTable,
                ImmutableList.of(dbName, tableName),
                olapTable.getBaseIndexId(),
                tabletIds,
                partitionIds,
                scanDistSpec,
                PreAggStatus.on(),
                immutableScanSlots,
                Optional.empty(),
                scanProps,
                Optional.empty(),
                immutableScanSlots,
                Collections.emptyList(),
                Collections.emptyList(),
                Optional.empty(),
                Optional.empty(),
                Collections.emptyList(),
                Optional.empty()
        );

        return scan;
    }

    /**
     * horn PhysicalValueScan 反译为 Doris 物理叶子:
     * <ul>
     *   <li>0 行 → {@link PhysicalEmptyRelation}(schema 列由 output_list 重建)</li>
     *   <li>1 行 → {@link PhysicalOneRowRelation}(projects = Alias(lit, schemaName),
     *       Alias.exprId 复用 schemaSlot.exprId 保证上层引用稳定)</li>
     * </ul>
     * <p>N 行不可达:Doris BindExpression 已把 InlineTable 拆成 OneRowRelation × N + Union。
     */
    private PhysicalPlan buildValueScan(TExpression texpr) throws UserException {
        // horn PhysicalValueScan::ToThrift 走 TPhysicalScan wrapper sub-struct
        // (跟 TPhysicalTableScan 同模式),不是 TOperatorUnion 直挂的 slot 14。
        TPhysicalValueScan valueScan = texpr.getOp().getOp_union()
                .getPhysical_scan().getPhysical_value_scan();
        StatementContext stmtCtx = hornCtx.getCascadesContext().getStatementContext();

        List<TOperator> schemaColumns = valueScan.isSetOutput_list()
                ? valueScan.getOutput_list() : Collections.emptyList();
        if (schemaColumns.isEmpty()) {
            throw new UserException(
                    "Horn output: PhysicalValueScan missing output_list (schema)");
        }

        List<Expression> schemaExprs = exprTranslator.translateList(schemaColumns);
        List<Slot> schemaSlots = new ArrayList<>(schemaExprs.size());
        for (Expression e : schemaExprs) {
            schemaSlots.add((Slot) e);
        }

        List<List<TOperator>> rows = valueScan.isSetValue_lists()
                ? valueScan.getValue_lists() : Collections.emptyList();

        // PhysicalOneRowRelation 主线没 override computeOutput,传 null 会抛 "Not support
        // compute output",故显式构造 LogicalProperties + supplier 锁定 schemaSlots
        // (PhysicalEmptyRelation 为一致性也走同款)。
        ImmutableList<Slot> immutableSchema = ImmutableList.copyOf(schemaSlots);
        LogicalProperties relationProps = new LogicalProperties(
                () -> immutableSchema,
                () -> DataTrait.EMPTY_TRAIT);

        if (rows.isEmpty()) {
            return new PhysicalEmptyRelation(stmtCtx.getNextRelationId(), schemaSlots, relationProps);
        }
        if (rows.size() > 1) {
            throw new UserException("Horn output: PhysicalValueScan with "
                    + rows.size() + " rows not supported");
        }
        // 1 行:PhysicalOneRowRelation.output 直接从 projects.toSlot 派生,
        // Alias 必须复用 schemaSlot.exprId,否则 OneRow 的 output ExprId 跟
        // scalarUidToSlot 登记的不一致,上层 Project/Filter 引用就断。
        List<TOperator> singleRow = rows.get(0);
        if (singleRow.size() != schemaSlots.size()) {
            throw new UserException("Horn output: PhysicalValueScan row arity "
                    + singleRow.size() + " != schema arity " + schemaSlots.size());
        }
        List<NamedExpression> projects = new ArrayList<>(schemaSlots.size());
        for (int i = 0; i < schemaSlots.size(); i++) {
            Slot slot = schemaSlots.get(i);
            Expression cellExpr = exprTranslator.translate(singleRow.get(i));
            projects.add(new Alias(slot.getExprId(), cellExpr, slot.getName()));
        }
        return new PhysicalOneRowRelation(stmtCtx.getNextRelationId(), projects, relationProps);
    }

    private PhysicalPlan buildFilter(TExpression texpr, PhysicalPlan child) throws UserException {
        TPhysicalFilter tfilter = texpr.getOp().getOp_union().getPhysical_filter();

        Set<Expression> conjuncts = new HashSet<>();
        for (TOperator predOp : tfilter.getFilter_list()) {
            Expression expr = exprTranslator.translate(predOp);
            LOG.debug("HORN_FILTER predicate: {}", expr);
            conjuncts.add(expr);
        }
        return new PhysicalFilter<>(conjuncts, null, child);
    }

    private PhysicalPlan buildProject(TExpression texpr, PhysicalPlan child) throws UserException {
        TPhysicalProject tproj = texpr.getOp().getOp_union().getPhysical_project();

        List<TOperator> projectList = tproj.getProject_list();
        List<TOperator> rewriteList = tproj.getRewrite_list();
        List<NamedExpression> projects = new ArrayList<>();
        for (int i = 0; i < projectList.size(); i++) {
            TOperator projOp = projectList.get(i);
            long projUid = Horn2DorisUtils.getRootScalar(projOp).getScalar_unique_id().getUnique_id();
            Slot existingSlot = hornCtx.getScalarUidToSlot().get(projUid);
            if (existingSlot != null) {
                // mark slot 特例:AS 别名投影时 mark slot 与别名共享 ExprId,直接透传会显示内部名 $c$1;
                // 对 MarkJoinSlotReference 包 Alias 恢复用户列名(仅 mark slot,普通列包 Alias 会多余 Project)。
                if (existingSlot instanceof MarkJoinSlotReference) {
                    String aliasName = Horn2DorisUtils.getRootScalar(rewriteList.get(i))
                            .getScalar_union().getScalar_column_ref().getColumn_name();
                    if (aliasName != null && !aliasName.equalsIgnoreCase(existingSlot.getName())) {
                        NamedExpression aliased = new Alias(existingSlot, aliasName);
                        projects.add(aliased);
                        hornCtx.getScalarUidToSlot().put(projUid, aliased.toSlot());
                        continue;
                    }
                }
                projects.add(existingSlot);
                continue;
            }
            Expression projExpr = exprTranslator.translate(projOp);
            String aliasName = Horn2DorisUtils.getRootScalar(rewriteList.get(i))
                    .getScalar_union().getScalar_column_ref().getColumn_name();
            NamedExpression namedProj = new Alias(projExpr, aliasName);
            projects.add(namedProj);
            hornCtx.getScalarUidToSlot().put(projUid, namedProj.toSlot());
        }

        // GROUPING() 函数还原:Doris 不能在 Project 里算 Grouping(),故把含 GroupingScalarFunction
        // 的 Alias 下推进 child Repeat.outputExpressions(由 RepeatNode 原生物化),本 Project 对应
        // 列退化成透传 slot;这层 Project 保留作 GROUPING_ID 的桥(Repeat.getOutput() 不含 groupingId)。
        if (child instanceof PhysicalRepeat
                && projects.stream().anyMatch(p -> p.containsType(GroupingScalarFunction.class))) {
            PhysicalRepeat<?> repeat = (PhysicalRepeat<?>) child;
            List<NamedExpression> repeatOutput = new ArrayList<>(repeat.getOutputExpressions());
            List<NamedExpression> newProjects = new ArrayList<>(projects.size());
            for (NamedExpression p : projects) {
                if (p.containsType(GroupingScalarFunction.class)) {
                    repeatOutput.add(p);              // Grouping(..) AS GROUPING_PREFIX_* 还原进 Repeat
                    newProjects.add(p.toSlot());      // Project 退化成透传该 slot
                } else {
                    newProjects.add(p);
                }
            }
            // Doris 计划不可变:withAggOutput 重建带新 output 的 Repeat,resetLogicalProperties 让 output 随新增列重算
            child = repeat.withAggOutput(repeatOutput).resetLogicalProperties();
            projects = newProjects;
        }

        // logicalProperties 传 null → AbstractPhysicalPlan ctor 转 Optional.empty()
        // → AbstractPlan 自动 LazyCompute(this::computeLogicalProperties)
        // → PhysicalProject.computeOutput() = projects.map(toSlot)
        return new PhysicalProject<>(projects, null, child);
    }

    /** Translate a Horn Motion node to a Doris PhysicalDistribute (Fragment boundary). */
    private PhysicalPlan buildMotion(TExpression texpr, PhysicalPlan child) throws UserException {
        TPhysicalMotion tmotion = texpr.getOp().getOp_union().getPhysical_motion();
        TDistributionSpec tspec = tmotion.getDistribution_spec();
        // Merge gather: horn PhysicalMotionGather 自带 sort_info 表示跨 fragment 多路归并,
        // 反译成 MERGE_SORT + PhysicalDistribute[Gather] 两层(不需包 LOCAL_SORT 占位)。
        if (tmotion.isSetSort_info()
                && tmotion.getSort_info().getOrder_by_exprs() != null
                && !tmotion.getSort_info().getOrder_by_exprs().isEmpty()) {
            List<OrderKey> orderKeys = buildOrderKeys(tmotion.getSort_info());
            PhysicalPlan gather = new PhysicalDistribute<>(toDistributionSpec(tspec), child);
            return new PhysicalQuickSort<>(orderKeys, SortPhase.MERGE_SORT, null, gather);
        }
        // 普通 Motion (无 merge gather):单纯 PhysicalDistribute。
        return new PhysicalDistribute<>(toDistributionSpec(tspec), child);
    }

    private PhysicalPlan buildJoin(TExpression texpr, PhysicalPlan left, PhysicalPlan right)
            throws UserException {
        TOperatorType opType = texpr.getOp().getOp_type();
        JoinType joinType = Horn2DorisUtils.getJoinType(opType);
        boolean useHash = Horn2DorisUtils.isHashJoin(opType);

        TPhysicalJoin tjoin = texpr.getOp().getOp_union().getPhysical_join();
        List<Expression> hashConjuncts = exprTranslator.translateList(tjoin.getEqual_join_predicate_list());
        List<Expression> otherConjuncts = exprTranslator.translateList(tjoin.getOther_join_conjuncts());

        // mark join(IN / EXISTS-OR 子查询):重建 MarkJoinSlotReference + mark 谓词。
        // 必须是 MarkJoinSlotReference 子类,否则 isMarkJoin()=false、不 append mark 列。
        // slot 身份经 scalarUidToSlot[uid] 复用:首次见到 uid 新建并注册,上层 Filter 同 uid 复用。
        Optional<MarkJoinSlotReference> markSlotRef = Optional.empty();
        List<Expression> markConjuncts = ExpressionUtils.EMPTY_CONDITION;
        if (tjoin.isSetMark_slot() && !tjoin.getMark_slot().isEmpty()) {
            // mark_slot 是单元素 list(同 grouping_id_func)。
            TScalar markScalar = Horn2DorisUtils.getRootScalar(tjoin.getMark_slot().get(0));
            long markUid = markScalar.getScalar_unique_id().getUnique_id();
            Slot existing = hornCtx.getScalarUidToSlot().get(markUid);
            MarkJoinSlotReference mjsr;
            if (existing instanceof MarkJoinSlotReference) {
                mjsr = (MarkJoinSlotReference) existing;
            } else {
                String markName = markScalar.getScalar_union().getScalar_column_ref().getColumn_name();
                mjsr = new MarkJoinSlotReference(
                        hornCtx.getCascadesContext().getStatementContext().getNextExprId(),
                        markName, false);
                hornCtx.getScalarUidToSlot().put(markUid, mjsr);
            }
            markSlotRef = Optional.of(mjsr);
            if (tjoin.isSetMark_join_predicate_list()
                    && tjoin.getMark_join_predicate_list() != null
                    && !tjoin.getMark_join_predicate_list().isEmpty()) {
                markConjuncts = exprTranslator.translateList(tjoin.getMark_join_predicate_list());
            }
        }

        // mark join:传 null logicalProperties → 惰性 computeOutput() 会 append mark boolean;
        // 预建 props 不含 mark 会漏列。非 mark join 仍用预建 props(保持既有行为)。
        LogicalProperties props = markSlotRef.isPresent() ? null : new LogicalProperties(
                () -> JoinUtils.getJoinOutput(joinType, left, right),
                () -> DataTrait.EMPTY_TRAIT);
        // PhysicalProperties 由 buildHelper 末尾 attachProperties 统一处理
        // (join driving-side inherit 规则;见 attachProperties 注释)
        PhysicalPlan join;
        if (useHash) {
            join = new PhysicalHashJoin<>(joinType, hashConjuncts, otherConjuncts,
                    markConjuncts, new DistributeHint(DistributeType.NONE), markSlotRef,
                    props, left, right);
        } else {
            join = new PhysicalNestedLoopJoin<>(joinType, hashConjuncts, otherConjuncts,
                    markConjuncts, markSlotRef, props, left, right);
        }
        // outer join 输出侧 nullable 升格回写由 buildHelper 末尾 syncMapWithPlanOutput 接管。
        return join;
    }

    /**
     * Convert a Horn TDistributionSpec to a Doris DistributionSpec.
     * 专给 buildMotion 用,返回 motion 的 target 分布(scan 节点分布由 buildScan 单独构造)。
     * 仅处理 motion derive 可能 emit 的 5 种类型;require-only 标签(kAny/kNonSingleton)出现即 bug,直接 throw。
     */
    private DistributionSpec toDistributionSpec(TDistributionSpec tspec) {
        switch (tspec.getDistribution_type()) {
            case kSingleton:
                // motion gather 之后单点 → DistributionSpecGather。
                return DistributionSpecGather.INSTANCE;
            case kReplicated:
            case kUniversal:
                // kReplicated: Broadcast target,每 instance 拿全份。
                // kUniversal: horn "everywhere"(系统表/常量),Doris 无此概念,Replicated 功能等价。
                return DistributionSpecReplicated.INSTANCE;
            case kHashed: {
                // kHashed 一定带 hash_columns;每个 hash 列的 unique_id 一定已被
                // child plan 登记到 scalarUidToSlot 且是 SlotReference(直接 cast)。
                List<ExprId> hashExprIds = new ArrayList<>();
                for (TOperator hashCol : tspec.getHash_columns()) {
                    long hornScalarUid = Horn2DorisUtils.getRootScalar(hashCol).getScalar_unique_id().getUnique_id();
                    hashExprIds.add(((SlotReference) hornCtx.getScalarUidToSlot().get(hornScalarUid)).getExprId());
                }
                // 带 DorisHashSpec → 绑定目标表 storage 桶布局。shuffleType 按 is_natural 分:
                //   true(scan 原生分布)→ NATURAL;false(motion shuffle 产物)→ STORAGE_BUCKETED。
                // 三要素(tableId/baseIndexId/partitionIds)与 scan 侧 buildScanDistributionSpec 逐字段对齐。
                if (tspec.isSetSpecial_hash_spec()
                        && tspec.getSpecial_hash_spec().getKind() == TSpecialHashSpecKind.kDoris) {
                    TDorisHashSpec storageBucketSpec = tspec.getSpecial_hash_spec().getDoris_hash_spec();
                    long storageBucketTableId = storageBucketSpec.getTable_id();
                    OlapTable storageBucketTable = (OlapTable) Env.getCurrentInternalCatalog()
                            .getTableByTableId(storageBucketTableId);
                    ShuffleType shuffleType = storageBucketSpec.isIs_natural()
                            ? ShuffleType.NATURAL : ShuffleType.STORAGE_BUCKETED;
                    return new DistributionSpecHash(
                            hashExprIds, shuffleType,
                            storageBucketTableId,
                            storageBucketTable.getBaseIndexId(),
                            new LinkedHashSet<>(storageBucketSpec.getSelected_partition_ids()));
                }
                // 无 special(Empty)→ 普通运行时 hash 重分布 → EXECUTION_BUCKETED
                //   (BE HASH_PARTITIONED)。NATURAL 仅描述 scan 自带桶分布、不能作 shuffle target。
                return new DistributionSpecHash(hashExprIds, ShuffleType.EXECUTION_BUCKETED);
            }
            case kRandom:
                // PhysicalMotionRandom 主动 scramble 到任意节点。DistributionSpecExecutionAny
                // 是 Doris "必 shuffle 但 target 任意"的专用 spec,跟 random target 语义吻合。
                return DistributionSpecExecutionAny.INSTANCE;
            default:
                // kAny / kNonSingleton: horn 端 require-only 标签,motion derive
                //   emit 这俩 = horn bug。kSentinel: thrift 哨兵值,合法 plan
                //   不该带。其它未知值同样异常。
                throw new IllegalStateException(
                        "Unexpected horn motion distribution type on derive path: "
                                + tspec.getDistribution_type());
        }
    }

    // PhysicalSort 反译成 Doris LOCAL_SORT phase(单 fragment 内完整排序)。
    // 上方若有 PhysicalMotionGather 配套,buildMotion 会自动包 MERGE_SORT + Distribute[Gather]。
    private PhysicalPlan buildSort(TExpression texpr, PhysicalPlan child) throws UserException {
        TPhysicalSort tsort = texpr.getOp().getOp_union().getPhysical_sort();
        TOrderSpec sortInfo = tsort.getSort_info();
        if (sortInfo == null) {
            throw new UserException("Horn output: PhysicalSort missing sort_info");
        }
        List<OrderKey> orderKeys = buildOrderKeys(sortInfo);
        return new PhysicalQuickSort<>(orderKeys, SortPhase.LOCAL_SORT, null, child);
    }

    // PhysicalLimit 反译:
    //   带 order_spec → PhysicalTopN (Global→MERGE_SORT, Local→LOCAL_SORT);若 child 是
    //     PhysicalQuickSort 则跳过它(Limit 自带 order_spec 已表达 sort+truncate)。
    //   无 order_spec → 1:1 PhysicalLimit
    private PhysicalPlan buildLimit(TExpression texpr, PhysicalPlan child) {
        TPhysicalLimit tlimit = texpr.getOp().getOp_union().getPhysical_limit();
        long limit = tlimit.isSetLimit() ? tlimit.getLimit() : -1;
        long offset = tlimit.isSetOffset() ? tlimit.getOffset() : 0;
        boolean isGlobal = tlimit.isSetIs_global_limit() ? tlimit.isIs_global_limit() : true;
        if (tlimit.isSetOrder_spec()
                && tlimit.getOrder_spec().getOrder_by_exprs() != null
                && !tlimit.getOrder_spec().getOrder_by_exprs().isEmpty()) {
            List<OrderKey> orderKeys = buildOrderKeys(tlimit.getOrder_spec());
            SortPhase sortPhase = isGlobal ? SortPhase.MERGE_SORT : SortPhase.LOCAL_SORT;
            PhysicalPlan topnChild = child instanceof PhysicalQuickSort
                    ? (PhysicalPlan) ((PhysicalQuickSort<?>) child).child(0)
                    : child;
            return new PhysicalTopN<>(orderKeys, limit, offset, sortPhase, null, topnChild);
        }
        LimitPhase phase = isGlobal ? LimitPhase.GLOBAL : LimitPhase.LOCAL;
        return new PhysicalLimit<>(limit, offset, phase, null, child);
    }

    /**
     * 反译 horn PhysicalHashAgg / PhysicalScalarAgg → Doris PhysicalHashAggregate。三态 phase 映射:
     *
     *   is_split | is_local | AggregateParam              | agg_funcs 形态     | 动作
     *   ---------+----------+-----------------------------+--------------------+---------
     *   true     | true     | (LOCAL,  INPUT_TO_BUFFER)   | 原函数 ScalarAggFn | 注册 map
     *   true     | false    | (GLOBAL, BUFFER_TO_RESULT)  | ColumnRef          | lookup map
     *   false    | *        | (GLOBAL, INPUT_TO_RESULT)   | 原函数             | 单层直出
     *
     * 第三态覆盖 CombineTwoAdjacentAggs(撤销 split)的产物;scalar agg 走同一路径。
     */
    private PhysicalPlan buildAgg(TExpression texpr, PhysicalPlan child) throws UserException {
        TPhysicalAgg tagg = texpr.getOp().getOp_union().getPhysical_agg();
        boolean isSplit = tagg.isIs_split();
        boolean isLocal = tagg.isIs_local_agg();

        // group_by:列引用(极少表达式),反译后透传进 output(两阶段 group key slot 不变)
        List<Expression> groupByExprs = new ArrayList<>();
        List<NamedExpression> output = new ArrayList<>();
        if (tagg.getGroup_by_exprs() != null) {
            for (TOperator gbOp : tagg.getGroup_by_exprs()) {
                Expression gb = exprTranslator.translate(gbOp);
                groupByExprs.add(gb);
                if (gb instanceof NamedExpression) {
                    output.add((NamedExpression) gb);
                } else {
                    output.add(new Alias(gb));
                }
            }
        }

        // 循环里只拼 output 并首次绑定 uid → slot(供上游 buildFilter/buildProject 按 uid 命中)。
        // ctor 的 adjustNullableForOutputs 会替换输出对象、改 nullable,交 buildHelper 末尾
        // syncMapWithPlanOutput 接管。scalarUidToAggFunc 的 fn 不在 plan.getOutput() 里,构造后单独刷新。
        AggregateParam param;
        if (isSplit && isLocal) {
            // ---- local: 原函数 → AggregateExpression(INPUT_TO_BUFFER) 包 Alias 出 buffer slot ----
            param = new AggregateParam(AggPhase.LOCAL, AggMode.INPUT_TO_BUFFER);
            if (tagg.getAggregate_functions() != null) {
                for (TOperator fnOp : tagg.getAggregate_functions()) {
                    long uid = Horn2DorisUtils.getRootScalar(fnOp).getScalar_unique_id().getUnique_id();
                    Expression fnExpr = exprTranslator.translate(fnOp);
                    if (!(fnExpr instanceof AggregateFunction)) {
                        throw new UserException("Horn: local agg expected AggregateFunction, got "
                                + fnExpr.getClass().getSimpleName());
                    }
                    AggregateFunction fn = (AggregateFunction) fnExpr;
                    Alias buffer = new Alias(new AggregateExpression(fn, param));
                    output.add(buffer);
                    hornCtx.getScalarUidToSlot().put(uid, buffer.toSlot());
                    hornCtx.getScalarUidToAggFunc().put(uid, fn);
                }
            }
        } else if (isSplit) {
            // ---- global: thrift 上是 ColumnRef,按 uid lookup 原函数 + buffer slot
            // (local 节点 buildAgg 尾部已注册构造后真值,bottom-up 先于 global)----
            param = new AggregateParam(AggPhase.GLOBAL, AggMode.BUFFER_TO_RESULT);
            if (tagg.getAggregate_functions() != null) {
                for (TOperator fnOp : tagg.getAggregate_functions()) {
                    long uid = Horn2DorisUtils.getRootScalar(fnOp).getScalar_unique_id().getUnique_id();
                    AggregateFunction fn = hornCtx.getScalarUidToAggFunc().get(uid);
                    Slot buffer = hornCtx.getScalarUidToSlot().get(uid);
                    if (fn == null || buffer == null) {
                        throw new UserException(
                                "Horn: global agg cannot find local fn/buffer for uid=" + uid);
                    }
                    Alias result = new Alias(new AggregateExpression(fn, param, buffer));
                    output.add(result);
                    hornCtx.getScalarUidToSlot().put(uid, result.toSlot());
                }
            }
        } else {
            // ---- 单层 (CombineTwoAdjacentAggs 撤销 split 或未拆): INPUT_TO_RESULT ----
            param = new AggregateParam(AggPhase.GLOBAL, AggMode.INPUT_TO_RESULT);
            if (tagg.getAggregate_functions() != null) {
                for (TOperator fnOp : tagg.getAggregate_functions()) {
                    long uid = Horn2DorisUtils.getRootScalar(fnOp).getScalar_unique_id().getUnique_id();
                    Expression fnExpr = exprTranslator.translate(fnOp);
                    if (!(fnExpr instanceof AggregateFunction)) {
                        throw new UserException("Horn: single agg expected AggregateFunction, got "
                                + fnExpr.getClass().getSimpleName());
                    }
                    AggregateFunction fn = (AggregateFunction) fnExpr;
                    Alias result = new Alias(new AggregateExpression(fn, param));
                    output.add(result);
                    hornCtx.getScalarUidToSlot().put(uid, result.toSlot());
                }
            }
        }

        PhysicalHashAggregate<PhysicalPlan> aggPlan = new PhysicalHashAggregate<>(
                groupByExprs, output, Optional.empty(),
                param, false /* maybeUsingStream */, null /* logicalProperties */, child);

        // nullable 升格回写由 buildHelper 末尾 syncMapWithPlanOutput 接管。
        // scalarUidToAggFunc 无需在此再注册:local 已 put OLD fn,global ctor 的
        // adjustNullableForOutputs 幂等补救(groupBy 空性一致 → fn 重建为等价新版)。
        return aggPlan;
    }

    /**
     * 反译 horn PhysicalWindow → Doris PhysicalWindow。
     * forward 已保证:analytic_functions 非空、共享同一 (partition, order, frame) spec、
     * window_frame 一定 emit,故本函数不再兜底空检查 / frame 默认值 / spec 拆组。
     * alias name 用 {@code fnExpr.toSql()}(TPhysicalWindow 不携带原始 SQL alias);
     * isSkew 暂固定 false。
     */
    private PhysicalPlan buildWindow(TExpression texpr, PhysicalPlan child) throws UserException {
        TPhysicalWindow twindow = texpr.getOp().getOp_union().getPhysical_window();

        List<Expression> partitionKeys = twindow.isSetPartition_exprs()
                ? exprTranslator.translateList(twindow.getPartition_exprs())
                : ImmutableList.of();

        List<OrderExpression> orderKeys = new ArrayList<>();
        if (twindow.isSetOrder_by_exprs()) {
            for (OrderKey orderKey : buildOrderKeys(twindow.getOrder_by_exprs())) {
                orderKeys.add(new OrderExpression(orderKey));
            }
        }

        // frame 经 translateScalarNode 的 kWindowFrame case 分发(唯一返回 WindowFrame),直接强转。
        WindowFrame frame = (WindowFrame) exprTranslator.translate(twindow.getWindow_frame().get(0));

        List<NamedExpression> windowExpressions = new ArrayList<>();
        for (TOperator fnOp : twindow.getAnalytic_functions()) {
            TScalar rootScalar = Horn2DorisUtils.getRootScalar(fnOp);
            long uid = rootScalar.getScalar_unique_id().getUnique_id();
            Expression fnExpr = exprTranslator.translate(fnOp);
            WindowExpression wExpr = new WindowExpression(
                    fnExpr, partitionKeys, orderKeys, frame, false /* isSkew */);
            Alias windowAlias = new Alias(wExpr, fnExpr.toSql());
            windowExpressions.add(windowAlias);
            hornCtx.getScalarUidToSlot().put(uid, windowAlias.toSlot());
        }

        WindowFrameGroup wfg = new WindowFrameGroup(windowExpressions.get(0));
        for (int i = 1; i < windowExpressions.size(); i++) {
            wfg.addGroup(windowExpressions.get(i));
        }

        // 跟 Doris 主线一致:requireProperties 只是占位符,真正的 child 需求由
        // RequestPropertyDeriver.visitPhysicalWindow 从 partition/order 动态推导。
        RequireProperties requireProperties = RequireProperties.followParent();

        return new PhysicalWindow<>(
                wfg, requireProperties, windowExpressions, false /* isSkew */,
                null /* logicalProperties */, child);
    }

    /**
     * thrift kPhysicalGrouping → Doris PhysicalRepeat.
     * GROUPING_ID slot nullable=false to match Doris NormalizeRepeat / BE repeat_operator DCHECK;
     * other grouping_expr_list slots keep nullable as-is. col_to_grouping_col_map is built on BE,
     * not read here.
     */
    private PhysicalPlan buildRepeat(TExpression texpr, PhysicalPlan child) throws UserException {
        TPhysicalGrouping tg = texpr.getOp().getOp_union().getPhysical_grouping();

        // 1. grouping_set_list → List<List<Expression>>
        List<List<Expression>> groupingSets = new ArrayList<>(tg.getGrouping_set_list().size());
        for (List<TOperator> tset : tg.getGrouping_set_list()) {
            groupingSets.add(exprTranslator.translateList(tset));
        }

        // 2. grouping_id_func:单元素 list → SlotReference(fresh ExprId 由 forward 端 emit 保留)
        //    强制 nullable=false 对齐 BE repeat_operator 的 GROUPING_ID 非空契约。
        TOperator tGroupingIdFn = tg.getGrouping_id_func().get(0);
        SlotReference groupingId = (SlotReference) exprTranslator.translate(tGroupingIdFn);
        groupingId = groupingId.withNullable(false);
        long groupingIdUid = Horn2DorisUtils.getRootScalar(tGroupingIdFn)
                .getScalar_unique_id().getUnique_id();
        hornCtx.getScalarUidToSlot().put(groupingIdUid, groupingId);

        // 3. outputExpressions = grouping_expr_list ∪ aggregate_function_inputs
        //    每个 slot 注册到 scalarUidToSlot 让上层 group_by 引用命中;nullable 保留已有结果,
        //    backward 不再二次强制。
        List<NamedExpression> outputExpressions = new ArrayList<>();
        for (TOperator colOp : tg.getGrouping_expr_list()) {
            SlotReference slot = (SlotReference) exprTranslator.translate(colOp);
            outputExpressions.add(slot);
            long uid = Horn2DorisUtils.getRootScalar(colOp).getScalar_unique_id().getUnique_id();
            hornCtx.getScalarUidToSlot().put(uid, slot);
        }
        if (tg.isSetAggregate_function_inputs()) {
            for (TOperator colOp : tg.getAggregate_function_inputs()) {
                SlotReference slot = (SlotReference) exprTranslator.translate(colOp);
                outputExpressions.add(slot);
                long uid = Horn2DorisUtils.getRootScalar(colOp).getScalar_unique_id().getUnique_id();
                hornCtx.getScalarUidToSlot().put(uid, slot);
            }
        }

        // 4. PhysicalRepeat ctor(主线签名不含 col_to_grouping_col_map);logicalProperties 传 null:
        //    buildHelper 末尾 setPhysicalProperties + syncMapWithPlanOutput 统一处理。
        return new PhysicalRepeat<>(
                groupingSets, outputExpressions, groupingId,
                null /* logicalProperties */, child);
    }

    /** 把 horn TOrderSpec.order_by_exprs 翻译成 Doris OrderKey 列表。 */
    private List<OrderKey> buildOrderKeys(TOrderSpec sortInfo) {
        List<OrderKey> orderKeys = new ArrayList<>();
        List<TOperator> exprList = sortInfo.getOrder_by_exprs();
        List<Boolean> ascList = sortInfo.getIs_asc_order();
        List<Boolean> nullsFirstList = sortInfo.getNulls_first();
        if (exprList == null) {
            return orderKeys;
        }
        for (int i = 0; i < exprList.size(); ++i) {
            Expression expr = exprTranslator.translate(exprList.get(i));
            boolean asc = i < ascList.size() ? ascList.get(i) : true;
            boolean nullsFirst = i < nullsFirstList.size() ? nullsFirstList.get(i) : !asc;
            orderKeys.add(new OrderKey(expr, asc, nullsFirst));
        }
        return orderKeys;
    }

    // getRootScalar 已移到 Horn2DorisUtils(跨 builder 复用:DorisExpressionBuilder.buildFrameBoundary 也需要)

}