Skip to content

BIDC-8: self-join — keep both aliases of one index (lossless From.aliasesToTable), reject one alias for two sources - #322

Merged
fupelaqu merged 11 commits into
mainfrom
feature/BIDC-8
Sep 12, 2026
Merged

BIDC-8: self-join — keep both aliases of one index (lossless From.aliasesToTable), reject one alias for two sources#322
fupelaqu merged 11 commits into
mainfrom
feature/BIDC-8

Conversation

@fupelaqu

@fupelaqu fupelaqu commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Story BIDC-8 — self-join: From.tableAliases keyed by table name collapses the legs (core half)

Refs SOFTNETWORK-APP/softclient4es-arrow#144 — closed by the arrow PR (softclient4es-arrow branch feature/BIDC-8, held until core's 0.23.0-SNAPSHOT is merged and re-published); this body deliberately carries no closing keyword, per the epic's issue-closure bookkeeping. Core half of the fix; the arrow half (planner leg attribution, the arrow#137 case-5 named rejection, the integration suites) rides train B after 0.23.0 publishes.

What was wrong (AC 1 — reproduced, not assumed)

From.tableAliases is ListMap[tableKey -> alias], so it can hold ONE alias per table by construction. For FROM idx a JOIN idx b both legs have the identical qualified reference, 21.2's aliasKey returns the bare idx for both, and the map keeps idx -> b. aliasesToTable was .swap of it, so alias a was gone from BOTH maps. Probe at the baseline b37ef940 (_bmad-output/implementation-artifacts/BIDC-8-reproduction-core.txt):

SELECT a.id, b.amount FROM idx a JOIN idx b ON a.id = b.id
  tableAliases   = ListMap(idx -> b)
  aliasesToTable = ListMap(b -> idx)
  a.id     -> name=a.id   table=None      tableAlias=None      <- never resolved
  b.amount -> name=amount table=Some(idx) tableAlias=Some(b)
  joinKeyMatches = Some(List())                                <- the ON clause lost its join key

JoinKey.apply needs Identifier.table, so the ON clause carried no key, the arrow JoinSpec.condition was empty and DuckDB received FROM "sq_a" INNER JOIN "sq_b" with no ON — the issue's "Parser Error: syntax error at end of input" (end-to-end capture on real ES 8.18 in BIDC-8-reproduction-arrow.txt). The issue's own hypothesis (registration keyed on the index) is refuted: sq_<alias> is alias-keyed.

