Skip to content

Uncorrelated WHERE subqueries (IN / EXISTS / scalar / quantified) — elasticsql - #337

Merged
fupelaqu merged 1 commit into
mainfrom
feature/22.2
Sep 15, 2026
Merged

fupelaqu merged 1 commit into
mainfrom
feature/22.2

Conversation

@fupelaqu

Copy link
Copy Markdown
Contributor

Uncorrelated WHERE subqueries — IN, EXISTS, scalar and quantified — now parse and execute ES-natively at every venue, including the plain REPL. The inner statement runs first at the single SingleSearch -> ElasticQuery seam (SearchApi.resolveWithSchema) and the predicate is rewritten into the literal form the bridges already emit.

Two further changes ride along, both directed by the lead: aggregates over zero documents now answer ANSI NULL on every major, and a grammar-wide packrat-memoisation defect is fixed (parse cost −64 %).

Closes #335

Closes #336

What ships

  • Grammar: <id> [NOT] IN (<body>), [NOT] EXISTS (<body>), [NOT] <id> <op> (<body>) for = <> != >= > <= <, and <id> <op> ANY|SOME|ALL (<body>). Bodies may end in WHERE or HAVING. Nothing is newly reservedWHERE any = 1 and SELECT some FROM t still parse; what makes the quantified form win is the alternation ORDER.
  • AST: InSubquery / ExistsSubquery / ScalarSubquery / QuantifiedSubquery, plus the resolved sentinels MatchAllCriteria / MatchNoneCriteria. = ANY|SOME renders canonically as IN and <> ALL / != ALL as NOT IN; SOME canonicalises to ANY. Every accepted shape is a parser fixed point (Parser(stmt.sql) == Right(stmt)) with its rendered text pinned.
  • Execution: two-phase, bounded at index.max_terms_count (65,536). A bare-column body runs as a bounded terms aggregation; a text or date inner column, and everything else, runs as written. Nested subqueries resolve recursively. An inner failure propagates with ITS status and cause.
  • ANSI NULL semantics, tested Docker-free and on real clusters: IN ignores inner NULLs, NOT IN over a NULL-bearing set returns no row, a scalar over zero rows matches nothing, ANY/SOME over an empty set is FALSE while ALL over an empty set is TRUE, and a NULL makes every ALL form — and every negated form — UNKNOWN.
  • Refused loudly, each with its own message: correlated subqueries, UNION ALL and FROM-less bodies, more than one projected column in IN / quantified / scalar position, a scalar body that is neither metric-only nor LIMIT 1, and subqueries in HAVING / CASE / JOIN ON / MATERIALIZED VIEW / WATCHER.

Routing does not move: such a statement is passthrough, and the licensed row cap still binds on the OUTER rows — the inner statement is an internal execution step and is not quota-checked.

