Skip to content

feat(sql,core): derived tables in FROM and JOIN — grammar, AST, column scope, loud guard (story 22.1) - #331

Merged
fupelaqu merged 1 commit into
mainfrom
feature/22.1
Sep 14, 2026
Merged

fupelaqu merged 1 commit into
mainfrom
feature/22.1

Conversation

@fupelaqu

@fupelaqu fupelaqu commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Story 22.1 — Derived tables in FROM and JOIN: grammar, AST, column scope, loud guard

FROM (SELECT …) [AS] alias and [join_type] JOIN (SELECT …) [AS] alias [ON …] now parse into a
DerivedTable node, are validated (projection scope, LATERAL refusal, duplicate correlation
names, the body's own rules), and are refused loudly with HTTP 400 at every execution site until
the relational engine can run them.

This is epic 22's Layer 0: every later story consumes this node and the ONE
relationalClosureRequired predicate.

Corpus movement: 9 of the 11 captured derived-table statements now parse (Tableau's connection
probe SELECT COL FROM (SELECT 1 AS COL) AS SUBQUERY, its canvas row count, and Superset's
JOIN (… GROUP BY … LIMIT 10) AS series_limit series limit among them). The Epic-21 scoreboard
headline is unchanged at SCORES 56/99 — these rows score residual, because Epic 21 did not fix
them — while the raw parse count moves 81 → 90.

Release notes

  • New: derived tables parse and are validated. They execute only through the relational
    engine
    (story 22.4) and are refused with HTTP 400 elsewhere. The alias is mandatory
    (SQL-92 §7.6 correlation name; MySQL raises error 1248 for its absence).
  • Binary-incompatible: Table arity 4→5 (derived, defaulted, appended last); new
    DerivedTable class; new SubqueryScope object. Downstream (arrow / jdbc / extensions) rebuild
    on the next core bump — already owed by 21.x.
  • Behaviour change — the direct client API. client.search / searchAsync / scroll /
    searchWithInnerHits over a cross-index JOIN now return 400 where they used to return rows
    from the first index. MEASURED before the change on a stub client: the JOIN leg was silently
    dropped and the query became match_all over the first table, HTTP 200. That is JOIN queries without a join-capable extension silently return wrong results #157's residual
    on the raw API (no sibling production code calls it — every one goes through gateway.run — which
    is why it survived), closed here on the lead's ruling.
  • Behaviour change — the macros. searchAs / scrollAs over a cross-index JOIN or a derived
    table no longer COMPILE. The JOIN spelling used to compile and then run the first index alone.
  • Behaviour change — messages. CoreDqlExtension's JOIN queries without a join-capable extension silently return wrong results #157 rejection is reworded, names the shape
    (a derived table or a cross-index JOIN) and is venue-neutral: the same text now reaches a Scala
    embedder, the JDBC driver and the Flight sidecar verbatim, so "re-run the installer" is given as
    the REPL's spelling of the remedy rather than the only one. The pre-existing wrong
    repl.md#extensions anchor is corrected.
  • Behaviour change — refusals that used to be lexer errors. DELETE FROM (SELECT …) d,
    CREATE WATCHER … FROM (SELECT …) d and CREATE MATERIALIZED VIEW … FROM (SELECT …) d are now
    refused with a message that names the construct and the remedy.
  • Behaviour change — a stray ) changes its wording. SELECT a FROM t WHERE a = 1) is still
    rejected, but as trailing input rather than as "Unbalanced parentheses". An unmatched OPENING
    parenthesis keeps "Unbalanced parentheses" byte-for-byte.
  • CREATE MATERIALIZED VIEW validates its SELECT body at parse time — already true on main
    since BIDC-8; this story adds only the derived-table arm, ordered first so its message wins. No MV
    fixture in this repository or in softclient4es-extensions carries a derived table, so nothing
    that used to parse is newly rejected.
  • 🔴 Release-order rule (PD-4): do not cut 0.23.0 with this story unless the Arrow
    JoinPlanner refuses derived tables (the sibling PR linked below, or story 22.4).
    From.enrichmentRequired is true for FROM t JOIN (SELECT …) d, so a venue that HAS
    softclient4es-arrow-extensions routes the statement to Arrow, whose planner reads the
    correlation name as an index name. Tableau aliases a derived table with the inner index's own
    name, so the failure mode is an HTTP 200 wrong answer, not a 404.
    Sibling PR that lifts the gate: SOFTNETWORK-APP/softclient4es-arrow#181.

Arrow planner probe (the reason for PD-4 and the sibling PR)

JoinDetector.CrossIndexJoinPattern already routes JOIN (SELECT …) to JoinPlanner.plan, where it
used to die at Parser(cleanedSql). Now that it parses:

shape what the planner does outcome
FROM (SELECT …) d WHERE … (no JOIN) classified Passthrough, never reaches the planner loud — the core guard
JOIN (SELECT …) AS d ON … the derived leg is collected as a StandardJoin; TableInfo(name = sj.source.name) is the alias; the leg SQL becomes SELECT … FROM d silent by coincidenceindex_not_found if no such index exists, a wrong answer with HTTP 200 if one does
FROM (SELECT …) d JOIN customers c ON … the primary TableInfo(name = primaryTable.name) is "d" same silent mode, and the Arrow venue (not the core guard) takes it
FROM (SELECT … JOIN … ) v GROUP BY … primaryTable.joins is empty loud, with a misleading message

