PaimonRowChangePlanBuilder.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.nereids.rules.analysis;

import org.apache.doris.catalog.Column;
import org.apache.doris.common.util.Util;
import org.apache.doris.datasource.paimon.PaimonRowChangeOperation;
import org.apache.doris.datasource.paimon.PaimonWriteTarget;
import org.apache.doris.nereids.CascadesContext;
import org.apache.doris.nereids.analyzer.Scope;
import org.apache.doris.nereids.analyzer.UnboundAlias;
import org.apache.doris.nereids.analyzer.UnboundSlot;
import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.trees.expressions.Alias;
import org.apache.doris.nereids.trees.expressions.And;
import org.apache.doris.nereids.trees.expressions.Cast;
import org.apache.doris.nereids.trees.expressions.EqualTo;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.IsNull;
import org.apache.doris.nereids.trees.expressions.LessThanEqual;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.expressions.Not;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.WindowExpression;
import org.apache.doris.nereids.trees.expressions.functions.agg.Count;
import org.apache.doris.nereids.trees.expressions.functions.scalar.AssertTrue;
import org.apache.doris.nereids.trees.expressions.functions.scalar.If;
import org.apache.doris.nereids.trees.expressions.literal.BigIntLiteral;
import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral;
import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral;
import org.apache.doris.nereids.trees.plans.commands.info.PaimonRowChangeSpec;
import org.apache.doris.nereids.trees.plans.commands.merge.MergeMatchedClause;
import org.apache.doris.nereids.trees.plans.commands.merge.MergeNotMatchedClause;
import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
import org.apache.doris.nereids.trees.plans.logical.LogicalWindow;
import org.apache.doris.nereids.types.BigIntType;
import org.apache.doris.nereids.types.DataType;
import org.apache.doris.nereids.types.IntegerType;
import org.apache.doris.nereids.types.TinyIntType;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

/** Builds Paimon changelog projections while binding against the current write target. */
final class PaimonRowChangePlanBuilder {
    private static final String BRANCH_LABEL = "__DORIS_PAIMON_MERGE_BRANCH__";
    private static final String MATCH_COUNT = "__DORIS_PAIMON_MERGE_MATCH_COUNT__";
    private static final String INSERT_COUNT = "__DORIS_PAIMON_MERGE_INSERT_COUNT__";
    private static final String INSERT_MARKER = "__DORIS_PAIMON_MERGE_INSERT_MARKER__";
    private static final String INSERT_KEY_PREFIX = "__DORIS_PAIMON_MERGE_INSERT_KEY_";

    private PaimonRowChangePlanBuilder() {
    }

    static LogicalProject<?> build(
            PaimonWriteTarget target, PaimonRowChangeSpec spec, LogicalPlan child,
            CascadesContext cascadesContext) {
        checkCapabilities(target, spec);
        LogicalProject<?> project;
        if (spec instanceof PaimonRowChangeSpec.Update) {
            project = buildUpdate(target,
                    (PaimonRowChangeSpec.Update) spec, child);
        } else if (spec instanceof PaimonRowChangeSpec.Delete) {
            project = buildDelete(target, (PaimonRowChangeSpec.Delete) spec, child);
        } else if (spec instanceof PaimonRowChangeSpec.Merge) {
            project = buildMerge(target,
                    (PaimonRowChangeSpec.Merge) spec, child, cascadesContext);
        } else {
            throw new AnalysisException("Unsupported Paimon row-change specification: "
                    + spec.getClass().getSimpleName());
        }
        return project;
    }

