github-actions[bot] commented on code in PR #69292:
URL: https://github.com/apache/doris/pull/69292#discussion_r4345724321


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindScore.java:
##########
@@ -0,0 +1,467 @@
+// 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.catalog.FunctionRegistry;
+import org.apache.doris.nereids.analyzer.UnboundAlias;
+import org.apache.doris.nereids.analyzer.UnboundFunction;
+import org.apache.doris.nereids.analyzer.UnboundRelation;
+import org.apache.doris.nereids.analyzer.UnboundSlot;
+import org.apache.doris.nereids.analyzer.UnboundStar;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.jobs.JobContext;
+import org.apache.doris.nereids.properties.OrderKey;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.Cast;
+import org.apache.doris.nereids.trees.expressions.ComparisonPredicate;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.GreaterThan;
+import org.apache.doris.nereids.trees.expressions.GreaterThanEqual;
+import org.apache.doris.nereids.trees.expressions.LessThan;
+import org.apache.doris.nereids.trees.expressions.LessThanEqual;
+import org.apache.doris.nereids.trees.expressions.Match;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SubqueryExpr;
+import org.apache.doris.nereids.trees.expressions.WindowExpression;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Score;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLikeLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCTE;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCheckPolicy;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalHaving;
+import org.apache.doris.nereids.trees.plans.logical.LogicalLimit;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPreAggOnHint;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalQualify;
+import org.apache.doris.nereids.trees.plans.logical.LogicalRepeat;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSelectHint;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSetOperation;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSink;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSort;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSubQueryAlias;
+import org.apache.doris.nereids.trees.plans.visitor.CustomRewriter;
+import org.apache.doris.nereids.util.ExpressionUtils;
+import org.apache.doris.qe.ConnectContext;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Bind score() to the olap scan that computes it.
+ *
+ * <p>score() is the relevance of a row for the MATCH and SEARCH predicates in 
the WHERE clause of
+ * its SELECT, and only the olap scan that evaluates those predicates computes 
it. So score() is
+ * supported in a SELECT that reads one olap table and has a MATCH or SEARCH 
predicate in its WHERE
+ * clause:
+ * <ul>
+ *   <li>in the WHERE clause, as one minimum score: score() > x or score() >= 
x;</li>
+ *   <li>in the select list, window functions and ORDER BY of a SELECT that 
does not aggregate.</li>
+ * </ul>
+ * A SELECT in a subquery, view, CTE or set operation has to ORDER BY score() 
with a LIMIT, which keeps
+ * the predicates of the outer query away from its scan. A correlated subquery 
cannot call score(), see
+ * CheckScoreUsage. Any other use of score() is rejected.
+ *
+ * <p>This rule runs first on each plan the analyzer binds: a statement, and 
on their own its views, CTEs
+ * and subqueries. The plan nodes of each SELECT are still its clauses then. 
The rule asks the table of a
+ * SELECT that calls score() for a score column, which BindRelation adds to 
the olap scan, and replaces
+ * the score() calls of the SELECT with that column. The scan scores every row 
it returns, so the minimum
+ * score is a filter on the column. PushDownScoreTopNIntoOlapScan pushes a 
TopN on the score into the
+ * scan.
+ */
+public class BindScore implements CustomRewriter {
+    public static final String UNSUPPORTED_SELECT_ERROR = "score() can only be 
used in a SELECT that reads one "
+            + "table and has a MATCH or SEARCH predicate in its WHERE clause";
+    public static final String NESTED_SELECT_ERROR = "score() in a subquery, 
view, CTE or set operation "
+            + "requires ORDER BY score() with LIMIT";
+    public static final String CORRELATED_SUBQUERY_ERROR = "score() cannot be 
used in a correlated subquery";
+    public static final String AGGREGATE_ERROR = "score() can only be used in 
the WHERE clause of a SELECT "
+            + "with GROUP BY, aggregate functions or DISTINCT";
+    public static final String HAVING_OR_QUALIFY_ERROR = "score(), MATCH and 
SEARCH cannot be used in HAVING "
+            + "or QUALIFY of a SELECT that calls score()";
+    public static final String ONE_RANGE_ERROR = "Only one score() range 
predicate is supported in WHERE clause";
+    public static final String RANGE_FORM_ERROR = "score() predicate in WHERE 
clause must be in the form of "
+            + "'score() > literal' or 'score() >= literal'";
+    public static final String TOP_LEVEL_RANGE_ERROR = "score() predicate must 
be a top-level AND condition "
+            + "in WHERE clause";
+    public static final String NUMERIC_COMPARISON_ERROR = "score() function 
can only be compared with numeric types";
+
+    // No column of a table can have a name with the hidden column prefix.
+    private static final String SCORE_COLUMN = Column.HIDDEN_COLUMN_PREFIX + 
"SCORE__";
+
+    @Override
+    public Plan rewriteRoot(Plan plan, JobContext jobContext) {
+        ConnectContext connectContext = 
jobContext.getCascadesContext().getConnectContext();
+        FunctionRegistry functions = 
connectContext.getEnv().getFunctionRegistry();
+        // A UDF named score takes precedence over the builtin score() with 
prefer_udf_over_builtin, see
+        // FunctionRegistry.findFunctionBuilder.
+        if (!plan.anyMatch(node -> callsScore((Plan) node)) || 
connectContext.getSessionVariable().preferUdfOverBuiltin
+                && !functions.findUdfBuilder(null, "score").isEmpty()) {
+            return plan;
+        }
+        // A view, a CTE and a subquery are analyzed without the sink of their 
statement.
+        Plan newPlan = bindQuery(plan, plan instanceof LogicalSink, functions);
+        // Outside the clauses of a SELECT, e.g. in a join condition.
+        if (newPlan.anyMatch(node -> callsScore((Plan) node))) {
+            throw new AnalysisException(UNSUPPORTED_SELECT_ERROR);
+        }
+        return newPlan;
+    }
+
+    /** The score column that BindRelation added to the scan. */
+    public static Optional<Slot> findScoreSlot(LogicalOlapScan scan) {
+        return scan.getVirtualColumns().stream()
+                .filter(column -> column instanceof Alias && ((Alias) 
column).child() instanceof Score)
+                .map(NamedExpression::toSlot)
+                .findFirst();
+    }
+
+    /** Make the scan of a table that a SELECT asked for its score column 
score every row it returns. */
+    public static LogicalOlapScan addScoreColumn(LogicalOlapScan scan, Alias 
scoreColumn) {
+        // A zero score limit scores every row.
+        return scan.appendVirtualColumnsAndTopN(ImmutableList.of(scoreColumn), 
scan.getAnnOrderKeys(),
+                scan.getAnnLimit(), ImmutableList.of(new 
OrderKey(scoreColumn.toSlot(), false, false)),
+                Optional.of(0L), Optional.empty());
+    }
+
+    /** Only the olap scan of a table can compute the score, not a view, a CTE 
or another relation. */
+    public static void checkScoreColumn(Plan relation, Alias scoreColumn) {
+        if (!relation.getOutputSet().contains(scoreColumn.toSlot())) {
+            throw new AnalysisException(UNSUPPORTED_SELECT_ERROR);
+        }
+    }
+
+    // A query: a SELECT, a set operation of queries or a query with a WITH 
clause, also under the sink of
+    // the statement.
+    private static Plan bindQuery(Plan plan, boolean outermost, 
FunctionRegistry functions) {
+        if (plan instanceof LogicalSink || plan instanceof LogicalCTE) {
+            return withChildren(plan, 
ImmutableList.of(bindQuery(plan.child(0), outermost, functions)));
+        }
+        if (plan instanceof LogicalSetOperation) {
+            return withChildren(plan, plan.children().stream()
+                    .map(child -> bindQuery(child, false, functions))
+                    .collect(ImmutableList.toImmutableList()));
+        }
+        return bindSelect(plan, outermost, functions);
+    }
+
+    private static Plan bindSelect(Plan top, boolean outermost, 
FunctionRegistry functions) {
+        // The parser builds a SELECT as the chain of its clauses, from its 
hints down to its WHERE clause.
+        List<Plan> clauses = new ArrayList<>();
+        Plan from = top;
+        for (; isClause(from); from = from.child(0)) {
+            clauses.add(from);
+        }
+        Plan newFrom = bindFrom(from, functions);
+        if (clauses.stream().anyMatch(BindScore::callsScore)) {
+            check(clauses, from, outermost, functions);
+            Alias scoreColumn = new Alias(new Score(), SCORE_COLUMN);
+            newFrom = askForScoreColumn(from, scoreColumn);
+            clauses = clauses.stream().map(clause -> bindScore(clause, 
scoreColumn.toSlot()))
+                    .collect(Collectors.toList());
+        }
+        Plan plan = newFrom;
+        for (int i = clauses.size() - 1; i >= 0; i--) {
+            plan = withChildren(clauses.get(i), ImmutableList.of(plan));
+        }
+        return plan;
+    }
+
+    private static boolean isClause(Plan plan) {
+        return plan instanceof LogicalPreAggOnHint || plan instanceof 
LogicalSelectHint
+                || plan instanceof LogicalLimit || plan instanceof LogicalSort 
|| plan instanceof LogicalQualify
+                || plan instanceof LogicalHaving || plan instanceof 
LogicalProject
+                || plan instanceof LogicalAggregate || plan instanceof 
LogicalRepeat || plan instanceof LogicalFilter;
+    }
+
+    // The FROM clause: tables, joins and lateral views of them, and 
subqueries, which are queries of
+    // their own.
+    private static Plan bindFrom(Plan from, FunctionRegistry functions) {
+        if (from.arity() == 0 || isTable(from)) {
+            return from;
+        }
+        if (from instanceof LogicalSubQueryAlias) {
+            return withChildren(from, 
ImmutableList.of(bindQuery(from.child(0), false, functions)));
+        }
+        // E.g. a set operation under ORDER BY and LIMIT.
+        if (isClause(from) || from instanceof LogicalSetOperation || from 
instanceof LogicalCTE) {
+            return bindQuery(from, false, functions);
+        }
+        return withChildren(from, from.children().stream()
+                .map(child -> bindFrom(child, functions))
+                .collect(ImmutableList.toImmutableList()));
+    }
+
+    // A table, also with an alias.
+    private static boolean isTable(Plan plan) {
+        Plan relation = plan instanceof LogicalSubQueryAlias ? plan.child(0) : 
plan;
+        return relation instanceof LogicalCheckPolicy && relation.child(0) 
instanceof UnboundRelation;
+    }
+
+    private static Plan askForScoreColumn(Plan table, Alias scoreColumn) {
+        if (table instanceof LogicalSubQueryAlias) {
+            return table.withChildren(askForScoreColumn(table.child(0), 
scoreColumn));
+        }
+        return table.withChildren(((UnboundRelation) 
table.child(0)).withScoreColumn(scoreColumn));
+    }
+
+    private static void check(List<Plan> clauses, Plan from, boolean 
outermost, FunctionRegistry functions) {
+        // A subquery in a clause reads another table.
+        if (!isTable(from) || clauses.stream().anyMatch(clause -> 
clause.getExpressions().stream()
+                .anyMatch(expression -> 
expression.containsType(SubqueryExpr.class)))) {
+            throw new AnalysisException(UNSUPPORTED_SELECT_ERROR);
+        }
+        Plan last = clauses.get(clauses.size() - 1);
+        List<Expression> where = last instanceof LogicalFilter
+                ? ((LogicalFilter<?>) last).getConjuncts().stream()
+                        .flatMap(conjunct -> 
ExpressionUtils.extractConjunction(conjunct).stream())
+                        .collect(Collectors.toList())
+                : ImmutableList.of();
+        if (where.stream().noneMatch(conjunct -> 
conjunct.anyMatch(BindScore::matchesText))) {
+            throw new AnalysisException(UNSUPPORTED_SELECT_ERROR);
+        }
+        clauses.forEach(clause -> expressions(clause).forEach(expression -> 
expression.foreach(node -> {
+            if (node instanceof ComparisonPredicate) {
+                checkComparedWithNumber((ComparisonPredicate) node);
+            }
+        })));
+        List<Expression> ranges = where.stream()
+                .filter(conjunct -> conjunct.anyMatch(BindScore::isScoreCall))
+                .collect(Collectors.toList());
+        if (ranges.size() > 1) {
+            throw new AnalysisException(ONE_RANGE_ERROR);
+        }
+        ranges.forEach(BindScore::checkRange);
+
+        List<Plan> aboveWhere = last instanceof LogicalFilter ? 
clauses.subList(0, clauses.size() - 1) : clauses;
+        boolean aggregates = aboveWhere.stream().anyMatch(clause -> 
aggregates(clause, functions));
+        // HAVING and QUALIFY can also read score(), MATCH and SEARCH through 
aliases of the select list.
+        Set<String> scoringAliases = new HashSet<>();
+        for (int i = aboveWhere.size() - 1; i >= 0; i--) {
+            for (Expression expression : 
expressions(aboveWhere.get(i)).collect(Collectors.toList())) {
+                if (expression instanceof UnboundAlias && ((UnboundAlias) 
expression).getAlias().isPresent()
+                        && scores(expression, scoringAliases)) {
+                    scoringAliases.add(((UnboundAlias) 
expression).getAlias().get().toLowerCase());
+                }
+            }
+        }
+        for (Plan clause : aboveWhere) {
+            if ((clause instanceof LogicalHaving || clause instanceof 
LogicalQualify)
+                    && clause.getExpressions().stream().anyMatch(expression -> 
scores(expression, scoringAliases))) {
+                throw new AnalysisException(HAVING_OR_QUALIFY_ERROR);
+            }
+            if (aggregates && callsScore(clause)) {
+                throw new AnalysisException(AGGREGATE_ERROR);
+            }
+        }
+        if (!outermost && !ordersByScoreWithLimit(aboveWhere)) {
+            throw new AnalysisException(NESTED_SELECT_ERROR);
+        }
+    }
+
+    // score() is a FLOAT. The type of a literal is known before binding, e.g. 
score() > 'abc' is rejected.
+    private static void checkComparedWithNumber(ComparisonPredicate 
comparison) {
+        for (int i = 0; i < 2; i++) {
+            Expression other = comparison.child(1 - i);
+            if (isScoreCall(comparison.child(i)) && other instanceof Literal
+                    && !other.getDataType().isNumericType() && 
!other.getDataType().isNullType()) {
+                throw new AnalysisException(NUMERIC_COMPARISON_ERROR + ", but 
found: " + other.getDataType()
+                        + " in " + comparison.toSql());
+            }
+        }
+    }
+
+    // score() > x or score() >= x, also written x < score() or x <= score(). 
A cast of score() to
+    // FLOAT or DOUBLE keeps its value.
+    private static void checkRange(Expression conjunct) {
+        if (!(conjunct instanceof ComparisonPredicate)) {
+            throw new AnalysisException(TOP_LEVEL_RANGE_ERROR);
+        }
+        ComparisonPredicate comparison = (ComparisonPredicate) conjunct;
+        boolean scoreAbove = (comparison instanceof GreaterThan || comparison 
instanceof GreaterThanEqual)
+                && isScore(comparison.left()) && 
isConstant(comparison.right());
+        boolean scoreBelow = (comparison instanceof LessThan || comparison 
instanceof LessThanEqual)
+                && isScore(comparison.right()) && 
isConstant(comparison.left());
+        if (!scoreAbove && !scoreBelow) {
+            throw new AnalysisException(RANGE_FORM_ERROR);
+        }
+    }
+
+    private static boolean isScore(Expression expression) {
+        if (expression instanceof Cast) {
+            return expression.getDataType().isFloatLikeType() && 
isScore(((Cast) expression).child());
+        }
+        return isScoreCall(expression);
+    }
+
+    private static boolean isConstant(Expression expression) {
+        return expression.getInputSlots().isEmpty() && 
!expression.anyMatch(BindScore::isScoreCall);
+    }
+
+    // GROUP BY, DISTINCT or an aggregate function, which aggregates all rows 
of a SELECT without GROUP BY.
+    private static boolean aggregates(Plan clause, FunctionRegistry functions) 
{
+        return clause instanceof LogicalAggregate || clause instanceof 
LogicalRepeat
+                || clause instanceof LogicalProject && ((LogicalProject<?>) 
clause).isDistinct()
+                || clause.getExpressions().stream().anyMatch(expression -> 
callsAggregate(expression, functions));
+    }
+
+    private static boolean callsAggregate(Expression expression, 
FunctionRegistry functions) {
+        // A window function aggregates the rows of its window.
+        if (expression instanceof WindowExpression) {
+            return false;
+        }
+        if (expression instanceof UnboundFunction && 
functions.isAggregateFunction(
+                ((UnboundFunction) expression).getDbName(), ((UnboundFunction) 
expression).getName())) {

Review Comment:
   [P2] Respect UDF precedence when classifying aggregate calls
   
   With `CREATE ALIAS FUNCTION sum(INT) WITH PARAMETER(x) AS abs(x)` and 
`prefer_udf_over_builtin=true`, the previously supported query `SELECT id, 
score() AS s, sum(id) FROM t WHERE c1 MATCH 'apple' ORDER BY s DESC LIMIT 10` 
resolves `sum(id)` to a scalar alias. Its reduced parsed plan is `Limit -> 
Sort(s) -> Project(id, score() AS s, sum(id)) -> Filter(MATCH) -> Table`. Here 
`FunctionRegistry.isAggregateFunction` checks the built-in SUM builders before 
considering UDF precedence, so `aggregates` becomes true and `check` throws 
`AGGREGATE_ERROR` before normal binding can resolve the scalar call. Resolve 
the effective function with the same precedence as `findFunctionBuilder` before 
deciding that a SELECT aggregates, and cover a scalar UDF overriding an 
aggregate name. This is distinct from the existing UDF-named-score report: 
`score()` remains the built-in here; the false aggregate classification comes 
from another select-list function.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to