Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/changelog/155010.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
area: ES|QL
issues:
- 154758
pr: 155010
summary: Fix `VerificationException` when in subquery is used together with `QSTR`/`KQL`
type: bug
Original file line number Diff line number Diff line change
Expand Up @@ -2863,3 +2863,175 @@ FROM employees, employees_incompatible METADATA _index
c:long | _index:keyword
100 | employees_incompatible
;

kqlOrInSubquery
required_capability: where_in_subquery_without_view
required_capability: kql_function
required_capability: where_in_subquery_with_qstr_kql_fix

FROM books
| WHERE kql("author: William") OR book_no IN (FROM books | WHERE book_no == "9032" | KEEP book_no)
| SORT book_no
| KEEP book_no, author
;

book_no:keyword | author:text
2713 | William Faulkner
2883 | William Faulkner
4724 | William Faulkner
4977 | William Faulkner
5119 | William Faulkner
5404 | William Faulkner
5578 | William Faulkner
8077 | William Faulkner
9032 | Tolstoy Leo
9896 | William Faulkner
;


qstrOrNotInSubquery
required_capability: where_in_subquery_without_view
required_capability: qstr_function
required_capability: where_in_subquery_with_qstr_kql_fix

FROM books
| WHERE qstr("author: William") OR book_no NOT IN (FROM books | WHERE book_no == "9032" | KEEP book_no)
| SORT book_no
| KEEP book_no, author
| LIMIT 10
;

book_no:keyword | author:text
1211 | Fyodor Dostoevsky
1463 | J. R. R. Tolkien
1502 | Nikolai Vasilevich Gogol
1937 | Fyodor Dostoevsky
1985 | Fyodor Dostoevsky
2130 | [Christopher Tolkien, John Ronald Reuel Tolkien]
2236 | Ursula K. Le Guin
2301 | John Ronald Reuel Tolkien
2378 | [Carol Faulkner, Holly Byers Ochoa, Lucretia Mott]
2382 | Ursula K. Le Guin
;

// Tests for the hash-join execution path ($<fieldName>$sv synthetic attribute).
// pragma: in_subquery_hash_join_threshold=0 forces any non-empty subquery result into
// the hash-join branch regardless of row count. The join key (job_positions) is a
// genuinely multi-valued keyword field in the employees index, so MvSingleValueOrNull
// is exercised on real multi-valued data. Employees whose job_positions is multi-valued
// are excluded by the MvSingleValueOrNull + IsNotNull guard that the hash-join inserts.
//
// AND variants (SemiJoin / AntiJoin): QSTR/KQL filters run before MvSingleValueOrNull,
// so only the employees that already passed the full-text filter are evaluated against
// the guard; we pick a last_name that matches only single-valued employees to keep the
// expected output deterministic and warning-free.
//
// OR variants (MarkJoin): all rows are evaluated by MvSingleValueOrNull regardless of
// whether they match the full-text condition, so multi-valued employees produce warnings.
// warningRegex:.* permits those warnings without constraining their exact text.

qstrAndInSubqueryHashJoinPathMultiValuedField
required_capability: where_in_subquery_without_view
required_capability: qstr_function
required_capability: where_in_subquery_with_qstr_kql_fix

pragma: in_subquery_hash_join_threshold=0

FROM employees
| WHERE QSTR("last_name: Simmel") AND job_positions IN (FROM employees | WHERE emp_no == 10002 | KEEP job_positions)
| SORT emp_no
| KEEP emp_no, last_name
;

emp_no:integer | last_name:keyword
10002 | Simmel
;

kqlAndInSubqueryHashJoinPathMultiValuedField
required_capability: where_in_subquery_without_view
required_capability: kql_function
required_capability: where_in_subquery_with_qstr_kql_fix

pragma: in_subquery_hash_join_threshold=0

FROM employees
| WHERE KQL("last_name: Simmel") AND job_positions IN (FROM employees | WHERE emp_no == 10002 | KEEP job_positions)
| SORT emp_no
| KEEP emp_no, last_name
;

emp_no:integer | last_name:keyword
10002 | Simmel
;

qstrAndNotInSubqueryHashJoinPathMultiValuedField
required_capability: where_in_subquery_without_view
required_capability: qstr_function
required_capability: where_in_subquery_with_qstr_kql_fix

pragma: in_subquery_hash_join_threshold=0

