BIDC-8: self-join — keep both aliases of one index (lossless From.aliasesToTable), reject one alias for two sources - #322
Merged
Merged
Conversation
…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
marked this pull request as ready for review
September 12, 2026 11:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Story BIDC-8 — self-join:
From.tableAliaseskeyed 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's0.23.0-SNAPSHOTis 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 after0.23.0publishes.What was wrong (AC 1 — reproduced, not assumed)
From.tableAliasesisListMap[tableKey -> alias], so it can hold ONE alias per table by construction. ForFROM idx a JOIN idx bboth legs have the identical qualified reference, 21.2'saliasKeyreturns the bareidxfor both, and the map keepsidx -> b.aliasesToTablewas.swapof it, so aliasawas gone from BOTH maps. Probe at the baselineb37ef940(_bmad-output/implementation-artifacts/BIDC-8-reproduction-core.txt):JoinKey.applyneedsIdentifier.table, so the ON clause carried no key, the arrowJoinSpec.conditionwas empty and DuckDB receivedFROM "sq_a" INNER JOIN "sq_b"with noON— the issue's "Parser Error: syntax error at end of input" (end-to-end capture on real ES 8.18 inBIDC-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.aliasesToTableis built directly and LOSSLESS (alias -> table key, samealiasKeyon the table side). It equals the old.swapwherever no two aliases share a key and keeps BOTH aliases of a self-join.tableAliaseskeeps its documented one-alias-per-table contract untouched — softclient4es-extensions reads it FORWARD (JoinDependencyGraph:520) and itsFieldAnalyzerreverse-scans it; neither sees a byte of difference.aliasesToTable:Identifier.update(sql/package.scala) andFieldSort.update(SQL parser: ORDER BY on a bare table alias produces an empty column name (dangling qualifier) #159'sbareTableAlias).Identifier.tablestays atableAliasesKEY, soschemas,joinSourceKeysandTemporalLiteralskeep their key language.TemporalLiterals: the cross-index guard's set isjoinSourceKeys - mainTableKey— on a self-join the join source IS the main index, whose mapping IS in hand (newFrom.mainTableKey).FROM t a, t bis rejected inFrom.validate()naming the JOIN spelling. Baseline behaviour wasa.xunresolved +b.yresolved (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'sFROM "a".orders o, "b".orders punchanged.Found by the review, fixed here (AD-7)
CreateTableandCreateMaterializedViewhad novalidate()override — both inherited the no-op default, so everySingleSearch.validate()rule was skipped insideCREATE TABLE … AS SELECTandCREATE MATERIALIZED VIEW … AS SELECT(measured:CREATE TABLE t AS SELECT a.x, b.y FROM t a, t bwas accepted while the bare SELECT was rejected;INSERT … SELECTalready delegated). Both now delegate to the embedded query'svalidate(); the column form of CREATE TABLE is unchanged. Blast radius: 0 over thesqlcorpus (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
From.validate(), case-insensitively (FROM orders o JOIN customers o,FROM orders A JOIN orders a,FROM t a, t a): these parsed, everyo.xresolved against the LAST source and the arrow planner registered two legs as onesq_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, orderskeep their 21.2 multi-index-search acceptance, and the alias-lessFROM t JOIN tis left to the join planner's own named rejection. CLAUDE.md's "licensing infrastructure #73" for this is stale —gh issue view 73is a merged PR — so nothing is closed.FROM orders o JOIN UNNEST(o.orders) AS i):o.idused to stay a literal field name and now resolves — pinned, release-noted.Deferred, pre-existing (recorded in the findings log, not changed)
searchInput(watcher) never runsFrom.validate():CREATE WATCHER … FROM orders o, orders p WHERE x = 1(UNQUALIFIED) is still accepted and flattened toorders— 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..update()on an already-updated statement collapsesfieldAliasacross same-named legs;FROM "a".orders o, orders paccepted while sources =[orders, orders](21.2 preserve-don't-interpret residual);JoinKey.keycollides on a self-join (no consumer).AD-9 — the Painless FILTER emission (lead ruling, option (a)): a
WHEREwith a function now runs at allMeasured 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 arenull(Object) and a primitiveboolean— Elasticsearch rejected the whole query at COMPILE time(
script_exception: compile error, caused byclass_cast_exception: Cannot cast from [boolean] to [java.lang.Object]), data-independent: it failed against an EMPTY index. The numeric familyfailed identically (
Cannot cast from [java.lang.Double] to [int]); the date family escaped onlybecause
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
checkchose its hard-codedspelling from the raw operator, so a
NOTwas 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>)); everyPredicate operand is parenthesised;
checkdispatches on the effective (NOT-folded) operator.script_fields, sorts, aggregations and ingest processors keepnull(21.8 Part C) — thescript_fieldsexpectations inSQLQuerySpecare byte-identical, which is the evidence the changestayed inside the filter context. ANSI three-valued logic is preserved because
NOTis folded intothe 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 alternativein the story spec.
Row-set pins (new,
GatewayApiIntegrationSpec, real ES, id1status='A'/ id2'B'/ id3 fieldABSENT, 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→[]. Twoun-scripted shapes are pinned beside them to make the two routes visible:
status = 'A' OR id = 1→[1](term queries) andNOT status = 'A'→[2,3]— ESmust_notINCLUDES a document lacking thefield, a pre-existing divergence from ANSI this story does not change.
Fixture pins changed — three, each justified:
SQLQuerySpec, both bridge copies)(param1 == null) ? null : (param1 * (param2 - 10)) > 10000def left1 = (param1 == null) ? null : (param1 * (param2 - 10)); (left1 == null ? false : ((left1 > 10000)))(param1 == null) ? null : Double.valueOf(Math.sqrt(param1)) > 100.0def left1 = …Math.sqrt(param1); (left1 == null ? false : ((left1 > 100.0)))(param1 == null) ? null : param1.trim().length() > 10def left1 = …param1.trim().length(); (left1 == null ? false : ((left1 > 10)))Two re-spacing rules (
defleft→def left,false:→false :) were added to thewhitespace-stripped expectation chains; they normalise the EXPECTED text only and weaken no
assertion. No
script_fieldsexpectation 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)
QuotedTableNameSpec"leave two IDENTICALLY qualified same-name tables collapsing, as before (AD-6 scope)"tableAliases == ListMap(orders -> p)for the comma shapetableAliasesstillorders -> p,aliasesToTablekeepsoANDp(JOIN spelling); the comma spelling is now REJECTEDQuotedTableNameSpecwatcherFROM orders o, orders p WHERE o.x = 1"still defeats it, exactly as before" — acceptedParserSpec"reject a self-correlation through duplicate table names"p.parent_idresolvedParser.scalaqualifiedOverManyIndicesscaladoc#159's two canary tests stay green untouched; the canary now also covers
ORDER BY a/ORDER BY bon a self-join (SelfJoinAliasSpec).Evidence
sql/test1058/1058 (baseline 1044 + 12 new:SelfJoinAliasSpec×11,TemporalLiteralsSpec+1) ·core/test979/979 ·+ sql/compile·+ core/compile·++ 2.12.20 sql/Test/compile.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)
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).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 inputFROM orders o, orders p WHERE o.x = 1— all used to be accepted with a silently wrong reading.o.idinFROM orders o JOIN UNNEST(o.orders) AS iwas a literal field name before).CREATE TABLE … AS SELECT/CREATE MATERIALIZED VIEW … AS SELECTwhose SELECT violates a SELECT-level rule (e.g. OFFSET is silently dropped under GROUP BY #295OFFSETunderGROUP BY, an inline HAVING aggregate,ORDER BY <bare table alias>) — the embedded query was never validated before (AD-7).From/SingleSearchgain lazy vals only;CreateTable/CreateMaterializedViewgain 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
falsecollapse could not reach a projection because aPainlessContextis only supplied on the filter path. ACASEover 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 itnever 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.⚠️ That is a deliberate deviation from the audit's prescription of a
PainlessNullSurvivalSpecnames everyconsumer 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 collapseis right in all of them.
context discriminator: measurement says such a flag would have exactly one legal value, because an
ExpressionIS 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, fieldABSENT,
Awith a second field absent,C):HTTP400 … cannot resolve symbol [left1]1/0/0/1/0, andWHERE (CASE …) = 1→[1, 4]NOTafterAND/ORNOT UPPER(status)='A' AND amount>10→[2]but the mirror order →[2,3][2, 5]NOTinside a CASE0/0/0/0/1— ANSInull_pointer_exceptionon the shard[1, 2, 5]Cannot cast from [int] to [java.lang.Object][1, 4]NOT LIKEover a functionscala.MatchError: LIKEescaping the query builder[2, 5]IN/BETWEENover a function{"terms":{"":…}}rejected as[bool] failed to parse field [filter][1, 2, 4]/ all fiveLIKEover a functioninvalid sequence of tokens near ['"A%"'][1, 4]Two Painless claims measured and refuted before the shipped
LIKEspelling: neitherString.matches(String)norPattern.compileis whitelisted (dynamic method … matches/1 not found,static method … compile/1 not found) — a regex may only enter through a/…/literal. Soa
%-only pattern decomposes into whitelistedStringmethods and only a pattern that truly needsa 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_LIKEemitsPattern.compile(...), which by the refutation above cannot run in any script context; andORDER BY UPPER(status)over a column some documents do not carry LOSES ROWS SILENTLY — a SORTscript, whose emission this story leaves correct (
? null :, pinned). On a single-shard index thesearch 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.failuresconsumer.New gates, each shown failing.
PainlessOperandFormSpecfails on 14 shapes whenPainlessContext.bindLocalstops hoisting into the prologue (a statement sequence spliced into apredicate or a CASE is a compile-time 400).
PainlessNullSurvivalSpec's declared-vs-referencedcheck 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.20forsql and core test sources, scalafmt, headerCheck. On real Elasticsearch the extended
GatewayApiIntegrationSpecANSI test is green on 6.8.23, 7.17.29, 8.18.3 and 9.0.3, and thefull es8
JavaClientGatewayApiSpecis 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 —
NOTover a function-wrappedBETWEENturned a loud 400 into a silent wronganswer.
BetweenExprhad nonegatedoverride, so the predicate'sNOTfell back to wrappingthe un-negated criterion in a
must_not, whose semantics INCLUDE a document lacking the field —inverting the guard's
falseinto a match.WHERE status = 'A' AND NOT ABS(amount) BETWEEN 1 AND 10returned the row with no
amount; the mirror order returned nothing. At the previous head thatstatement was a loud
HTTP 400, so this PR had made it worse. Fixed, and the new pins coverBETWEEN,INandIS NULLmirror-orders — the round-9 pair used onlyUPPER(status) = 'A', theone criteria class that already had the override, which is exactly why the hole survived.
🔴
LIKEmeant three different things at once.toRegextranslated%and_and nothingelse, so every other regex metacharacter kept its regex meaning on the paths that end in a regex.
Measured:
status LIKE 'A.B%'(nativeregexp) matchedA.BANDAXB1;UPPER(status) LIKE 'A.B%'(string methods) matched onlyA.B;UPPER(status) LIKE 'A.B%1'(Painless regex) matchedonly
AXB1. Fixed in the SHARED translation — fixing it only in the Painless emitter would haveproduced 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%andstill 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 emittedleft1 ==~ //, and//opens a Painless comment(
unexpected character [//))))]) — the empty core now takes the equality path.CASE … THEN x ENDwith no
ELSEemitted a truncated ternary (param2 ? 1), then, once completed, aCannot cast from [int] to [java.lang.Object]— SQL's implicit NULL is now emitted with the resultboxed, 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.not—ElasticBridge's nested arm — to be aligned with the otherthree by folding. It was tried:
SQLQuerySpec's "predicate with distinct nested" fixture turnedmust_not[nested] + filterintomust[nested, nested(… == false)]. Under a nested relation thecriterion runs over a CHILD document inside an existential, so
NOToutside andNOTinside aredifferent 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 wasright 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
LIKEreads only%and_as wildcards. Every other character in a pattern is now matchedliterally, on the scripted and the native path.
WHERE status LIKE 'A.B%'previously matchedAXB1; it now matches only values that really begin withA.B. Patterns relying on the oldreading must be rewritten with
_or%.RLIKEis unaffected — its operand is a regularexpression by definition.
CASEover a function insideSCRIPT ASemits a different script. The condition nowcollapses an absent field to
falsewhere it previously renderednull, so an ingest pipelinestored before this version differs from what the engine generates now. Re-run
CREATE TABLE/ALTER … SET SCRIPT ASfor tables built earlier if the pipeline must match.(Same class as the 21.8 Part C note.)
ORDER BYover a function of a sparsely populated column LOSES ROWS SILENTLY on a multi-shardindex — disclosed, not introduced here. The emitted sort script preserves
nullcorrectly;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 bothdoc twins; surfacing shard failures is recorded as a separate issue.
Recorded, not fixed
REGEXP_LIKEemitsjava.util.regex.Pattern.compile(...), which is not whitelisted in any scriptcontext — measured in round 9, untouched here, no test exercises it.
LIKE:UPPER(x)/LOWER(x)over a plain column is really acase-insensitive match, and ES ≥ 7.10 supports
case_insensitive: trueonregexp/wildcardnatively. 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. Recordedat the decomposition site; no issue filed.
🤖 Generated with Claude Code