IvmNormalizeCommonChecker.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.mtmv.ivm;

import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.FeNameFormat;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.plans.JoinType;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.algebra.SetOperation.Qualifier;
import org.apache.doris.nereids.trees.plans.logical.LogicalJoin;
import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
import org.apache.doris.nereids.trees.plans.logical.LogicalResultSink;
import org.apache.doris.nereids.trees.plans.logical.LogicalUnion;

import com.google.common.collect.ImmutableSet;

import java.util.Set;
import java.util.stream.Collectors;

/**
 * The query shapes an IVM MV cannot maintain, checked once for every normalize.
 *
 * <p>Each shape here has to be rejected while a query is normalized, and the reason is the same
 * whichever model runs the normalize: a DISTINCT project folds rows together, a UNION DISTINCT or a
 * UNION ALL with constant arms reaches rows the MV does not hold as the query's own, and a column name
 * Doris reserves for a hidden one would leave the MV holding fewer columns than the query produced.
 * Keeping each such gate here rather than in a normalizer is what stops the two normalize paths -- the
 * unique-key one and the append-only detail one, plus the delta rewriter that accepts the same shapes the
 * detail normalize does -- from drifting apart on what they accept.
 *
 * <p>The join gate is the one gate that carries a per-model policy: how many join types a model can
 * maintain differs. An append-only detail MV maintains only {@link #DETAIL_JOIN_TYPES}, a unique-key MV
 * also maintains the outer joins ({@link #UNIQ_JOIN_TYPES}). The accepted set is therefore the caller's,
 * but the gate as a whole is shared, because a mark join's mark is orthogonal to its type: the apply
 * rewrites put one on a LEFT_SEMI, a LEFT_ANTI, a NULL_AWARE_LEFT_ANTI, or even a CROSS join, so a test
 * of the join type alone does not reject it. A mark join reaches rows the MV does not hold as the query's
 * own, exactly as an outer join does, so it must be rejected by name before the type is looked at.
 *
 * <p>The class holds no state: every gate is a pure check that throws when it rejects.
 */
final class IvmNormalizeCommonChecker {

    /** Join types an append-only detail (DUP) MV can maintain; see {@link IvmNormalizeDupKeysMTMV}. */
    static final Set<JoinType> DETAIL_JOIN_TYPES =
            ImmutableSet.of(JoinType.INNER_JOIN, JoinType.CROSS_JOIN);

    /** Join types a unique-key MV can maintain; see {@link IvmNormalizeUniqKeysMTMV}. */
    static final Set<JoinType> UNIQ_JOIN_TYPES =
            ImmutableSet.of(JoinType.INNER_JOIN, JoinType.CROSS_JOIN,
                    JoinType.LEFT_OUTER_JOIN, JoinType.RIGHT_OUTER_JOIN, JoinType.FULL_OUTER_JOIN);

    private IvmNormalizeCommonChecker() {
    }

    /**
     * The rejection every normalize's {@code visit} default throws: a node type no model accepts lands
     * there, and the caller throws what this returns.
     */
    static IvmException unsupportedNode(Plan plan) {
        return new IvmException(IvmFailureReason.PLAN_PATTERN_UNSUPPORTED,
                "IVM does not support plan node: " + plan.getClass().getSimpleName());
    }

    static void checkProject(LogicalProject<? extends Plan> project) {
        if (project.isDistinct()) {
            throw new IvmException(IvmFailureReason.PLAN_PATTERN_UNSUPPORTED,
                    "IVM does not support DISTINCT project.");
        }
    }

    static void checkUnion(LogicalUnion union) {
        if (union.getQualifier() != Qualifier.ALL) {
            throw new IvmException(IvmFailureReason.PLAN_PATTERN_UNSUPPORTED,
                    "IVM does not support UNION DISTINCT. Only UNION ALL is supported.");
        }
        if (!union.getConstantExprsList().isEmpty()) {
            throw new IvmException(IvmFailureReason.PLAN_PATTERN_UNSUPPORTED,
                    "IVM does not support UNION ALL with constant expressions.");
        }
    }

    /**
     * Rejects an MV output column whose name a Doris hidden column already owns. Both models lay hidden
     * columns on the MV they build -- the unique-key model its row-id, any model that enables row binlog
     * its own commit tso and row lsn -- so a user column taking a reserved prefix would be filtered out of
     * the schema the user reads, leaving an MV that holds fewer columns than its query produced.
     */
    static void checkResultSinkOutputNames(LogicalResultSink<? extends Plan> sink) {
        for (NamedExpression output : sink.getOutputExprs()) {
            try {
                FeNameFormat.checkColumnName(output.getName());
            } catch (AnalysisException e) {
                throw new IvmException(IvmFailureReason.PLAN_PATTERN_UNSUPPORTED, e.getMessage(), e);
            }
        }
    }

    /**
     * Rejects a join no model with {@code allowedJoinTypes} can maintain. A mark join is rejected before
     * its type is looked at: it passes a type test whenever its type happens to be an accepted one (a
     * CROSS join carrying an EXISTS, say), which is exactly the hole a type-only check leaves.
     */
    static void checkJoin(LogicalJoin<? extends Plan, ? extends Plan> join, Set<JoinType> allowedJoinTypes) {
        if (join.isMarkJoin()) {
            throw new IvmException(IvmFailureReason.PLAN_PATTERN_UNSUPPORTED,
                    "IVM does not support a mark join (a subquery with disjunction): the mark can drop "
                            + "a row the MV already holds.");
        }
        if (!allowedJoinTypes.contains(join.getJoinType())) {
            throw new IvmException(IvmFailureReason.PLAN_PATTERN_UNSUPPORTED,
                    "IVM does not support join type " + join.getJoinType() + ". Only "
                            + allowedJoinTypes.stream().map(JoinType::toString).collect(Collectors.joining(", "))
                            + " are supported.");
        }
    }
}