FROM employees
| WHERE QSTR("last_name: Warwick") AND job_positions NOT IN (FROM employees | WHERE emp_no == 10002 | KEEP job_positions)
| SORT emp_no
| KEEP emp_no, last_name
;

emp_no:integer | last_name:keyword
10020 | Warwick
;

kqlAndNotInSubqueryHashJoinPathMultiValuedField
required_capability: where_in_subquery_without_view
required_capability: kql_function
required_capability: where_in_subquery_with_qstr_kql_fix

pragma: in_subquery_hash_join_threshold=0

FROM employees
| WHERE KQL("last_name: Warwick") AND job_positions NOT IN (FROM employees | WHERE emp_no == 10002 | KEEP job_positions)
| SORT emp_no
| KEEP emp_no, last_name
;

emp_no:integer | last_name:keyword
10020 | Warwick
;

qstrOrInSubqueryHashJoinPathMultiValuedField
required_capability: where_in_subquery_without_view
required_capability: qstr_function
required_capability: where_in_subquery_with_qstr_kql_fix

pragma: in_subquery_hash_join_threshold=0

FROM employees
| WHERE QSTR("last_name: Simmel") OR job_positions IN (FROM employees | WHERE emp_no == 10056 | KEEP job_positions)
| SORT emp_no
| KEEP emp_no, last_name
;
warningRegex:.*

emp_no:integer | last_name:keyword
10002 | Simmel
10056 | Bernini
;

kqlOrInSubqueryHashJoinPathMultiValuedField
required_capability: where_in_subquery_without_view
required_capability: kql_function
required_capability: where_in_subquery_with_qstr_kql_fix

pragma: in_subquery_hash_join_threshold=0

FROM employees
| WHERE KQL("last_name: Simmel") OR job_positions IN (FROM employees | WHERE emp_no == 10056 | KEEP job_positions)
| SORT emp_no
| KEEP emp_no, last_name
;
warningRegex:.*