The fix (AD-1, on top of 21.2's AD-6 — extends it, does not re-key it)

  • From.aliasesToTable is built directly and LOSSLESS (alias -> table key, same aliasKey on the table side). It equals the old .swap wherever no two aliases share a key and keeps BOTH aliases of a self-join. tableAliases keeps its documented one-alias-per-table contract untouched — softclient4es-extensions reads it FORWARD (JoinDependencyGraph:520) and its FieldAnalyzer reverse-scans it; neither sees a byte of difference.
  • The two in-core reverse-lookup consumers now read aliasesToTable: Identifier.update (sql/package.scala) and FieldSort.update (SQL parser: ORDER BY on a bare table alias produces an empty column name (dangling qualifier) #159's bareTableAlias). Identifier.table stays a tableAliases KEY, so schemas, joinSourceKeys and TemporalLiterals keep their key language.
  • TemporalLiterals: the cross-index guard's set is joinSourceKeys - mainTableKey — on a self-join the join source IS the main index, whose mapping IS in hand (new From.mainTableKey).
  • Tripwire 2 (lead ruling: keep or reject LOUDLY, never change silently): the comma-FROM duplicate FROM t a, t b is rejected in From.validate() naming the JOIN spelling. Baseline behaviour was a.x unresolved + b.y resolved (a silent wrong answer); after the re-key both would have resolved against one doubled index (a different one). FROM t, t (same alias) unchanged; 21.2's FROM "a".orders o, "b".orders p unchanged.

Found by the review, fixed here (AD-7)

CreateTable and CreateMaterializedView had no validate() override — both inherited the no-op default, so every SingleSearch.validate() rule was skipped inside CREATE TABLE … AS SELECT and CREATE MATERIALIZED VIEW … AS SELECT (measured: CREATE TABLE t AS SELECT a.x, b.y FROM t a, t b was accepted while the bare SELECT was rejected; INSERT … SELECT already delegated). Both now delegate to the embedded query's validate(); the column form of CREATE TABLE is unchanged. Blast radius: 0 over the sql corpus (1054/1054) and over softclient4es-extensions' unit suites (see evidence). Pinned by the "REJECTED inside every statement kind that embeds a query" case.

Round 2 (independent review) — also in this branch

  • One EXPLICIT alias written for two sources is rejected in From.validate(), case-insensitively (FROM orders o JOIN customers o, FROM orders A JOIN orders a, FROM t a, t a): these parsed, every o.x resolved against the LAST source and the arrow planner registered two legs as one sq_o (DuckDB: "closed pending query result"). An alias-less table's bare name is NOT an alias (round 3, NEW-4): FROM t, t, FROM "prod_us".orders, "prod_eu".orders, FROM "a".orders, orders keep their 21.2 multi-index-search acceptance, and the alias-less FROM t JOIN t is left to the join planner's own named rejection. CLAUDE.md's "licensing infrastructure #73" for this is stale — gh issue view 73 is a merged PR — so nothing is closed.
  • The lossless map also changes resolution for an UNNEST whose nested field is named like its table (FROM orders o JOIN UNNEST(o.orders) AS i): o.id used to stay a literal field name and now resolves — pinned, release-noted.
  • Watcher-flip pin asserts CREATE WATCHER … FROM a JOIN b ON … parses the JOIN and then silently discards it #191's exact message; AD-6 scaladoc corrected; remedy text no longer invents an alias for an alias-less duplicate.

Deferred, pre-existing (recorded in the findings log, not changed)

  • searchInput (watcher) never runs From.validate(): CREATE WATCHER … FROM orders o, orders p WHERE x = 1 (UNQUALIFIED) is still accepted and flattened to orders — behaviour-preserving; the QUALIFIED form is rejected by CREATE WATCHER … FROM a JOIN b ON … parses the JOIN and then silently discards it #191's guard.
  • A second .update() on an already-updated statement collapses fieldAlias across same-named legs; FROM "a".orders o, orders p accepted while sources = [orders, orders] (21.2 preserve-don't-interpret residual); JoinKey.key collides on a self-join (no consumer).

AD-9 — the Painless FILTER emission (lead ruling, option (a)): a WHERE with a function now runs at all

Measured on live ES 8.18.3 before the change: WHERE UPPER(status) <> 'ZZZ' emitted
(param1 == null) ? null : param1.toUpperCase().compareTo("ZZZ") != 0, whose ternary branches are
null (Object) and a primitive boolean — Elasticsearch rejected the whole query at COMPILE time
(script_exception: compile error, caused by class_cast_exception: Cannot cast from [boolean] to [java.lang.Object]), data-independent: it failed against an EMPTY index. The numeric family
failed identically (Cannot cast from [java.lang.Double] to [int]); the date family escaped only
because YEAR(...) folds into the parameter assignment and took the already-correct guarded branch.
Two further defects sat on the same path: a composed predicate was not parenthesised, and ?:
is the loosest Painless operator, so a guard swallowed its sibling; and check chose its hard-coded
spelling from the raw operator, so a NOT was silently dropped.

The fix: in a Query context a left-hand side that reads a document field is bound to a
prologue-declared local and compared INSIDE its guard — (<v> == null ? false : (<check>)); every
Predicate operand is parenthesised; check dispatches on the effective (NOT-folded) operator.
script_fields, sorts, aggregations and ingest processors keep null (21.8 Part C) — the
script_fields expectations in SQLQuerySpec are byte-identical, which is the evidence the change
stayed inside the filter context. ANSI three-valued logic is preserved because NOT is folded into
the operator before the guard and NOT (<composite>) is not expressible in this dialect (measured);
the ruling's Boolean-boxing sketch was therefore not needed — recorded as the rejected alternative
in the story spec.

Row-set pins (new, GatewayApiIntegrationSpec, real ES, id1 status='A' / id2 'B' / id3 field
ABSENT, seeded via COPY INTO because an INSERT omitting a column writes an empty string):
UPPER(status) = 'A'[1]; NOT UPPER(status) = 'A'[2]; UPPER(status) <> 'A'[2];
… OR id = 1[1]; … AND id = 1[1]; NOT … AND id = 1[]; NOT … OR id = 1
[1,2]; ABS(amount) > 10[2,3]; LOWER(status) = 'a' AND ABS(amount) > 10[]. Two
un-scripted shapes are pinned beside them to make the two routes visible: status = 'A' OR id = 1
[1] (term queries) and NOT status = 'A'[2,3] — ES must_not INCLUDES a document lacking the
field, a pre-existing divergence from ANSI this story does not change.

Fixture pins changed — three, each justified:

fixture (SQLQuerySpec, both bridge copies) old filter bytes new filter bytes why correct
arithmetic function as script field and condition (param1 == null) ? null : (param1 * (param2 - 10)) > 10000 def left1 = (param1 == null) ? null : (param1 * (param2 - 10)); (left1 == null ? false : ((left1 > 10000))) old text does not compile on any ES (measured); new binds the value and compares inside the guard
mathematic function as script field and condition (param1 == null) ? null : Double.valueOf(Math.sqrt(param1)) > 100.0 def left1 = …Math.sqrt(param1); (left1 == null ? false : ((left1 > 100.0))) same defect, numeric family
string function as script field and condition (param1 == null) ? null : param1.trim().length() > 10 def left1 = …param1.trim().length(); (left1 == null ? false : ((left1 > 10))) same defect, string family

Two re-spacing rules (defleftdef left, false:false : ) were added to the
whitespace-stripped expectation chains; they normalise the EXPECTED text only and weaken no
assertion. No script_fields expectation moved.

Gate integrity: the arrow branch's P-1 integration case, which round 6 had narrowed to avoid this
surface, is restored to the leg-local pushdown shape and passes on real ES.

Pins deliberately changed (AC 7 — 21.2 coordination; every one pre-authorised by LR-4)

pin 21.2 said now
QuotedTableNameSpec "leave two IDENTICALLY qualified same-name tables collapsing, as before (AD-6 scope)" tableAliases == ListMap(orders -> p) for the comma shape tableAliases still orders -> p, aliasesToTable keeps o AND p (JOIN spelling); the comma spelling is now REJECTED
QuotedTableNameSpec watcher FROM orders o, orders p WHERE o.x = 1 "still defeats it, exactly as before" — accepted accepted REJECTED by #191's guard (a qualifier over a doubled single index is still a qualifier a multi-index search cannot scope)
ParserSpec "reject a self-correlation through duplicate table names" rejected because only p.parent_id resolved still rejected, now with both sides resolved — comment updated
Parser.scala qualifiedOverManyIndices scaladoc "a WHOLLY unqualified self-join still defeats it" updated

#159's two canary tests stay green untouched; the canary now also covers ORDER BY a / ORDER BY b on a self-join (SelfJoinAliasSpec).

Evidence

  • sql/test 1058/1058 (baseline 1044 + 12 new: SelfJoinAliasSpec ×11, TemporalLiteralsSpec +1) · core/test 979/979 · + sql/compile · + core/compile · ++ 2.12.20 sql/Test/compile.
  • BEFORE-form pins were run green at the baseline before the re-key (T4), then flipped with the before-state recorded in their comments.
  • Downstream (arrow, held branch feature/BIDC-8): planner/executor unit suites and the JOIN integration suites on real ES — see the arrow PR body.

Release note (0.23.0)

  • A self-join (FROM idx a JOIN idx b ON …) now resolves both legs; every column must be qualified (a bare name is ambiguous, as for any JOIN).
  • Newly rejected: a table listed twice in a comma-separated FROM under different aliases (FROM t a, t b); one explicit alias for two sources, case-insensitively (FROM orders o JOIN customers o, FROM orders A JOIN orders a, FROM t a, t a); a watcher input FROM orders o, orders p WHERE o.x = 1 — all used to be accepted with a silently wrong reading.
  • Resolution change: an UNNEST whose nested field is named like its table now resolves the table's own qualifier (o.id in FROM orders o JOIN UNNEST(o.orders) AS i was a literal field name before).
  • Newly rejected: a CREATE TABLE … AS SELECT / CREATE MATERIALIZED VIEW … AS SELECT whose SELECT violates a SELECT-level rule (e.g. OFFSET is silently dropped under GROUP BY #295 OFFSET under GROUP BY, an inline HAVING aggregate, ORDER BY <bare table alias>) — the embedded query was never validated before (AD-7).
  • No binary-incompatible change: From/SingleSearch gain lazy vals only; CreateTable/CreateMaterializedView gain an override.

Round 9 — the independent AD-9 audit, fixed forward

The audit executed the round-8 emission instead of reading it, and found one blocking regression
plus four shapes that had never worked. All are fixed here; the safety claim that covered them
(round 8's "C1") is recorded as FALSIFIED, with what replaces it.

What the audit falsified. C1 said the false collapse could not reach a projection because a
PainlessContext is only supplied on the filter path. A CASE over a function takes a context too,
and the condition's local was bound on a throwaway context while the prologue came from another, so
Elasticsearch answered HTTP 400 … cannot resolve symbol [left1] — a script referencing a name it
never declared. The containment half of the claim rested on "no expectation moved", which is absence
of evidence: four more shapes were broken and no unit test could see any of them.

What replaces it is a different KIND of claim — enumeration. PainlessNullSurvivalSpec names every
consumer of a criteria rendering in the repository (script query, predicate composition, CASE
condition, HAVING bucket_selector) and every one is a BOOLEAN position, which is why the collapse
is right in all of them. ⚠️ That is a deliberate deviation from the audit's prescription of a
context discriminator: measurement says such a flag would have exactly one legal value, because an
Expression IS a boolean and a projected VALUE goes through a different renderer that still emits
? null :. Pinned three ways (script_fields, script sort, terms script).

Red then green, measured on live Elasticsearch 8.18.3 over five documents (A, B, field
ABSENT, A with a second field absent, C):

item before after
CASE over a function HTTP400 … cannot resolve symbol [left1] 1/0/0/1/0, and WHERE (CASE …) = 1[1, 4]
NOT after AND/OR NOT UPPER(status)='A' AND amount>10[2] but the mirror order → [2,3] both [2, 5]
the same NOT inside a CASE the field-absent row took THEN 0/0/0/0/1 — ANSI
both operands guarded null_pointer_exception on the shard [1, 2, 5]
non-atomic operand Cannot cast from [int] to [java.lang.Object] [1, 4]
NOT LIKE over a function scala.MatchError: LIKE escaping the query builder [2, 5]
IN / BETWEEN over a function {"terms":{"":…}} rejected as [bool] failed to parse field [filter] [1, 2, 4] / all five
LIKE over a function invalid sequence of tokens near ['"A%"'] [1, 4]

Two Painless claims measured and refuted before the shipped LIKE spelling: neither
String.matches(String) nor Pattern.compile is whitelisted (dynamic method … matches/1 not found, static method … compile/1 not found) — a regex may only enter through a /…/ literal. So
a %-only pattern decomposes into whitelisted String methods and only a pattern that truly needs
a regex uses the literal. That is also the only form that runs on ES 6.8, where script regexes
are disabled by default — verified green there.

Reported, not fixed (pre-existing, outside this story's surface): REGEXP_LIKE emits
Pattern.compile(...), which by the refutation above cannot run in any script context; and
ORDER BY UPPER(status) over a column some documents do not carry LOSES ROWS SILENTLY — a SORT
script, whose emission this story leaves correct (? null :, pinned). On a single-shard index the
search is rejected; on a multi-shard index it returns HTTP 200 with the failing shard's rows simply
absent (measured: 3 shards, 7 docs, _shards.failed: 1, hits.total: 5), because core has no
_shards.failures consumer.

New gates, each shown failing. PainlessOperandFormSpec fails on 14 shapes when
PainlessContext.bindLocal stops hoisting into the prologue (a statement sequence spliced into a
predicate or a CASE is a compile-time 400). PainlessNullSurvivalSpec's declared-vs-referenced
check fails when a local is bound without being declared — the exact B-1 root cause.

Regression. sql 1061, core 979, bridge 202 × es6/es7/es8/es9, + sql/compile, ++ 2.12.20 for
sql and core test sources, scalafmt, headerCheck. On real Elasticsearch the extended
GatewayApiIntegrationSpec ANSI test is green on 6.8.23, 7.17.29, 8.18.3 and 9.0.3, and the
full es8 JavaClientGatewayApiSpec is 75/75.


Round 10 — the final audit, fixed forward

One blocking regression this PR introduced, two measurably false documentation claims, and four
smaller defects. Every one measured on live Elasticsearch 8.18.3 before and after, over documents
A / B / field-absent / A.B / AXB1 / A-with-no-amount.

🔴 BLOCKING — NOT over a function-wrapped BETWEEN turned a loud 400 into a silent wrong
answer.
BetweenExpr had no negated override, so the predicate's NOT fell back to wrapping
the un-negated criterion in a must_not, whose semantics INCLUDE a document lacking the field —
inverting the guard's false into a match. WHERE status = 'A' AND NOT ABS(amount) BETWEEN 1 AND 10
returned the row with no amount; the mirror order returned nothing. At the previous head that
statement was a loud HTTP 400, so this PR had made it worse. Fixed, and the new pins cover
BETWEEN, IN and IS NULL mirror-orders — the round-9 pair used only UPPER(status) = 'A', the
one criteria class that already had the override, which is exactly why the hole survived.

🔴 LIKE meant three different things at once. toRegex translated % and _ and nothing
else, so every other regex metacharacter kept its regex meaning on the paths that end in a regex.
Measured: status LIKE 'A.B%' (native regexp) matched A.B AND AXB1; UPPER(status) LIKE 'A.B%' (string methods) matched only A.B; UPPER(status) LIKE 'A.B%1' (Painless regex) matched
only AXB1. Fixed in the SHARED translation — fixing it only in the Painless emitter would have
produced a fourth reading.

Two documentation claims were false and are corrected in both twins. "The same predicate returns
the same rows whichever way round you write it" was falsified by the BLOCKING item (true again now).
"Patterns using only % work on every supported version" was simply wrong: 'A%B' is pure % and
still compiles to a regex, which stock ES 6.8 refuses. The docs now state the real rule — the
string-method fast path is taken when the pattern has no _ and % only at the ends.

Smaller: LIKE '' over a function emitted left1 ==~ //, and // opens a Painless comment
(unexpected character [//))))]) — the empty core now takes the equality path. CASE … THEN x END
with no ELSE emitted a truncated ternary (param2 ? 1), then, once completed, a
Cannot cast from [int] to [java.lang.Object] — SQL's implicit NULL is now emitted with the result
boxed, which is the spelling that compiles without a return (measured both ways).

🔴 One audit prescription was REFUTED and NOT applied, with the evidence. The audit asked for a
fourth consumer of Predicate.notElasticBridge's nested arm — to be aligned with the other
three by folding. It was tried: SQLQuerySpec's "predicate with distinct nested" fixture turned
must_not[nested] + filter into must[nested, nested(… == false)]. Under a nested relation the
criterion runs over a CHILD document inside an existential, so NOT outside and NOT inside are
different questions — "no reply is before that date" (a blog with no replies qualifies) versus "some
reply is not before it" (it does not). The fold applies where the negated criterion is evaluated
over the SAME document, and that site is now marked NOT-FOLD: with the reason. What the audit was
right about is that nothing ENUMERATED these consumers: all four are now listed on
Predicate.emittedRight, and a source scan fails if a new one states neither side.

Release notes for this version

  • LIKE reads only % and _ as wildcards. Every other character in a pattern is now matched
    literally, on the scripted and the native path. WHERE status LIKE 'A.B%' previously matched
    AXB1; it now matches only values that really begin with A.B. Patterns relying on the old
    reading must be rewritten with _ or %. RLIKE is unaffected — its operand is a regular
    expression by definition.
  • A CASE over a function inside SCRIPT AS emits a different script. The condition now
    collapses an absent field to false where it previously rendered null, so an ingest pipeline
    stored before this version differs from what the engine generates now. Re-run
    CREATE TABLE / ALTER … SET SCRIPT AS for tables built earlier if the pipeline must match.
    (Same class as the 21.8 Part C note.)
  • ORDER BY over a function of a sparsely populated column LOSES ROWS SILENTLY on a multi-shard
    index
    — disclosed, not introduced here. The emitted sort script preserves null correctly;
    Elasticsearch fails the shard while building the comparator. Single-shard: the search is
    rejected. Multi-shard: HTTP 200 and the failing shard's documents are absent from the result,
    with no error reaching the caller (core does not inspect _shards.failures). Documented in both
    doc twins; surfacing shard failures is recorded as a separate issue.

Recorded, not fixed

  • REGEXP_LIKE emits java.util.regex.Pattern.compile(...), which is not whitelisted in any script
    context — measured in round 9, untouched here, no test exercises it.
  • A future improvement for scripted LIKE: UPPER(x) / LOWER(x) over a plain column is really a
    case-insensitive match, and ES ≥ 7.10 supports case_insensitive: true on regexp / wildcard
    natively. It would not replace the decomposition — a genuinely computed operand
    (SUBSTRING(x,1,3) LIKE 'AB%') cannot be a native query — and it is unavailable on 6.8. Recorded
    at the decomposition site; no issue filed.

🤖 Generated with Claude Code

fupelaqu and others added 11 commits September 11, 2026 13:35
…ToTable, alias-first resolution

Refs SOFTNETWORK-APP/softclient4es-arrow#144 (closes from the arrow PR, not from here).

`From.tableAliases` is `ListMap[tableKey -> alias]`, so by construction it holds ONE alias per
table: for `FROM idx a JOIN idx b` both legs share the bare key `idx` and the map kept `idx -> b`.
`aliasesToTable` was its `.swap`, so alias `a` was gone from BOTH maps, `Identifier.update`
never resolved `a.id`, `JoinKey.apply` produced no key and `On.joinKeyMatches` was EMPTY —
the malformed `INNER JOIN "sq_b"` (no ON) that arrow#144 saw as DuckDB's "syntax error at end
of input" (reproduced at b37ef94 before any fix; captures in the PR body).

- `From.aliasesToTable` is built DIRECTLY and losslessly (alias -> table key, same `aliasKey`
  on the table side): a strict superset of the old `.swap`; `tableAliases` keeps its
  one-alias-per-table contract byte-for-byte (extensions read it forward). The two in-core
  reverse-lookup consumers move to it: `Identifier.update` and `FieldSort.update` (#159's
  `bareTableAlias`, whose canary now also covers `ORDER BY a` / `ORDER BY b` on a self-join).
- `TemporalLiterals`: join-source set minus the new `From.mainTableKey` — a self-join's join
  source IS the main index, whose mapping is the one in hand.
- Tripwire 2 (lead ruling "keep or reject loudly"): `FROM t a, t b` — a table listed twice in a
  comma-separated FROM under DIFFERENT aliases — is rejected in `From.validate()` naming the
  JOIN spelling with the user's aliases; `FROM t, t` and 21.2's `FROM "a".orders o, "b".orders p`
  are untouched. Pinned in the BEFORE form at the baseline, then flipped.
- Found by the review by measurement, fixed here: `CreateTable` and `CreateMaterializedView`
  had no `validate()` override, so `CREATE TABLE … AS SELECT` / `CREATE MATERIALIZED VIEW … AS
  SELECT` skipped every SELECT-level rule (`INSERT … SELECT` delegated). Both now delegate;
  blast radius 0 over the sql corpus (1054) and extensions' unit suites (162).
- 21.2's AD-6 scope pins are changed ON PURPOSE (LR-4): the "identically qualified tables keep
  collapsing" pin now asserts the lossless map and the comma rejection; the watcher
  `FROM orders o, orders p WHERE o.x = 1` flips from accepted to rejected by #191's guard
  (a qualifier over a doubled single index is still one a multi-index search cannot scope).
- `documentation/sql/joins.md`: new Self-join section; the arrow#137 "must be written bare"
  limit note now describes the named error (MDX twin in softclient4es-web feature/BIDC-8).

Release notes (0.23.0): self-joins resolve both legs (qualify every column); newly rejected —
`FROM t a, t b`, the qualified watcher self-join, and a CTAS / MV whose SELECT violates a
SELECT-level rule. No binary-incompatible change (lazy vals + two overrides).

Tests: sql 1054 (+10: SelfJoinAliasSpec x9, TemporalLiteralsSpec +1), core 979, `+ sql/compile`,
`+ core/compile`, `++ 2.12.20 sql/Test/compile`; arrow JOIN integration on real ES 6.8 / 7.17 /
8.18 / 9.0 against this branch's publishLocal (see the arrow branch).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… findings

Follow-up to 518da2f after the independent review round (references
SOFTNETWORK-APP/softclient4es-arrow#144; the arrow PR carries the closing reference).

- `From.validate()` rejects one alias used for more than one source: `FROM orders o JOIN
  customers o` and `FROM t JOIN t ON t.id = t.parent` both parsed — every `o.x` resolved
  against the LAST source and the arrow planner registered two legs as the same `sq_o`
  (DuckDB: "Attempting to execute an unsuccessful or closed pending query result"). The exempt
  shape stays `FROM t, t` under ONE alias (a multi-index search, nothing resolves differently).
- The comma-FROM remedy text no longer invents an alias token for an alias-less occurrence.
- The AD-6 scaladoc names `aliasesToTable` as the resolver's map (it used to say a reverse scan).
- Pins: the watcher-flip case asserts #191's exact message, not merely a rejection; the UNNEST
  whose nested field is named like its table (`FROM orders o JOIN UNNEST(o.orders) AS i`) now
  resolves `o.id` where the lossy `.swap` left it a literal field name — two aliases share a key
  there without a self-join; one-alias-for-two-sources rejections.
- `documentation/sql/joins.md`: the self-join section states the four rules that ship (qualify
  every column; alias a column selected from both legs; one alias per leg; JOIN not comma) and
  the alias-in-ORDER-BY note describes the behaviour the planner and the connection now have.

Release notes (0.23.0), in addition to 518da2f: one alias for two sources is rejected; an UNNEST
named like its table resolves the table qualifier.

Tests: sql 1056 (+2), core 979, `+ sql/compile`, `++ 2.12.20 sql/Test/compile`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…se-insensitively

Round-3 follow-up (delta review of 3d7518d); references SOFTNETWORK-APP/softclient4es-arrow#144,
the arrow branch carries the closing reference.

- NEW-4: `From.validate()`'s one-alias-for-two-sources check treated an alias-less table's bare
  name as its alias and rejected 21.2's preserve-don't-interpret multi-index searches
  (`FROM "prod_us".orders, "prod_eu".orders`, `FROM "a".orders, orders`) with an alias nobody
  wrote. Only EXPLICIT aliases are compared now; `FROM t, t` stays accepted and the alias-less
  `FROM t JOIN t` is left to the join planner's own named guard. `FROM t a, t a` and
  `FROM orders a JOIN orders a` stay rejected.
- NEW-3: the comparison is case-insensitive — DuckDB's catalog folds `sq_A` and `sq_a`, so
  `FROM orders A JOIN orders a` passed both guards and died with the forbidden "closed pending
  query result" string.
- `documentation/sql/joins.md`: the alias rule states the case-insensitivity; the alias-in-ORDER-BY
  note says a column used only in a leg's own pushed-down WHERE is accepted even when the mapping
  does not have it (the arrow executor backstop's WHERE-only exemption).

Tests: sql 1058 (+2), core 979, `+ sql/compile`, `++ 2.12.20 sql/Test/compile`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lumn>

Lead ruling NEW-1b (2026-09-11): over two different indices sharing a column name the REPL
returns every row and names only the colliding columns `<alias>.<column>` (`o.id`, `c.id`),
leaving the others bare; JDBC / ADBC / Flight SQL keep Arrow's duplicate labels. Identical
wording in softclient4es-web joins.mdx. References SOFTNETWORK-APP/softclient4es-arrow#144;
the arrow branch carries the closing reference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… columns

Round-5 follow-up (review MEDIUM-4): the self-join paragraph states that `SELECT *` over a JOIN
omits object and nested columns and their sub-fields — list them explicitly to select sub-fields.
Identical wording in softclient4es-web joins.mdx. References SOFTNETWORK-APP/softclient4es-arrow#144;
the arrow branch carries the closing reference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ws ANSI three-valued logic

AD-9, lead ruling option (a). References SOFTNETWORK-APP/softclient4es-arrow#144; the arrow branch
carries the closing reference.

MEASURED on live Elasticsearch 8.18.3 before the change: `WHERE UPPER(status) <> 'ZZZ'` emitted
`(param1 == null) ? null : param1.toUpperCase().compareTo("ZZZ") != 0`, whose ternary branches are
`null` (Object) and a primitive `boolean`. Elasticsearch rejected the WHOLE query at compile time —
`script_exception: compile error`, caused by `class_cast_exception: Cannot cast from [boolean] to
[java.lang.Object]` — and the failure is data-independent: it fires on an EMPTY index. The numeric
family failed identically (`Cannot cast from [java.lang.Double] to [int]`); the date family escaped
only because `YEAR(...)` folds into the parameter assignment and took the already-correct guarded
branch. So a leg-local function predicate had never executed.

Two further defects sat on the same path, both silent:
- a composed predicate was NOT parenthesised, and `?:` is the loosest operator in Painless, so
  `a == null ? false : (x) && b == null ? false : (y)` parsed as
  `a == null ? false : (((x) && b == null) ? false : (y))` — the sibling became part of the first
  guard's condition (the rule `bucketPipelinePainless`'s own scaladoc states for HAVING, never
  applied to WHERE);
- `check` chose its hard-coded spelling (`compareTo(...) == 0`, `isEqual(...)`) from the RAW
  operator while only the generic fallback consulted `painlessOp`, so a `NOT` was dropped.

The shape shipped, in a QUERY context only: a left-hand side that reads a document field
(`nullable || dependencies.nonEmpty`) is bound to a local declared in the script PROLOGUE — a
Painless `def x = …;` is a statement and an operand of a composed predicate must be an expression —
and the comparison lands INSIDE the guard, `(<v> == null ? false : (<check>))`; every Predicate
operand is parenthesised; `check` dispatches on the effective, NOT-folded operator. `script_fields`,
sorts, aggregations and ingest processors keep `null` (21.8 Part C): their expectations are
byte-identical. ANSI three-valued logic holds because `NOT` is folded into the operator before the
guard and `NOT (<composite>)` is not expressible in this dialect (measured) — the ruling's
`Boolean`-boxing sketch was therefore unnecessary and is recorded as the rejected alternative.

Fixtures: three filter expectations in BOTH bridge copies (template + hand-maintained es6), each
justified old-bytes/new-bytes/why in the PR body, plus two re-spacing rules for the
whitespace-stripped expectation chains. No script_fields expectation moved.

Behaviour pins (row sets, never bytes): `GatewayApiIntegrationSpec` gains 11 assertions on real ES
with id3's field ABSENT — seeded via COPY INTO because an INSERT omitting a column writes an empty
string — including the two un-scripted shapes that show the other route (`NOT status = 'A'` is ES
`must_not`, which INCLUDES a document lacking the field: pre-existing, unchanged, now visible).

Release notes (0.23.0): a `WHERE` predicate applying a function to a column now runs (it previously
failed the whole query with the compile error above) and follows ANSI three-valued logic for absent
fields; a `NOT` in a scripted predicate is no longer dropped. Documented in
`documentation/sql/dql_statements.md` (its MDX twin needs the same paragraph).

Tests: sql 1058, core 979, `+ sql/compile`, `++ 2.12.20 sql/Test/compile`; bridge suites 197/197 on
es6, es7, es8, es9; es8 client integration 412 (+ the new pin, 75/75 on JavaClientGatewayApiSpec).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…very WHERE comparison

Round 9 of the independent AD-9 audit. Every item was measured on live Elasticsearch
8.18.3 over four documents (status A / status B / status ABSENT / status C) BEFORE
the fix and again after, and every fix carries a gate that fails without it.

B-2, M-5 — a NOT written after AND / OR used to give DIFFERENT ROWS depending on
writing order. `WhereParser.predicate` is `criteria ~ (and|or) ~ not.? ~ criteria`,
so the NOT lands on the PREDICATE, and the predicate emitted it two ways: `asFilter`
wrapped the un-negated right criterion in a `must_not` (which MATCHES a document
lacking the field) while `painless` rendered `!(left && right)` (which negates the
whole composite). Measured: `NOT UPPER(status)='A' AND amount>10` -> [2] versus
`amount>10 AND NOT UPPER(status)='A'` -> [2,3]. `Criteria.negated` now folds the NOT
into the criterion it qualifies, so both paths agree and `check` folds it into the
operator. Same fold inside a CASE (audit case C2): the absent row went to THEN where
ANSI takes ELSE.

M-6 — `ComparisonOperator.not` covered six of thirteen operators, so
`UPPER(status) NOT LIKE 'A%'` died with a bare `scala.MatchError: LIKE` escaping the
query builder. It is now `maybeNegated: Option[ComparisonOperator]`, exhaustive over
the sealed set (so a new operator cannot be added without answering), and the three
callers that cannot represent a missing negation decline instead of crashing:
`painlessNot` renders the `!` INSIDE the null guard, the geo shortcut falls through,
and the date-math range falls back to a script.

L-10 — `LIKE` over a function emitted `left1 .matches "A%"`, which is not Painless.
Two repaired spellings were measured and REFUTED before the shipped one:
`String.matches(String)` is not whitelisted, and neither is `Pattern.compile` (which
also condemns REGEXP_LIKE's emission — pre-existing, untouched, reported). A `%`-only
pattern now decomposes into whitelisted String methods; only a pattern that needs a
real regex uses a `/…/` literal. That is also the only form that works on ES 6.8,
where script regexes are disabled by default.

L-8 — `IN` and `BETWEEN` over a function fell through to `termsQuery`/`rangeQuery`
keyed on `identifier.name`, which is the EMPTY STRING for a function wrapper:
`{"terms":{"":[…]}}` rejected as `[bool] failed to parse field [filter]`. The
"this operand needs a script" test is now one named predicate used by all three
conversions, and both criteria render through the SHARED guarded path by overriding
`check` rather than `painless` — which also repaired `[…].contains` being applied to
the element instead of the list, a chained `a <= x <= b` Painless rejects, and a
`'A'` bound rendered in SQL quotes inside a script.

H-3 — the audit asked for a context discriminator; measurement says the discriminator
is already structural, and the story is better without one. An `Expression` IS a
boolean: all four consumers of a criteria rendering (script query, predicate
composition, CASE condition, HAVING bucket_selector — enumerated in
`PainlessNullSurvivalSpec`) are boolean positions, so the `false` collapse is right in
every one. A projected value goes through `Identifier.painless`, a different renderer,
which still emits `? null :` — pinned for script_fields, script sort and terms script.

L-11 — the statement form `def left = …; <expr>` is legal only with no context;
spliced into a predicate or a CASE it is a compile-time 400. The two latent sites now
hoist through `bindLocal` when there is a context, the invariant is named at each
site, and `PainlessOperandFormSpec` gates it: inlining `bindLocal` reddens it on 14
shapes.

Also: `atomic` now asks the honest question (is there an operator at paren depth 0),
which keeps a literal and a method chain out of a needless local — two pins measure
both sides of that line.

Green: sql 1061, core 979, bridge 202 x 4 majors (es6/es7/es8/es9), `+ sql/compile`,
`++ 2.12.20` for sql and core test sources, scalafmt, headerCheck. On REAL
Elasticsearch the extended `GatewayApiIntegrationSpec` ANSI test passes on 6.8.23,
7.17.29, 8.18.3 and 9.0.3, and the full es8 GatewayApi suite is 75/75.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e thing

Round 10, the final audit. Each item was measured on live Elasticsearch 8.18.3 before
and after, and each fix carries a gate that fails without it.

BLOCKING — this story had turned a loud error into a silent wrong answer. `BetweenExpr`
had no `negated` override, so a predicate's NOT fell back to wrapping the un-negated
criterion in a `must_not`, whose semantics INCLUDE a document lacking the field: the
guard's `false` came back as a match. Measured over a document with `status` but no
`amount`, `WHERE status = 'A' AND NOT ABS(amount) BETWEEN 1 AND 10` returned it and the
mirror order returned nothing; at the previous head that statement was a loud HTTP 400.
It survived round 9 because the mirror-order pins there used only `UPPER(status) = 'A'`,
the one criteria class that already had the override — a pin that exercises the class
you just fixed proves nothing about its siblings. The new pins cover BETWEEN, IN and
IS NULL in both writing orders. Two negation tables existed, `Criteria.negated` and a
private copy in `MetricSelectorScript`; the private one had the BETWEEN arm right.
Collapsed into one.

`LIKE` meant three different things at once. `toRegex` translated `%` and `_` and
nothing else, so every other regex metacharacter kept its regex meaning wherever a
pattern ended up as a regex. Measured over `A.B` and `AXB1`: the native `regexp` query
matched both, the string-method path matched only `A.B`, and the Painless regex matched
only `AXB1`. In SQL only `%` and `_` are wildcards, so the escape belongs in the SHARED
translation — putting it in the Painless emitter alone would have produced a fourth
reading. USER-VISIBLE: `LIKE 'A.B%'` no longer matches `AXB1`.

An audit prescription REFUTED and not applied. It asked for `ElasticBridge`'s nested arm
to fold its NOT like the other three consumers. Tried, and `SQLQuerySpec`'s "predicate
with distinct nested" fixture caught the flip: `must_not[nested] + filter` became
`must[nested, nested(… == false)]`. Under a nested relation the criterion runs over a
CHILD document inside an existential, so NOT outside and NOT inside ask different
questions — "no reply is before that date", which a blog with no replies satisfies,
versus "some reply is not before it", which it does not. The fold is valid exactly where
the negated criterion is evaluated over the SAME document. What the audit was right
about is that nothing ENUMERATED these consumers: all four are now listed on
`Predicate.emittedRight`, each classified fold or `NOT-FOLD:`, and a source scan fails
if a fifth states neither. That scan strips comments first — measured: without it the
comment explaining the gate satisfied the gate.

Smaller: `LIKE ''` over a function emitted `left1 ==~ //`, and `//` opens a Painless
comment; the empty core now takes the equality path, matching the native form. A CASE
with no ELSE emitted a truncated ternary, and once completed with SQL's implicit NULL it
needed the result boxed — measured that it is the absence of a `return` that makes
`cond ? 1 : null` fatal, and that `(def)` compiles in both positions. The sort pin is
annotated with the live 500 it does NOT cover, so nobody reads it as "the sort path
works", and both doc twins now carry that limitation, the real LIKE fast-path rule, and
the release note.

Green: sql 1065, core 979, bridge 203 x es6/es7/es8/es9, `+ sql/compile`, `++ 2.12.20`
for sql, core and both bridge test trees, scalafmt, headerCheck; es8
JavaClientGatewayApiSpec 75/75 on real ES 8.18.3 with sixteen new round-10 pins. Core
re-published serialised and content-verified; arrow re-run against it (the shared
`toRegex` change reaches its pushdown): arrowJoin 228/228, `+ compile`,
JoinExtensionIntegrationSpec 38/38 on ES 8.18.3 and 7.17.29.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…class axis

Round 11, the last fix round. Each item measured on live Elasticsearch 8.18.3 before
and after.

The `ORDER BY` disclosure was describing a DATA-LOSS mode as a loud error, in four
places. Sorting by a function of a column some documents do not carry fails the shard
building the comparator — and only a SINGLE-shard index turns that into an error.
Measured on three shards with seven documents, one lacking the field: HTTP 200,
`_shards.failed: 1`, `hits.total: 5`, the failing shard's two documents simply absent.
Nothing surfaces it; `grep -rn "_shards" core/src/main` finds no consumer on the search
path. Corrected in both doc twins, the PR body and the pin's own comment, and named as
the #205 / #209 / #253 family. Making core raise on `_shards.failed` is a separate
issue, deliberately not started here.

A `CASE … END` with no `ELSE` is NULL-valued and reached comparisons unguarded. `= 1`
hid it, because Painless tolerates `null == 1`; `> 0` gave `Cannot invoke
"Object.getClass()" because "leftObject" is null` and a string `=` gave `cannot access
method/field [compareTo] from a null def reference`. The operand discriminator now has a
third disjunct — a nullable FUNCTION makes its operand nullable — which is what the
round-10 pin's chosen shape had hidden. It does not re-open the `functions.nonEmpty`
trap round 9 measured: a `CASE` with an `ELSE` reports `nullable = false`, so the
literal shapes stay unguarded and byte-identical.

`%%` means what `%` means, but the fast-path test stripped only one leading and one
trailing `%`, so `'%%A'` fell to a regex and the rule the code documents was false as
written — measured, `circuit_breaking_exception: Regular expression considered too many
characters` on 8.18 and `Regexes are disabled` on 6.8. Runs are collapsed first, which
makes the documented rule true and keeps more patterns off the regex path everywhere.

A NEW GATE ON THE AXIS THE BLOCKING DEFECT LIVED ON. Round 10's gate enumerated the
CONSUMERS of a predicate's NOT; BLOCKING-1 was a criteria CLASS with no `negated`, which
no consumer-side check could ever see. The class-axis gate has no allow-list — declare a
`maybeNot` field, override `negated` — and was shown failing by deleting one override.

⚠️ Two things stated rather than fixed, both measured. `MatchCriteria` and
`MultiMatchCriteria` declare no `maybeNot` at all, so `NOT match(x) AGAINST ('y')` still
takes the `must_not` route and matches a document lacking the field — the same deviation
the bare-column `NOT status = 'A'` route has. Fixing it needs a new field (arity, and a
`.sql` render that `MaterializedViewExtension` persists) plus a bridge arm emitting
`must_not(match) + exists(field)`: the lead's call, not this round's. It is pinned as it
behaves and named by the gate's scaladoc rather than left silent. And the consumer-axis
gate's limits are now written down: it is anchored on the `Predicate` TYPE rather than a
receiver name, so both measured evasions (`pr.not`, a destructured fourth field) are
caught, but classification stays per FILE — counting uses against classifications is
defeated because `Where.scala` legitimately says `negated` many times, and a line-window
version cried wolf on `Predicate(l, _, r, _, _)`. A text scan cannot type a receiver;
the class-axis gate is the proof, this one is the reminder.

Also: `(def)` now parenthesises its operand (it was casting a nested condition), three
stale line citations corrected, a literal escape sequence in a scaladoc fixed, and
`emittedRight` / `notConsumed` returned to `private[query]` — the consumer that needed
them wider was reverted in round 10.

Green: sql 1066, core 979, bridge 203 x es6/es7/es8/es9, `+ sql/compile`, `++ 2.12.20`
for sql, core and both bridge test trees, scalafmt, headerCheck; es8
JavaClientGatewayApiSpec 75/75 on real ES 8.18.3 with the round-11 pins. Core
re-published serialised and content-verified; arrow re-run against it because the shared
`toRegex` and the operand discriminator both reach its pushdown: arrowJoin 228/228,
JoinExtensionIntegrationSpec 38/38 on ES 8.18.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught this, not me (run 34683904367): the ANSI three-valued test asserted
`WHERE UPPER(status) LIKE 'A_'` on every major, and it fails on BOTH ES 6 clients with
`illegal_state_exception: Regexes are disabled. Set [script.painless.regex.enabled] to
[true]`. The product behaviour is right and already documented — a LIKE compiles to
whitelisted String methods only when the pattern has no `_` and uses `%` at the ends
alone, everything else becomes a regex, and 6.x ships that setting off. The defect was
the TEST: it certified a shape on a configuration no 6.8 user has.

⚠️ And a correction to this story's own record. Round 9 reported this suite green on
6.8.23 / 7.17.29 / 8.18.3 / 9.0.3, which was true OF ROUND 9's CONTENT — every LIKE
shape then took the string-method path. Round 10 ADDED the `_` shape and re-ran only
8.18.3, so the 6.8 claim was carried forward stale rather than re-derived. Re-derive
after the content changes; CI is the authority.

The fix is the capability idiom this suite already uses for enrich policies:
`ElasticsearchVersion.supportsPainlessRegex` (false below 7.0), a `supportsPainlessRegex`
accessor beside `supportsEnrichPolicies`, and the test SPLIT — every shape that takes the
string-method fast path stays asserted on all majors, and only the regex-requiring ones
move to a capability-gated test that cancels with its reason visible in the log.

NOT done, deliberately: enabling `script.painless.regex.enabled` in the ES 6 fixture.
That would make the suite certify a cluster nobody runs — the "gate satisfied by the
shape you chose" failure this story has already repeated twice.

The version boundary is also pinned in `ElasticsearchVersionSpec`, because a live run
only ever covers the majors someone remembered to run.

Verified on the failing surface only: es6rest and es6jest — the previously red test
passes, the gated one cancels with its reason; es8java — BOTH pass, 0 cancelled, so the
gate does not skip where the capability holds. `core/testOnly *ElasticsearchVersionSpec`
14/14. The other `==~`-emitting specs (PainlessNullSurvivalSpec, PainlessOperandFormSpec,
LikePatternSpec) assert on the EMISSION and never reach Elasticsearch, so they have no
exposure — checked, not assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fupelaqu
fupelaqu marked this pull request as ready for review September 12, 2026 11:38
@fupelaqu
fupelaqu merged commit c3473ed into main Sep 12, 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.

1 participant