    private static void checkCapabilities(PaimonWriteTarget target, PaimonRowChangeSpec spec) {
        if (spec instanceof PaimonRowChangeSpec.Update) {
            PaimonRowChangeCapabilities.checkUpdate(target,
                    updatedColumns(((PaimonRowChangeSpec.Update) spec).getAssignments()));
            return;
        }
        if (spec instanceof PaimonRowChangeSpec.Delete) {
            PaimonRowChangeCapabilities.checkDelete(target);
            return;
        }
        if (!(spec instanceof PaimonRowChangeSpec.Merge)) {
            throw new AnalysisException("Unsupported Paimon row-change specification: "
                    + spec.getClass().getSimpleName());
        }
        PaimonRowChangeSpec.Merge merge = (PaimonRowChangeSpec.Merge) spec;
        Set<String> updatedColumns = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        boolean containsUpdate = false;
        boolean containsDelete = false;
        for (MergeMatchedClause clause : merge.getMatchedClauses()) {
            containsDelete |= clause.isDelete();
            containsUpdate |= !clause.isDelete();
            updatedColumns.addAll(updatedColumns(clause.getAssignments()));
        }
        PaimonRowChangeCapabilities.checkMerge(
                target, updatedColumns, containsUpdate, containsDelete);
    }

    private static Set<String> updatedColumns(List<EqualTo> assignments) {
        Set<String> columns = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        for (EqualTo assignment : assignments) {
            List<String> parts = ((UnboundSlot) assignment.left()).getNameParts();
            columns.add(parts.get(parts.size() - 1));
        }
        return columns;
    }

    private static LogicalProject<?> buildUpdate(PaimonWriteTarget target,
            PaimonRowChangeSpec.Update update, LogicalPlan child) {
        Map<String, Expression> changes = Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
        for (EqualTo assignment : update.getAssignments()) {
            List<String> parts = ((UnboundSlot) assignment.left()).getNameParts();
            String column = parts.get(parts.size() - 1);
            if (changes.put(column, assignment.right()) != null) {
                throw new AnalysisException("Duplicate column name in Paimon UPDATE: " + column);
            }
        }

        String targetName = update.getTableAlias() != null
                ? update.getTableAlias()
                : Util.getTempTableDisplayName(target.getDorisTable().getName());
        List<NamedExpression> projects = new ArrayList<>();
        projects.add(new UnboundAlias(new TinyIntLiteral(PaimonRowChangeOperation.UPDATE),
                PaimonRowChangeOperation.OPERATION_COLUMN));
        for (Column column : target.getSchema()) {
            Expression value = changes.remove(column.getName());
            if (value == null) {
                value = new UnboundSlot(targetName, column.getName());
            }
            projects.add(value instanceof NamedExpression
                    ? (NamedExpression) value : new UnboundAlias(value, column.getName()));
        }
        if (!changes.isEmpty()) {
            throw new AnalysisException(
                    "Unknown column in Paimon UPDATE: " + String.join(", ", changes.keySet()));
        }
        return new LogicalProject<>(projects, child);
    }

    private static LogicalProject<?> buildDelete(PaimonWriteTarget target,
            PaimonRowChangeSpec.Delete delete, LogicalPlan child) {
        String targetName = delete.getTableAlias() != null
                ? delete.getTableAlias()
                : Util.getTempTableDisplayName(target.getDorisTable().getName());
        List<NamedExpression> projects = new ArrayList<>();
        projects.add(new UnboundAlias(new TinyIntLiteral(PaimonRowChangeOperation.DELETE),
                PaimonRowChangeOperation.OPERATION_COLUMN));
        for (Column column : target.getSchema()) {
            projects.add(new UnboundSlot(targetName, column.getName()));
        }
        return new LogicalProject<>(projects, child);
    }