emp_no:integer | last_name:keyword
10002 | Simmel
10056 | Bernini
;
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,14 @@ public enum Cap {
* correct time-series index when a join presents.
*/
WHERE_IN_SUBQUERY_WITH_TS,

/**
* Allow disjunctive IN subquery work with QSTR/KQL, including the fix for the KQL/QSTR positional check failing with a
* VerificationException when the subquery is inlined via the hash-join and there is a synthetic attribute in the Eval before
* QSTR or KQL.
*/
WHERE_IN_SUBQUERY_WITH_QSTR_KQL_FIX,

/**
* Support for views in cluster state (and REST API).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,12 +315,15 @@ private static String syntheticConstName(Expression value, LogicalPlan subquery)
return "$$in_subquery_const$" + value.hashCode() + "$" + subquery.hashCode();
}

/** Name prefix shared by all synthetic boolean mark attributes created for {@link MarkJoin}. */
public static final String MARK_ATTRIBUTE_NAME_PREFIX = "$$in_subquery_mark$";

/**
* Generates a unique synthetic name for the boolean mark attribute produced by a
* {@link MarkJoin} in place of an {@link InSubquery}.
*/
private static String syntheticMarkName(InSubquery inSubquery) {
return "$$in_subquery_mark$" + inSubquery.value().hashCode() + "$" + inSubquery.subquery().hashCode();
return MARK_ATTRIBUTE_NAME_PREFIX + inSubquery.value().hashCode() + "$" + inSubquery.subquery().hashCode();
}

public static void verify(LogicalPlan plan) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.elasticsearch.compute.operator.ScoreOperator;
import org.elasticsearch.index.IndexMode;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.xpack.esql.analysis.InSubqueryResolver;
import org.elasticsearch.xpack.esql.capabilities.PostAnalysisPlanVerificationAware;
import org.elasticsearch.xpack.esql.capabilities.PostOptimizationPlanVerificationAware;
import org.elasticsearch.xpack.esql.capabilities.PostOptimizationVerificationAware;
Expand Down Expand Up @@ -50,6 +51,7 @@
import org.elasticsearch.xpack.esql.plan.logical.Aggregate;
import org.elasticsearch.xpack.esql.plan.logical.Dedup;
import org.elasticsearch.xpack.esql.plan.logical.EsRelation;
import org.elasticsearch.xpack.esql.plan.logical.Eval;
import org.elasticsearch.xpack.esql.plan.logical.ExternalRelation;
import org.elasticsearch.xpack.esql.plan.logical.Filter;
import org.elasticsearch.xpack.esql.plan.logical.Fork;
Expand All @@ -64,6 +66,7 @@
import org.elasticsearch.xpack.esql.plan.logical.Sample;
import org.elasticsearch.xpack.esql.plan.logical.TopNBy;
import org.elasticsearch.xpack.esql.plan.logical.UnionAll;
import org.elasticsearch.xpack.esql.plan.logical.join.AbstractSubqueryJoin;
import org.elasticsearch.xpack.esql.plan.logical.join.InlineJoin;
import org.elasticsearch.xpack.esql.plan.logical.join.Join;
import org.elasticsearch.xpack.esql.plan.logical.join.LookupJoin;
Expand All @@ -77,6 +80,7 @@
import java.util.Locale;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Predicate;

import static org.elasticsearch.xpack.esql.common.Failure.fail;
Expand Down Expand Up @@ -299,7 +303,8 @@ private static void checkFullTextQueryFunctionForCondition(
|| lp instanceof OrderBy
|| lp instanceof EsRelation
|| lp instanceof ParameterizedQuery
|| lp instanceof Sample),
|| lp instanceof Sample
|| lp instanceof AbstractSubqueryJoin),
fullTextFunction -> "[" + fullTextFunction.functionName() + "] " + fullTextFunction.functionType(),
failures
);
Expand Down Expand Up @@ -358,6 +363,72 @@ private static void checkFullTextFunctionsInAggs(Aggregate agg, Failures failure
* @param failures failures to add errors to
* @param <E> class of the type to look for
*/
/**
* Traverses the plan depth-first like {@link LogicalPlan#forEachDown}, but with two exceptions:
* <ul>
* <li>The right (subquery) side of any {@link AbstractSubqueryJoin} is skipped entirely —
* it executes independently and its nodes are not "between" the full-text function and
* its data source.</li>
* <li>Nodes synthesised by {@link AbstractSubqueryJoin#inlineData} to materialise
* {@link org.elasticsearch.xpack.esql.plan.logical.join.MarkJoin} mark attributes are
* treated as opaque boundaries: traversal stops at these nodes and does <em>not</em>
* recurse into their children. Stopping is correct for two independent reasons:
* <ol>
* <li><b>Synthetic nodes are not user commands.</b> Directly below the boundary node
* sit implementation artefacts introduced by {@code inlineData} — a mark
* {@link Eval}, a {@link org.elasticsearch.xpack.esql.plan.logical.join.Join},
* an {@code $$&lt;field&gt;$sv} {@link Eval}, and a
* {@link org.elasticsearch.xpack.esql.plan.logical.local.LocalRelation} — none of
* which are in the QSTR/KQL allowlist, but none of which are user-written commands
* that could place a full-text function in a disallowed position.</li>
* <li><b>The user-written subtree was already verified.</b> The only user-written
* content embedded below the boundary is {@link AbstractSubqueryJoin#left()}.
* Before {@code inlineData} ran, the coordinator-side verifier walked through the
* {@link org.elasticsearch.xpack.esql.plan.logical.join.MarkJoin} node (allowed as
* an {@link AbstractSubqueryJoin}) and recursed into {@code left()}, checking every
* node there. Any disallowed user command in {@code left()} would have been caught
* at that point.</li>
* </ol>
* See {@link #isMarkJoinInlinedNode} for the boundary patterns detected.</li>
* </ul>
*/
private static void forEachDownExcludingSubqueryRightSide(LogicalPlan plan, Consumer<LogicalPlan> consumer) {
if (isMarkJoinInlinedNode(plan)) {
return; // synthetic inlining artefacts above; user-written left() already verified at coordinator time
}
consumer.accept(plan);
List<LogicalPlan> children = plan instanceof AbstractSubqueryJoin subqueryJoin ? List.of(subqueryJoin.left()) : plan.children();
for (LogicalPlan child : children) {
forEachDownExcludingSubqueryRightSide(child, consumer);
}
}

/**
* Returns {@code true} when {@code plan} is a node synthesised by
* {@link AbstractSubqueryJoin#inlineData} to materialise a boolean mark attribute, which comes
* in two shapes:
* <ul>
* <li><b>Filter path / empty / short-circuit</b>: a bare {@link Eval} whose every alias
* carries the {@link InSubqueryResolver#MARK_ATTRIBUTE_NAME_PREFIX} name prefix.</li>
* <li><b>Hash-join path</b>: a {@link Project} whose direct child is such a mark
* {@link Eval} (produced by
* {@link org.elasticsearch.xpack.esql.plan.logical.join.MarkJoin#buildHashJoinPathPlan}).
* </li>
* </ul>
* Both shapes are implementation artefacts that must not be treated as user-written commands
* in the positional check.
*/
private static boolean isMarkJoinInlinedNode(LogicalPlan plan) {
if (plan instanceof Eval eval) {
return eval.fields().isEmpty() == false
&& eval.fields().stream().allMatch(alias -> alias.name().startsWith(InSubqueryResolver.MARK_ATTRIBUTE_NAME_PREFIX));
}
if (plan instanceof Project project && project.child() instanceof Eval eval) {
return isMarkJoinInlinedNode(eval);
}
return false;
}

private static <E extends Expression> void checkCommandsBeforeExpression(
LogicalPlan plan,
Expression condition,
Expand All @@ -367,7 +438,7 @@ private static <E extends Expression> void checkCommandsBeforeExpression(
Failures failures
) {
condition.forEachDown(typeToken, exp -> {
plan.forEachDown(LogicalPlan.class, lp -> {
forEachDownExcludingSubqueryRightSide(plan, lp -> {
// `checkCommandsBeforeExpression` should be completely skipped for search functions that do not operate on index fields,
// but for now all checks apply, except for MV_EXPAND which can be used before a runtime search function
if ((lp instanceof MvExpand && exp instanceof FullTextFunction ftf && ftf.isRuntimeSearch()) == false
Expand Down Expand Up @@ -712,7 +783,7 @@ private static boolean isInCurrentNode(LogicalPlan plan, FullTextFunction functi
private static boolean hasSubqueryInChildrenPlans(LogicalPlan plan) {
Holder<Boolean> hasSubquery = new Holder<>(false);
plan.forEachDown(p -> {
if (p instanceof UnionAll) {
if (p instanceof UnionAll || p instanceof AbstractSubqueryJoin) {
hasSubquery.set(true);
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.elasticsearch.xpack.esql.plan.logical.RegexExtract;
import org.elasticsearch.xpack.esql.plan.logical.UnaryPlan;
import org.elasticsearch.xpack.esql.plan.logical.inference.InferencePlan;
import org.elasticsearch.xpack.esql.plan.logical.join.AbstractSubqueryJoin;
import org.elasticsearch.xpack.esql.plan.logical.join.InlineJoin;
import org.elasticsearch.xpack.esql.plan.logical.join.Join;
import org.elasticsearch.xpack.esql.plan.logical.join.JoinTypes;
Expand Down Expand Up @@ -174,7 +175,7 @@ private static LogicalPlan pushDownPastJoin(Filter filter, Join join, FoldContex
LogicalPlan plan = filter;
// pushdown only through LEFT joins
// TODO: generalize this for other join types
if (join.config().type() == JoinTypes.LEFT) {
if (join.config().type() == JoinTypes.LEFT || join instanceof AbstractSubqueryJoin) {
LogicalPlan left = join.left();
LogicalPlan right = join.right();
var conjunctions = Predicates.splitAnd(filter.condition());
Expand Down Expand Up @@ -208,7 +209,14 @@ private static LogicalPlan pushDownPastJoin(Filter filter, Join join, FoldContex
// motivation does not apply. The duplicate filter above the join already enforces the
// predicate. The only producer today is AbstractSubqueryJoin.inlineAsHashJoin's sentinel filter
// (which is a no-op against the constant-TRUE sentinel column anyway).
if (scoped.rightFilters.isEmpty() == false && join instanceof InlineJoin == false && right instanceof LocalRelation == false) {
// AbstractSubqueryJoin: never push filters to the right (subquery) side. The subquery executes
// independently and produces the IN-list values; injecting a filter there would silently
// shrink that value set and corrupt the join semantics. Left-side filters have already
// been handled above.
if (scoped.rightFilters.isEmpty() == false
&& join instanceof InlineJoin == false
&& right instanceof LocalRelation == false
&& join instanceof AbstractSubqueryJoin == false) {
List<Expression> rightPushableFilters = buildRightPushableFilters(scoped.rightFilters, foldCtx);
if (rightPushableFilters.isEmpty() == false) {
if (join.right() instanceof Filter existingRightFilter) {
Expand Down
Loading