ParserSpec wall time (10 runs each, no assertion)

  • before: median 3.315 s, range 3.102–3.613 s
  • after: median 3.135 s, range 3.023–3.587 s

Verification

sql 1146 · core 1012 · macros-tests 23 · bridge template 203 · es6 bridge 203, all green;
cross-compiled on 2.12.20 and 2.13.16; headerCheck / scalafmtCheck / Test/scalafmtCheck green.
ES integration green on all five clients (es6 REST + Jest, es7, es8, es9) including the three new
derived-table rows.

Found while verifying — PRE-EXISTING, measured against a control checkout, NOT fixed here

  1. A double-quoted qualifier on the RIGHT-HAND side of a JOIN ON comparison is rejected.
    A quoted table name in JOIN position is rejected. That first characterisation was WRONG and
    is retracted
    — it was inferred from one failing statement with no control. Re-measured on
    unmodified main, shape by shape:

    shape verdict
    ON b.k = t0.k (control) parses
    ON "b"."k" = t0.k — quoted qualifier, LEFT side parses
    ON b.k = `t0`.`k` — BACKTICK qualifier, right side parses
    ON b.k = t0."k" — quoted column, bare qualifier parses
    ON b.k = "t0".k / ON b.k = "t0"."k" rejected (end of input expected)

    The JOIN table name is NOT the cause: JOIN u AS "t0" ON b.k = "t0"."k" (bare name) fails
    identically. The render round-trip is NOT broken either: every quoted-JOIN render measured
    (aliased, un-aliased, qualified, CROSS, backtick-canonicalised) is a fixed point.

    Root cause: story 21.1 AD-13 deliberately keeps literal FIRST in the alternation for value
    positions, so on the right of = the lexeme "t0" is read as the string literal t0 and
    ."k" becomes trailing input. Backticks are unambiguous, which is why they parse.

    Why it matters: Tableau's SQL-92 dialect double-quotes every identifier, so this is the exact
    ON it emits for a join (its MySQL/backtick equivalent already works, e.g. corpus row
    tableau.mysql.w8.054). It is also self-inconsistent — the mirrored ON "b"."k" = t0.k parses.
    Does not affect this story's nine corpus rows: every JOIN they carry is a derived table.

  2. SchemaCacheTtlApiSpec "refuse a non-positive value" fails on the Scala 2.12 leg of
    core/test, identically on the control. + core/test is not part of the documented gate;
    ++ 2.12.20 core/Test/compile is, and is green.

Residual recorded, not filed (lead ruling: narrow)

The duplicate-source rule is scoped to collisions involving a derived table. FROM orders JOIN orders (two sources, no written aliases) still collapses in the alias maps and still parses.

Sibling PR

SOFTNETWORK-APP/softclient4es-arrow#181 — the loud JoinPlanner refusal. Both were approved up front; this one lands first, then the core snapshot publishes, then the Arrow arm.

Closes #330

…m loudly until the engine can run them

`FROM (SELECT ...) [AS] alias` and `[join_type] JOIN (SELECT ...) [AS] alias [ON ...]` now parse
into a `DerivedTable` node whose `name` IS its correlation name, so every existing alias map,
qualifier resolution and join-leg key works with no new arm. The statement is validated -- an outer
reference must name a column the subquery projects, a body that reads an enclosing alias is
rejected as LATERAL, a correlation name may not name anything else in the same FROM, and the body's
own rules are checked one level down -- and then refused with HTTP 400 at every execution site,
because a derived table's `sources` is an ALIAS and executing it naively would search an index
named after the subquery.

Nine of the eleven derived-table statements captured from Tableau and Superset now parse; the two
that do not are declared residuals with a named owner. The Epic 21 headline is unchanged at
SCORES 56/99 -- these rows score `residual` -- while the raw parse count moves 81 -> 90.

Also here, because the grammar needs it: `whereCriteria` becomes a depth-aware scanner that leaves
a depth-0 `)` to whoever opened it. Without it, a body whose last clause is WHERE or HAVING loses
its own closing parenthesis to the inner clause and the whole statement is rejected as unbalanced.

Behaviour changes, all release-noted:
  - the direct client API (`search` / `searchAsync` / `scroll` / `searchWithInnerHits`) returns 400
    for a cross-index JOIN where it used to return rows from the first index with HTTP 200;
  - `searchAs` / `scrollAs` no longer compile for a cross-index JOIN or a derived table;
  - the closure rejection message names the shape and is venue-neutral;
  - `DELETE`, `CREATE WATCHER` and `CREATE MATERIALIZED VIEW` refuse derived tables by name;
  - a stray `)` is still rejected, as trailing input rather than as unbalanced parentheses.

`Table` gains a defaulted `derived` field (arity 4->5), so downstream repositories rebuild on the
next core bump.

Closed Issue #330
@fupelaqu
fupelaqu marked this pull request as ready for review September 14, 2026 09:11
@fupelaqu
fupelaqu merged commit 3a6a217 into main Sep 14, 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

Development

Successfully merging this pull request may close these issues.

Derived tables in FROM and JOIN are rejected by the parser (every BI tool emits them)

1 participant