    private static LogicalProject<?> buildMerge(PaimonWriteTarget target,
            PaimonRowChangeSpec.Merge merge, LogicalPlan child, CascadesContext cascadesContext) {
        LogicalPlan plan = child;
        Expression targetPresent = targetPresence(plan, target, merge.getTargetNameInPlan());
        if (!merge.getMatchedClauses().isEmpty()) {
            Alias matchCount = generateTargetMatchCount(
                    plan, target, merge.getTargetNameInPlan());
            plan = new LogicalWindow<>(ImmutableList.of(matchCount), plan);
            targetPresent = new And(targetPresent, new AssertTrue(
                    new LessThanEqual(matchCount.toSlot(), new BigIntLiteral(1)),
                    new VarcharLiteral("Paimon MERGE matched one target row with multiple source rows")));
        }
        ExpressionAnalyzer analyzer = new ExpressionAnalyzer(
                plan, new Scope(plan.getOutput()), cascadesContext, true, false);
        Alias unboundBranchLabel = generateBranchLabel(merge, targetPresent);
        Alias branchLabel = new Alias(
                analyzer.analyze(unboundBranchLabel.child()), BRANCH_LABEL);
        Slot branchLabelSlot = branchLabel.toSlot();
        List<NamedExpression> branchOutputs = new ArrayList<>(plan.getOutput());
        branchOutputs.add(branchLabel);
        plan = new LogicalProject<>(branchOutputs, plan);
        plan = new LogicalFilter<>(
                ImmutableSet.of(new Not(new IsNull(branchLabelSlot))), plan);

        List<List<Expression>> branchProjections = new ArrayList<>();
        for (MergeMatchedClause clause : merge.getMatchedClauses()) {
            branchProjections.add(clause.isDelete()
                    ? buildDeleteProjection(target, merge.getTargetNameInPlan())
                    : buildUpdateProjection(target, merge, clause));
        }
        for (MergeNotMatchedClause clause : merge.getNotMatchedClauses()) {
            branchProjections.add(buildInsertProjection(target, clause));
        }
        if (branchProjections.isEmpty()) {
            throw new AnalysisException("Paimon MERGE requires at least one WHEN clause");
        }
        for (List<Expression> branch : branchProjections) {
            for (int i = 0; i < branch.size(); i++) {
                branch.set(i, analyzer.analyze(branch.get(i)));
            }
        }

        List<String> outputNames = new ArrayList<>();
        outputNames.add(PaimonRowChangeOperation.OPERATION_COLUMN);
        List<DataType> outputTypes = new ArrayList<>();
        outputTypes.add(TinyIntType.INSTANCE);
        for (Column column : target.getSchema()) {
            outputNames.add(column.getName());
            outputTypes.add(DataType.fromCatalogType(column.getType()));
        }
        if (!merge.getNotMatchedClauses().isEmpty()) {
            plan = checkInsertPrimaryKeyUniqueness(
                    target, plan, outputNames, outputTypes, branchProjections, branchLabelSlot);
        }
        return new LogicalProject<>(
                generateFinalProjections(
                        outputNames, outputTypes, branchProjections, branchLabelSlot), plan);
    }

    private static LogicalPlan checkInsertPrimaryKeyUniqueness(
            PaimonWriteTarget target, LogicalPlan plan, List<String> outputNames, List<DataType> outputTypes,
            List<List<Expression>> branchProjections, Slot branchLabel) {
        List<Expression> partitionKeys = new ArrayList<>();
        List<NamedExpression> projects = new ArrayList<>(plan.getOutput());
        for (String primaryKey : target.getTable().primaryKeys()) {
            int column = -1;
            for (int i = 1; i < outputNames.size(); i++) {
                if (outputNames.get(i).equalsIgnoreCase(primaryKey)) {
                    column = i;
                    break;
                }
            }
            if (column < 0) {
                throw new AnalysisException(
                        "Unable to resolve Paimon MERGE primary key column '" + primaryKey + "'");
            }
            DataType dataType = outputTypes.get(column);
            Alias insertKey = new Alias(generateFinalExpression(
                    column, branchProjections, branchLabel, dataType),
                    INSERT_KEY_PREFIX + partitionKeys.size() + "__");
            projects.add(insertKey);
            partitionKeys.add(insertKey.toSlot());
        }
        Expression insertedRow = new If(
                new EqualTo(generateFinalExpression(
                                0, branchProjections, branchLabel, TinyIntType.INSTANCE),
                        new TinyIntLiteral(PaimonRowChangeOperation.INSERT)),
                new BigIntLiteral(1), new NullLiteral(BigIntType.INSTANCE));
        Alias insertMarker = new Alias(insertedRow, INSERT_MARKER);
        projects.add(insertMarker);
        plan = new LogicalProject<>(projects, plan);
        WindowExpression countInserts = new WindowExpression(
                new Count(insertMarker.toSlot()), partitionKeys, ImmutableList.of());
        Alias insertCount = new Alias(countInserts, INSERT_COUNT);
        plan = new LogicalWindow<>(ImmutableList.of(insertCount), plan);
        plan = new LogicalFilter<>(ImmutableSet.of(new AssertTrue(
                new LessThanEqual(insertCount.toSlot(), new BigIntLiteral(1)),
                new VarcharLiteral(
                        "Paimon MERGE attempted to insert multiple rows with the same primary key"))),
                plan);
        return plan;
    }