Release notes

  • Version: main carried the released 0.23.0, so this branch opens 0.24.0-SNAPSHOT.
  • Binary-incompatible (sql): new Criteria subtypes (an exhaustive match downstream needs new arms) and new operator tokens (EXISTS, ANY, SOME, ALL). SingleSearch arity is unchanged. Downstream rebuild required.
  • Behaviour change — WHERE x = ANY (…) / = SOME (…): these used to parse ANY/SOME as a column name and then fail; they are now the quantified form. No word is newly reserved, and a column named any or some keeps working.
  • Quantified semantics (worth stating because the two empty-set rules are opposite): > ANY reduces to > min, > ALL to > max (and symmetrically for >=, <, <=); = ALL holds only when the inner set is a single value; <> ANY is its negation. Over an EMPTY subquery ANY/SOME is FALSE and ALL is TRUE. A NULL among the inner values makes every ALL form and every negated form match nothing.
  • 🔴 Aggregates over zero documents now return NULL — on EVERY aggregation, not only inside a subquery. SELECT MAX(x) FROM t WHERE <no match> previously answered 0.0 on ES 8.18 and an absent column on 6.8 / 7.17 / 9.0; it now answers NULL everywhere, as an explicit null column. COUNT is unchanged: 0, never NULL. SUM is unchanged: it keeps Elasticsearch's own 0.0 — a deliberate divergence from ANSI (which says NULL), because the engine computes that value on every supported major and synthesising NULL would override the engine everywhere and silently flip SUM for every existing consumer. Any statement, dashboard or downstream consumer that relied on the old 0.0 for MIN/MAX/AVG will see different results. The rule lives in one place, ClientAggregation.nullOverEmptyInput.
  • Seam behaviour: SearchApi.resolveWithSchema now executes inner statements synchronously on both the sync and the async path — on the thread that constructs the outer request, bounded at one round trip for a projection / scalar / EXISTS body and at ~66 scroll pages for a row-shaped one. Inner row-shaped scrolls materialise on the separate scroll-routing system, so there is no self-deadlock at any nesting depth.
  • ElasticBridge.query refuses an unresolved subquery node with a named IllegalArgumentException (was the generic Unsupported filter type).
  • Relation validation (pre-existing, wider than subqueries): NESTED(…) / CHILD(…) / PARENT(…) now validate the criteria they wrap. Rules that were silently skipped inside a relation — including IN's and an expression's own type checks — now apply, so a statement that parsed before may be rejected.
  • Date literals (pre-existing Space-separated timestamp literals are forwarded verbatim to ES and rejected — only T-separated ISO-8601 works (breaks Superset date filters) #276 gap): a literal in ISO form compared against a column whose mapping format accepts no ISO alternative used to be forwarded verbatim and rejected by Elasticsearch; it is now re-rendered in the column's own pattern. WHERE ts = '2026-06-04T00:00:00' against a "format": "yyyy-MM-dd HH:mm:ss" column now works. One contract pin was retargeted accordingly.
  • Cardinality error message: the too_many_buckets / max_terms_count translation is now on every search path and its wording covers both causes, so a plain high-cardinality GROUP BY gets a clearer message than before.
  • Scroll logging: the ▶ Row query … INFO line and ElasticResponse.sql on the scroll route now carry the statement AS WRITTEN, as search always did — a resolved subquery can carry up to 65,536 literals.
  • Documentation lag, accepted and deliberate: the published documentation still states that IN (SELECT …) is rejected by the parser. The prose sweep is held for the epic's documentation story; the in-repo help corpus (SELECT topic) is updated here.

Parse cost: 3.490 s → 1.240 s median (−64 %)

ParserSpec, 10 timed runs per configuration, same quiesced machine, median and range:

configuration median range
main, pristine (control, same session) 3.490 s 3.132–3.764
this branch with the new alternatives, before the fix 4.927 s 4.534–5.085
this branch as shipped 1.240 s 1.188–1.647
main + the SAME two-word fix (control) 1.228 s 1.203–1.626

PackratParsers memoises on (parser INSTANCE, position) and the library's own scaladoc requires productions to be lazy val; all 49 WhereParser productions were defs, so no alternative could ever hit another's memo entry. Declaring any_identifier and criteria as lazy val is the whole fix.

The main-with-the-fix row is the one that matters: the win is a pre-existing, grammar-wide cost that every statement the engine has ever parsed was paying — not a credit this story's grammar earns. Against that control this story's four new alternatives cost +0.95 % (1.228 → 1.240), with fully overlapping ranges, i.e. below the suite's measurement resolution. No timing assertion was added.

Folding the subquery forms into equality / comparison / inLiteral as RHS shapes was considered and deliberately not done: it targets ~1 % that cannot be measured above noise, at the cost of a large diff into the three hottest shared productions.

Narrowing check. Because this touches shared productions, the parser was built from BOTH trees and fed the same 4,824 distinct inputs — every census and help-corpus statement from main, every prefix truncation at token boundaries (a broad refusal corpus), plus stray-paren, dangling-AND and malformed-keyword variants — and the verdicts diffed: 0 differing, 0 narrowed, 0 widened, 0 throws.

Verification

  • sql 1208, core 1049, bridge template 206, es6 bridge 206, es9 bridge 206, macrosTests 24 — all green; + sql/compile and + core/compile green on both Scala legs.
  • headerCheck scalafmtSbtCheck scalafmtCheck Test/scalafmtCheck green.
  • Real Elasticsearch 6.8 (REST + Jest), 7.17, 8.18, 9.0, every client: WhereSubqueryCompletenessSpec (3-shard two-index fixture, closed-form oracles) 16 tests — one row version-gated because ES 6.8 does not enforce a per-index max_terms_count; the sibling completeness guards (GroupBy, Select, WindowPartition, Limit, Scroll) 73 tests; and ReplGatewayIntegrationSpec 70 tests, including the new WHERE-subquery section.
  • The empty-aggregate change was measured on the wire and after conversion on all four majors before and after the fix, and is guarded by an exact-value assertion (NULL for MIN/MAX/AVG, 0 for COUNT, 0.0 for SUM, and unchanged values on a non-empty aggregate) on a multi-shard fixture, on every client.

…/ quantified

Story 22.2. The four ANSI-92 uncorrelated WHERE-subquery forms parse, render
back to themselves, and execute ES-natively at every venue: the inner statement
runs first at the single `SingleSearch -> ElasticQuery` seam and the predicate is
rewritten into the literal form the bridges already emit (`terms`, a literal
comparison, `match_all`, `match_none`).

Correlated subqueries are refused with a named message; ANSI NULL semantics hold
for IN / NOT IN, the scalar form and every quantifier, including the two opposite
empty-set rules (ANY over nothing is FALSE, ALL over nothing is TRUE).

Also in this change, both lead-directed:

* An aggregate over ZERO documents now answers ANSI NULL on every major. Measured
  on real clusters, Elasticsearch answers identically on the wire and ES 8 alone
  converted the null to `0.0`, because that module reads the typed response and a
  primitive `double` swallows the null inside the vendor's model. The null is
  recovered from the document count, in core, so one rule serves every client.
  COUNT stays 0; SUM keeps Elasticsearch's 0.0 by recorded decision.

* Packrat memoisation was defeated grammar-wide: the library memoises on the
  parser INSTANCE and every `WhereParser` production was a `def`. Declaring
  `any_identifier` and `criteria` as `lazy val` takes `ParserSpec` from 3.490 s
  to 1.240 s median (-64 %), verified to change no verdict over 4,824 inputs
  parsed by both trees.

Closes #335
Closes #336

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fupelaqu
fupelaqu marked this pull request as ready for review September 15, 2026 05:11
@fupelaqu
fupelaqu merged commit e7a7348 into main Sep 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant