github-actions[bot] commented on code in PR #67932:
URL: https://github.com/apache/doris/pull/67932#discussion_r4005450775
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/RewriteSearchToSlots.java:
##########
@@ -103,11 +108,27 @@ private Expression rewriteSearch(Search search,
LogicalOlapScan scan) {
}
Map<String, String> normalizedFields = new HashMap<>();
+ Map<String, String> fieldAnalyzers = new HashMap<>();
+ Set<List<String>> qualifiers = new HashSet<>();
// Create slot reference children from field bindings
List<Expression> slotChildren = new ArrayList<>();
for (SearchDslParser.QsFieldBinding binding :
qsPlan.getFieldBindings()) {
- String originalFieldName = binding.getFieldName();
+ String bindingName = binding.getFieldName();
+ String originalFieldName = bindingName;
+ int analyzerSeparator = bindingName.lastIndexOf('@');
+ while (analyzerSeparator > 0 &&
bindingName.charAt(analyzerSeparator - 1) == '\\') {
+ analyzerSeparator = bindingName.lastIndexOf('@',
analyzerSeparator - 1);
+ }
+ if (analyzerSeparator >= 0 && findSlotByName(bindingName,
scan) == null) {
Review Comment:
[P1] Preserve analyzer-selector syntax before schema lookup. For an unquoted
`name@english` the parser leaves `@` unescaped, but this branch treats it as a
selector only when a slot named `name@english` does not exist. If a table has
both indexed `name` and a literal ``name@english`` column, the query silently
binds the literal column and its index instead of applying the `english`
analyzer to `name`, so it can return different rows. Please carry
quoted/unquoted provenance through parsing and split every unquoted selector
independently of slot collisions, with a collision regression.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/RewriteSearchToSlots.java:
##########
@@ -156,12 +177,26 @@ private Expression rewriteSearch(Search search,
LogicalOlapScan scan) {
"Field '%s' not found in table for search: %s",
originalFieldName, search.getDslString()));
}
- checkInvertedIndexExists(scan.getTable(), slot.getName(),
search.getDslString(), false);
+ checkInvertedIndexExists(tableForSlot(slot, scan),
slot.getName(), search.getDslString(), false);
Review Comment:
[P1] Check the physical original column rather than the visible alias. For a
passthrough such as `(SELECT content AS body FROM t) s`, `findSlotByName`
returns `body` and that slot retains
`originalTable=t`/`originalColumn=content`, but this call asks `t` for a column
named `body` and falsely reports that no index exists. The VARIANT parent path
has the same alias issue. Please use the slot's original column (plus subpath
where applicable) for index validation while retaining the alias only for DSL
binding, and add renamed-output regressions.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/RewriteSearchToSlots.java:
##########
@@ -156,12 +177,26 @@ private Expression rewriteSearch(Search search,
LogicalOlapScan scan) {
"Field '%s' not found in table for search: %s",
originalFieldName, search.getDslString()));
}
- checkInvertedIndexExists(scan.getTable(), slot.getName(),
search.getDslString(), false);
+ checkInvertedIndexExists(tableForSlot(slot, scan),
slot.getName(), search.getDslString(), false);
childExpr = slot;
normalizedFieldName = slot.getName();
}
- normalizedFields.put(originalFieldName, normalizedFieldName);
+ for (Slot input : childExpr.getInputSlots()) {
+ qualifiers.add(input.getQualifier());
+ }
+ if (qualifiers.size() > 1) {
+ throw new AnalysisException("Each SEARCH expression must
reference fields from one table; "
+ + "combine separate SEARCH expressions with SQL
AND/OR");
+ }
+ String fieldKey = normalizedFieldName.toLowerCase(Locale.ROOT);
+ if (fieldAnalyzers.containsKey(fieldKey)
+ && !Objects.equals(fieldAnalyzers.get(fieldKey),
binding.getAnalyzerName())) {
Review Comment:
[P2] Compare analyzer identities with the same normalization used for index
lookup. `isAnalyzerMatched` accepts analyzer names case-insensitively, so both
`name@CRM_DOC_TEXT` and `name@crm_doc_text` resolve to the same index, but this
`Objects.equals` check then rejects them as two analyzers for one field.
Normalize with `Locale.ROOT` (or compare case-insensitively) and cover
mixed-case spellings in one DSL.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownMatchProjectionAsVirtualColumn.java:
##########
@@ -124,13 +141,150 @@ private LogicalProject<?> pushDown(
newProjections.build(), childRebuilder.apply(newScan));
}
+ private boolean isIndexSearch(Expression expression) {
+ return expression instanceof Match || expression instanceof
SearchExpression;
+ }
+
+ private Plan pushDownResidual(Plan plan) {
+ Plan child = plan.child(0);
+ Map<Expression, Expression> replacements = new LinkedHashMap<>();
+ for (Expression expression : plan.getExpressions()) {
+ for (Expression search : expression.<Expression>collect(e ->
isIndexSearch((Expression) e))) {
+ if (replacements.containsKey(search)) {
+ continue;
+ }
+ Pair<Plan, Slot> result = materialize(search, child);
+ if (result != null) {
+ child = result.first;
+ replacements.put(search, result.second);
+ }
+ }
+ }
+ if (replacements.isEmpty()) {
+ return null;
+ }
+ if (plan instanceof LogicalFilter) {
+ Set<Expression> conjuncts = new LinkedHashSet<>();
+ for (Expression expression : ((LogicalFilter<?>)
plan).getConjuncts()) {
+ conjuncts.add(ExpressionUtils.replace(expression,
replacements));
+ }
+ // Hide additional scan values from the original filter's
consumers.
+ return new LogicalProject<>(ImmutableList.copyOf(plan.getOutput()),
+ new LogicalFilter<>(conjuncts, child));
+ }
+ LogicalProject<?> project = (LogicalProject<?>) plan;
+ List<NamedExpression> projects = new ArrayList<>();
+ for (NamedExpression expression : project.getProjects()) {
+ projects.add((NamedExpression) ExpressionUtils.replace(expression,
replacements));
+ }
+ return project.withProjectsAndChild(projects, child);
+ }
+
+ private Plan pushDownJoin(LogicalJoin<?, ?> join) {
+ List<Plan> children = new ArrayList<>(join.children());
+ Map<Expression, Expression> replacements = new LinkedHashMap<>();
+ for (Expression expression : join.getExpressions()) {
+ for (Expression search : expression.<Expression>collect(e ->
isIndexSearch((Expression) e))) {
+ if (replacements.containsKey(search)) {
+ continue;
+ }
+ for (int side = 0; side < children.size(); side++) {
+ // Join conditions consume child values before this join's
NULL extension.
+ // This also handles WHERE predicates moved into an inner
join by rewriting.
+ Pair<Plan, Slot> result = materialize(search,
children.get(side));
+ if (result != null) {
+ children.set(side, result.first);
+ replacements.put(search, result.second);
+ break;
+ }
+ }
+ }
+ }
+ if (replacements.isEmpty()) {
+ return null;
+ }
+ Plan rewritten = join.withConjunctsChildren(
+ ExpressionUtils.replace(join.getHashJoinConjuncts(),
replacements),
+ ExpressionUtils.replace(join.getOtherJoinConjuncts(),
replacements),
+ ExpressionUtils.replace(join.getMarkJoinConjuncts(),
replacements),
+ children.get(0), children.get(1),
join.getJoinReorderContext());
+ return new LogicalProject<>(ImmutableList.copyOf(join.getOutput()),
rewritten);
+ }
+
+ private Pair<Plan, Slot> materialize(Expression expression, Plan plan) {
+ Set<Slot> inputs = expression.getInputSlots();
+ if (inputs.isEmpty() || !plan.getOutputSet().containsAll(inputs)) {
+ return null;
+ }
+ if (plan instanceof LogicalOlapScan) {
+ LogicalOlapScan scan = (LogicalOlapScan) plan;
+ if (!canPushDown(scan)) {
+ return null;
+ }
+ for (NamedExpression column : scan.getVirtualColumns()) {
+ if (column instanceof Alias && ((Alias)
column).child().equals(expression)) {
+ return Pair.of(scan, column.toSlot());
+ }
+ }
+ Alias alias = new Alias(expression);
+ return Pair.of(scan.appendVirtualColumns(ImmutableList.of(alias)),
alias.toSlot());
+ }
+ if (plan instanceof LogicalProject) {
+ LogicalProject<?> project = (LogicalProject<?>) plan;
+ if (project.containsNoneMovableFunction()) {
+ return null;
+ }
+ Expression rewritten = ExpressionUtils.replace(expression,
project.getAliasToProducer());
+ Pair<Plan, Slot> result = materialize(rewritten, project.child());
+ if (result == null) {
+ return null;
+ }
+ List<NamedExpression> projects = new
ArrayList<>(project.getProjects());
+ projects.add(result.second);
Review Comment:
[P2] Record or reuse the materialization before leaving this project. If the
same MATCH appears in this child projection and a preserved-side outer-join
`ON` condition, `pushDownJoin` first reaches this path and appends one virtual
slot; top-down traversal then reaches the rebuilt project, where the direct
Project-to-scan rule allocates a second alias because it does not consult the
scan's existing virtual columns. Both ExprIds stay referenced and the segment
iterator evaluates/materializes the predicate twice. Please centralize the
reuse check and assert this plan has one virtual MATCH column.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/SearchExpression.java:
##########
@@ -72,11 +74,14 @@ public boolean foldable() {
@Override
public SearchExpression withChildren(List<Expression> children) {
- // Validate that all children are SlotReference or ElementAt (for
variant subcolumns)
+ // Null-rejection inference temporarily replaces input slots with NULL.
+ // Such symbolic expressions are not execution-time field bindings.
for (Expression child : children) {
- if (!(child instanceof SlotReference || child instanceof
ElementAt)) {
+ if (!(child instanceof SlotReference || child instanceof ElementAt
+ || child instanceof NullLiteral)) {
Review Comment:
[P1] Do not let inference-only NULL children persist into executable SEARCH
plans. For `a LEFT JOIN b ON FALSE` with `search('content:john') IS NULL` on
`b`, join elimination produces `NULL AS content` and filter-through-project
substitutes it here, yielding `Search(NULL)` directly over `a`'s scan. The
materializer skips it, the final Filter-to-scan check admits it, and BE's
no-iterator path produces empty data and null bitmaps, so SEARCH is
false/non-null and the preserved rows are wrongly rejected. Please keep
symbolic NULL replacement non-persistent or reject non-slot/subcolumn children
before translation, and cover false outer-join padding.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/ExpressionTranslator.java:
##########
@@ -698,19 +698,25 @@ public Expr visitSearchExpression(SearchExpression
searchExpression,
// Look up the inverted index for each field (needed for variant
subcolumn analyzer)
Index invertedIndex = null;
+ String analyzer = searchExpression.getQsPlan().getFieldBindings()
+ .get(fieldIndexes.size()).getAnalyzerName();
if (slotExpr instanceof SlotReference) {
SlotReference slot = (SlotReference) slotExpr;
OlapTable olapTbl = getOlapTableDirectly(slot);
if (olapTbl != null) {
Column column = slot.getOriginalColumn().orElse(null);
if (column != null) {
- invertedIndex = olapTbl.getInvertedIndex(column,
slot.getSubPath());
+ invertedIndex = olapTbl.getInvertedIndex(column,
slot.getSubPath(), analyzer);
Review Comment:
[P1] Ensure this selected analyzer also constrains BE reader choice for
`EXACT`. FE resolves the requested index here and sends its properties, but
`FieldReaderResolver` derives `analyzer_key` only when the query type is not
`EQUAL_QUERY`; SEARCH maps `EXACT` to `EQUAL_QUERY`. Two custom
standard/keyword analyzers are both FULLTEXT readers, so the empty-key selector
can pick the lower index ID instead of the requested keyword analyzer and
return different rows. Please honor an explicit analyzer for every clause type
(then apply type preference within that analyzer) and add a two-analyzer EXACT
regression.
--
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]