    private static Expression targetPresence(LogicalPlan plan,
            PaimonWriteTarget target, List<String> targetNameInPlan) {
        String primaryKey = target.getTable().primaryKeys().get(0);
        return new Not(new IsNull(findTargetSlot(plan, targetNameInPlan, primaryKey)));
    }

    private static Alias generateTargetMatchCount(LogicalPlan plan,
            PaimonWriteTarget target, List<String> targetNameInPlan) {
        List<Expression> partitionKeys = new ArrayList<>();
        for (String primaryKey : target.getTable().primaryKeys()) {
            partitionKeys.add(findTargetSlot(plan, targetNameInPlan, primaryKey));
        }
        WindowExpression countMatches = new WindowExpression(
                new Count(partitionKeys.get(0)),
                partitionKeys, ImmutableList.of());
        return new Alias(countMatches, MATCH_COUNT);
    }

    private static Slot findTargetSlot(
            LogicalPlan plan, List<String> targetNameInPlan, String columnName) {
        List<Slot> matches = plan.getOutput().stream()
                .filter(slot -> slot.getName().equalsIgnoreCase(columnName))
                .filter(slot -> qualifierEndsWith(slot.getQualifier(), targetNameInPlan))
                .collect(ImmutableList.toImmutableList());
        if (matches.size() != 1) {
            throw new AnalysisException("Unable to resolve Paimon MERGE target column '"
                    + String.join(".", targetNameInPlan) + "." + columnName + "'");
        }
        return matches.get(0);
    }

    private static boolean qualifierEndsWith(List<String> qualifier, List<String> suffix) {
        if (qualifier.size() < suffix.size()) {
            return false;
        }
        int offset = qualifier.size() - suffix.size();
        for (int i = 0; i < suffix.size(); i++) {
            if (!qualifier.get(offset + i).equalsIgnoreCase(suffix.get(i))) {
                return false;
            }
        }
        return true;
    }

    private static Alias generateBranchLabel(
            PaimonRowChangeSpec.Merge merge, Expression targetPresent) {
        Expression matchedLabel = new NullLiteral(IntegerType.INSTANCE);
        for (int i = merge.getMatchedClauses().size() - 1; i >= 0; i--) {
            MergeMatchedClause clause = merge.getMatchedClauses().get(i);
            if (i != merge.getMatchedClauses().size() - 1
                    && !clause.getCasePredicate().isPresent()) {
                throw new AnalysisException("Only the last matched clause may omit its condition");
            }
            Expression result = new IntegerLiteral(i);
            matchedLabel = clause.getCasePredicate().isPresent()
                    ? new If(clause.getCasePredicate().get(), result, matchedLabel) : result;
        }
        Expression notMatchedLabel = new NullLiteral(IntegerType.INSTANCE);
        for (int i = merge.getNotMatchedClauses().size() - 1; i >= 0; i--) {
            MergeNotMatchedClause clause = merge.getNotMatchedClauses().get(i);
            if (i != merge.getNotMatchedClauses().size() - 1
                    && !clause.getCasePredicate().isPresent()) {
                throw new AnalysisException("Only the last not matched clause may omit its condition");
            }
            Expression result = new IntegerLiteral(i + merge.getMatchedClauses().size());
            notMatchedLabel = clause.getCasePredicate().isPresent()
                    ? new If(clause.getCasePredicate().get(), result, notMatchedLabel) : result;
        }
        return new Alias(new If(targetPresent, matchedLabel, notMatchedLabel), BRANCH_LABEL);
    }

    private static List<Expression> buildDeleteProjection(
            PaimonWriteTarget target, List<String> targetNameInPlan) {
        List<Expression> output = new ArrayList<>();
        output.add(new TinyIntLiteral(PaimonRowChangeOperation.DELETE));
        for (Column column : target.getSchema()) {
            output.add(targetSlot(targetNameInPlan, column.getName()));
        }
        return output;
    }

    private static List<Expression> buildUpdateProjection(PaimonWriteTarget target,
            PaimonRowChangeSpec.Merge merge, MergeMatchedClause clause) {
        Map<String, Expression> changes = Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
        for (EqualTo assignment : clause.getAssignments()) {
            List<String> parts = ((UnboundSlot) assignment.left()).getNameParts();
            String column = parts.get(parts.size() - 1);
            if (changes.put(column, assignment.right()) != null) {
                throw new AnalysisException("Duplicate column name in Paimon MERGE UPDATE: " + column);
            }
        }
        List<Expression> output = new ArrayList<>();
        output.add(new TinyIntLiteral(PaimonRowChangeOperation.UPDATE));
        for (Column column : target.getSchema()) {
            output.add(changes.containsKey(column.getName())
                    ? changes.remove(column.getName())
                    : targetSlot(merge.getTargetNameInPlan(), column.getName()));
        }
        if (!changes.isEmpty()) {
            throw new AnalysisException("Unknown column in Paimon MERGE UPDATE: "
                    + String.join(", ", changes.keySet()));
        }
        return output;
    }

    private static List<Expression> buildInsertProjection(
            PaimonWriteTarget target, MergeNotMatchedClause clause) {
        if (clause.getRow().size() != target.getSchema().size()) {
            throw new AnalysisException(
                    "Paimon MERGE INSERT currently requires values for every table column");
        }
        Map<String, Expression> values = Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
        if (!clause.getColNames().isEmpty()) {
            if (clause.getColNames().size() != clause.getRow().size()
                    || clause.getColNames().size() != target.getSchema().size()) {
                throw new AnalysisException(
                        "Paimon MERGE INSERT currently requires every table column");
            }
            for (int i = 0; i < clause.getColNames().size(); i++) {
                if (values.put(clause.getColNames().get(i), unwrap(clause.getRow().get(i))) != null) {
                    throw new AnalysisException("Duplicate column in Paimon MERGE INSERT");
                }
            }
        }
        List<Expression> output = new ArrayList<>();
        output.add(new TinyIntLiteral(PaimonRowChangeOperation.INSERT));
        for (int i = 0; i < target.getSchema().size(); i++) {
            Column column = target.getSchema().get(i);
            Expression value = clause.getColNames().isEmpty()
                    ? unwrap(clause.getRow().get(i)) : values.remove(column.getName());
            if (value == null) {
                throw new AnalysisException(
                        "Missing column in Paimon MERGE INSERT: " + column.getName());
            }
            output.add(value);
        }
        if (!values.isEmpty()) {
            throw new AnalysisException("Unknown column in Paimon MERGE INSERT: "
                    + String.join(", ", values.keySet()));
        }
        return output;
    }

    private static Expression unwrap(NamedExpression expression) {
        return expression instanceof Alias || expression instanceof UnboundAlias
                ? expression.child(0) : expression;
    }

    private static Expression targetSlot(List<String> targetNameInPlan, String column) {
        List<String> parts = Lists.newArrayList(targetNameInPlan);
        parts.add(column);
        return new UnboundSlot(parts);
    }

    private static List<NamedExpression> generateFinalProjections(
            List<String> names, List<DataType> dataTypes,
            List<List<Expression>> branches, Slot branchLabel) {
        List<NamedExpression> output = new ArrayList<>();
        for (int column = 0; column < branches.get(0).size(); column++) {
            Expression value = generateFinalExpression(
                    column, branches, branchLabel, dataTypes.get(column));
            output.add(new Alias(value, names.get(column)));
        }
        return output;
    }

    private static Expression generateFinalExpression(
            int column, List<List<Expression>> branches, Slot branchLabel, DataType dataType) {
        Expression value = new NullLiteral(dataType);
        for (int branch = branches.size() - 1; branch >= 0; branch--) {
            Expression branchValue = branches.get(branch).get(column);
            branchValue = new Cast(branchValue, dataType);
            value = new If(new EqualTo(branchLabel,
                    new IntegerLiteral(branch)), branchValue, value);
        }
        return value;
    }
}