fix(sql,bridge): name un-aliased aggregates after the whole expression; bucket pipelines read params.<metric> null-guarded (#54, #223) - #288
Merged
Conversation
…n; bucket pipelines read params.<metric> null-guarded An un-aliased aggregate's metricName was the bare field name, so two aggregates over one field in HAVING/ORDER BY collapsed onto one aggregation (HAVING COUNT(x) > 5 AND MAX(x) > 3 compared the count twice, ORDER BY MIN(x) beside HAVING MAX(x) lost its order key), a dotted field leaked into buckets_path keys (a nested-level HAVING silently emitted no having_filter), and an aggregate over a self-contained function (MAX(ABS(salary))) was named "". Identifier.metricName now derives the name from the whole expression (count_x, max_abs_salary, count_distinct_x; COUNT(*) keeps count_all). The bucket_selector script read doc[...] and re-applied the transform the metric aggregation had already applied (HAVING MAX(YEAR(x)) > 2020). A context-free rendering of an aggregate is now the bucket-pipeline form: params.<metric> only, the transform once in the metric script, and every compared metric null-guarded as one parenthesised expression (lead directive, AC 4b) so it composes under AND/OR; the temporal literal is converted to epoch millis inside the guard. Both bridge copies resolve buckets_path through one resolver keyed on the aggregation's carried local name. Found by the live run and fixed here too: HAVING <alias> of a SELECT aggregate returned every group (Having.resolveAggregateAliases); arithmetic over aggregates (MAX(x) - MIN(x) AS d) never created its operands and core rejected the bucket_script before Elasticsearch (auxiliaryAggs operands, AggregationType.BucketScript); auxiliaryAggs dedup is now order-preserving. Ten pinned having_filter fixtures per bridge copy move to the guarded form; AggregationNamingSpec pins the emitted JSON and adds two structural guards (no empty aggregation/order key; no unguarded possibly-null dereference or comparison in any emitted Painless). Seven live tests with exact row oracles green on ES 6.8 (rest + jest), 7.17, 8.18, 9.0. Story BIDC-2 Closes #54 Closes #223 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…NOT on the right operand, guarded BETWEEN/IN, loud rejects (R2-1..R2-9) R2-1: HAVING <alias> of a bucket_script (MAX(x) - MIN(x) AS d ... HAVING d > 3) was silently ignored and the first commit made the statement executable; the alias now resolves to the SELECT item, the selector reads params.d from the sibling pipeline aggregation (buckets_path d -> d), and inline arithmetic over aggregates in HAVING is a validation error naming the remedy. R2-2: MetricSelectorScript negated the LEFT operand of A AND NOT B; the NOT is pushed into the right-hand expression so a missing metric still fails the negated test. R2-3: BETWEEN and IN over an aggregate bypassed the guarded bucket rendering (BETWEEN even emitted a chained comparison Painless rejects); both render through bucketPipelineCheck with their own negation. R2-4: the AC 4b guard asserts a bucket_selector is present for every HAVING shape. R2-5: IS [NOT] NULL renders the null test alone. R2-6: extractMetricNames drops __now__ (both bridge copies) so a nested-level HAVING with now - interval keeps its condition. R2-9: auxiliary aggregates dedup against SELECT by expression; an alias naming a SELECT aggregate and a different HAVING/ORDER BY aggregate is a validation error. Lead to confirm: an aggregate function in WHERE is now rejected (it used to be silently dropped while its aggregation was still created) — the house-style default, reversible. Docs: HAVING alias references and the rejected shapes. Story BIDC-2 Closes #54 Closes #223 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Painless == (R2-3, live) The ES 8.18 leg found that the bucket form [v1,v2].contains(params.<metric>) never matched: a buckets_path value arrives as a boxed Double while the literals are Integers, and List.contains uses Java equals. The membership is now rendered as a disjunction of Painless == comparisons, which promote numerics and use equals for strings; the two IN pins in both bridge copies follow. Story BIDC-2 Closes #54 Closes #223 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ELETE and UPDATE included (S2-2) The reject lived only in SingleSearch.validate(), so DELETE FROM t WHERE COUNT(x) > 5 still became match_all and wiped the index, and the same UPDATE touched every document (#280 data-loss family). The arm now lives in Where.validate(), and Update / Delete gain a validate() that validates their WHERE; Parser.apply already validates every parsed statement, so the parse path covers DML. Pinned in HavingAggregateResolutionSpec (DELETE, UPDATE, AND-combined; plain forms stay accepted) and live (both rejected, count and ages unchanged). Also: negated()'s compound fallback documented as grammar-unreachable. Story BIDC-2 Closes #54 Closes #223 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…checks the element, not the array (third look)
Third-look tidy-up on top of the BIDC-2 review follow-ups (verdict: approve, no
HIGH/MEDIUM residual).
- T3-1 `CreateEnrichPolicy.validate()` never validated its source WHERE, so an
aggregate there was dropped and the policy enriched from EVERY source document
(`match_all`). It now uses the same `where.map(_.validate())` idiom as
`Update`/`Delete`, which brings it under the S2-2 aggregate reject.
- T3-3 `HAVING COUNT(x) IN (1, 2)` was rejected by the shared
`Expression.validate` comparing the element with the list's ARRAY type
(`BIGINT` vs `ARRAY<BIGINT>`), while the untyped `MAX(x) IN (...)` passed
through `Any`. `InExpr` overrides `validate()` and compares against the list's
ELEMENT type; a genuine mismatch (`COUNT(x) IN ('a','b')`) stays loud and now
names the element types.
- Lead ruling 2026-09-06: the loud reject of an aggregate in WHERE (SELECT,
DELETE, UPDATE, and now CREATE ENRICH POLICY's source WHERE) is CONFIRMED as
shipped; the "lead to confirm" markers become "lead-confirmed 2026-09-06" and
the behaviour change is a 0.23.0 release note.
Tests: sql `*ParserTotalitySpec *HavingAggregateResolutionSpec` 42/42 (two new
pins, each asserting the rejection is the grammar's and not the boundary catch);
`softclient4es-sql-bridge` and `es6bridge` `*AggregationNamingSpec` 28/28 each;
`++ 2.12.20 sql/Test/compile` and `scalafmtAll` clean.
Story BIDC-2
Closes #54
Closes #223
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…newly accepted by T3-3 T3-3 made `COUNT(x) IN (…)` pass validation, so a shape that used to be rejected now reaches the bucket-pipeline emission -- and every existing IN pin was a `MAX(x)` one, with `COUNT(x) IN` covered only at parse level, which cannot see a wrong script. - `AggregationNamingSpec` (template and the hand-maintained es6 copy, kept byte-identical): two emitted-JSON pins for `HAVING COUNT(x) IN (1, 2)` and its NOT twin -- the guarded `==` chain over `params.count_x` and the declared `buckets_path` key `count_x -> count_x`, the same assertions the MAX pins make. Both shapes added to the structural guards' list (35 -> 37 shapes). - `GatewayApiIntegrationSpec`: one live case beside `MAX(age) IN (40, 50)` -- `HAVING COUNT(name) IN (2, 3)` over the `having_naming` table (Paris 2, Lyon 1, Marseille 1, Nice 1) expects exactly Paris, so an empty result (a `value_count` long compared with Integer literals -- the S2-1 boxing class of defect) and an all-pass guard both fail loudly. Tests: sql `*HavingAggregateResolutionSpec` 7/7, `softclient4es-sql-bridge` and `es6bridge` `*AggregationNamingSpec` 30/30 each, `softclient4es-core-testkit` compile clean, `scalafmtAll` clean. Story BIDC-2 Closes #54 Closes #223 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fupelaqu
marked this pull request as ready for review
September 6, 2026 11:14
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.
Closes #54
Closes #223
Story BIDC-2 (BI defect-closure epic). Branch
feature/BIDC-2offorigin/maine55bd937. Version line0.23.0-SNAPSHOTverified, untouched. NopublishLocal, no GitHub issue operation.What
One naming rule, one rendering rule, one resolver.
Identifier.metricName(sql) — an un-aliased aggregate is now named after the WHOLE expression (AliasUtils.normalizeover the DISTINCT-aware chain):count_x,max_x,count_distinct_x,max_year_createdat,max_abs_salary,count_emails_address.COUNT(*)keepscount_all/count_distinct_all; an alias always wins. The auxiliary aggregation created for a HAVING / ORDER BY-only aggregate is named with it, sobuckets_pathresolves by construction.Identifier.painless(None)on an aggregate isparams.<metricName>(no transform — it lives once, in the metric aggregation's own script);Expression.painless(None)on an aggregate predicate is ONE parenthesised, null-guarded expression:(params.k == null ? false : (params.k > 2020)), both operands guarded when the right-hand side is an aggregate. The temporal-literal → epoch-millis conversion moved into it (it used to be appended to the whole predicate byMetricSelectorScript).resolveBucketMetric(bridge template + hand-maintainedes6/bridge) —metricSelectorForBucketandextractMetricsPathForBucketshare one resolver;buckets_pathvalues are the aggregation's carried local name (agg.name), never a string-split of the.-joinedaggName. No case-class arity change.MAX(x) - MIN(x) AS d) — documented for years, never worked (no operand aggregations, self-referencingbuckets_path {"d":"d"},params.x - params.x).auxiliaryAggsnow creates the operands (the Aggregations referenced only in HAVING or WHERE are not created #53 mechanism),BucketScriptAggregation.updateregisters only aggregate operands.auxiliaryAggsdedup is order-preserving (wasgroupBy→ hash order → unpinnable JSON).HAVING cnt > 1wherecntaliases a SELECT aggregate now filters (Having.resolveAggregateAliases, sql). Found by the live run: the bare alias carries no aggregate function, so the selector rendered1 == 1and the emitted query had nohaving_filter— every group came back. This is the "workaround" issue HAVING COUNT(field) fails when COUNT(field) has no alias #54 itself prescribes; it never worked. Scoped to HAVING (ORDER BY already resolves aliases by name; WHERE keeps reading a bare name as a field).sqlAggregationToClientAggregationgains theBucketScriptAggregationarm (AggregationType.BucketScript). Found by the live run: the bridge emitted a correctbucket_script, and core rejected it before the request left the JVM (Unsupported aggregation type: bucket_script).Why — what T2 captured at
e55bd937(bridge template, ES 8 elastic4s)#54 as filed does not reproduce — every un-aliased
COUNT(x)already emitted a consistent agg /buckets_path/paramsname (__c2in SELECT,xHAVING-only) because #53 aliases the auxiliary Field withmetricName. But that name was the bare field, which is the real defect behind the title:#223 reproduces exactly as filed:
After (same shapes): K1 →
count_x+max_x,(params.count_x == null ? false : (params.count_x > 5)) && (params.max_x == null ? false : (params.max_x > 3)); K2 →"order":{"min_x":"desc"}; N4 →count_e_address+ itshaving_filter; S3 → metricmax_year_createdat(script unchanged, transform once) +(params.max_year_createdat == null ? false : (params.max_year_createdat > 2020)); S3d → two guarded terms joined by||; S4 →"order":{"max_abs_salary":"desc"}; B1 →max_x,min_x+{"max_x":"max_x","min_x":"min_x"},params.max_x - params.min_x. Nested direct-child path unchanged:"nb":"e>nb"(N1, now pinned — it was not).Deviation from the spec's AC-1 wording
AC-1 expected
{"x": "count_x"}(keyx, aggcount_x). The key and the aggregation name are ONE name by construction (the auxiliary Field's alias); splitting them would mean un-aliasing auxiliary Fields — a larger change with no observable gain. Shipped:{"count_x": "count_x"}/params.count_x. The invariant AC-1 states — the path names the aggregation Elasticsearch actually emits — holds, and is now enforced structurally (AggregationNamingSpec: everyparams.<k>a pipeline script reads is a declaredbuckets_pathkey).Lead directive (2026-09-06) — AC 4b, null-safety
bucket_selectorscripts read only declaredparams.<k>, neverdoc[, and null-guard every compared metric (uniformly, COUNT included). Enforced byAggregationNamingSpec"a bucket pipeline script should read only declared, null-guarded params and never a document" over 23 shapes, in BOTH bridge copies.(… ? null : …)group or an unguarded nullabledef. Enforced by "no emitted Painless should dereference or compare a possibly-null value unguarded" (structural walk over every script the 23 shapes emit, both copies). The metric scripts already satisfied it (def param1 = (doc[..] ? null : <chain>); param1,(param1 == null) ? null : Double.valueOf(Math.abs(param1))).bucket_selectorkeeps Elasticsearch's default (skip); nothing explicit is emitted. Contract: a bucket whose compared metric is missing never passes a HAVING comparison — proven live in both directions (HAVING MAX(age) > 0andHAVING MAX(age) < 1000both drop theNicebucket whose only user has no age, no request error).Having.script(the extensions MV transform path) shares the producer, so it is guarded identically.Downstream fixture sweep (AC 7)
buckets_path/bucket_selector/having_filter/ElasticAggregation(/SQLAggregation(acrosssoftclient4es-jdbc,softclient4es-arrow,softclient4es-extensions:SchemaProbeSpec.scala:80,ElasticFlightProducer.scala:2097) — no pinned JSON.community/.../graph/Stage.scalabuilds the MV transform's ownhaving_filterfromHaving.script(:291) into aTransformBucketSelectorConfig(:295) with its ownextractAggregatePaths(:310, whose empty-name fallback:319already usesAliasUtils.normalize(identifier.identifierName)— the same spelling adopted here). No pinned JSON. Behaviour change reaching it:Having.scriptnow emits the guarded(params.k == null ? false : (params.k > n))form andparams.<metric>for transform-bearing aggregates (wasdoc[]).buckets_paththere is built from SELECT aggregates only, so a HAVING mixing a SELECT-aliased aggregate with a HAVING-only one ships a script reading an undeclaredparams.<k>— before, a runtime failure at the transform checkpoint; now the guard yieldsfalseand the view is silently empty. Extensions follow-up:buildBucketSelectorshould assert everyparams.<k>inhaving.scriptis abucketsPathkey (mirror core's structural test) and fail loudly.Nothing downstream pins the moved emissions; no
"size":65536-style sweep is needed for this PR.Review follow-up (second commit — independent review, approve-with-fixes: 0 HIGH / 3 MEDIUM / 8 LOW)
HAVING <alias>over abucket_script(MAX(x) - MIN(x) AS d … HAVING d > 3) was silently ignored, and this PR made the statement executable. RESOLVED rather than rejected: the alias map coversisBucketScriptSELECT items, the substituted identifier routes to the bucket rendering (params.d),Identifier.allMetricsPathpublishesd -> d, and the bridge resolver finds the siblingbucket_scriptby name (Elasticsearch lets abucket_selectorread a sibling pipeline aggregation). Inline arithmetic over aggregates in HAVING (HAVING MAX(x) - MIN(x) > 3, no alias) is now a validation error naming the remedy.MetricSelectorScriptnegated the LEFT operand ofA AND NOT B(pre-existing). The NOT is now pushed INTO the right-hand expression (> 3→<= 3inside its guard), so a bucket whose metric is missing still fails the negated test as SQL's three-valued NOT would; compound right sides fall back to!( … ).1 <= p <= 5Painless rejects). Both now render through an overridablebucketPipelineCheck—(p == null ? false : [!](p >= a && p <= b)),(p == null ? false : [!](p == v1 || p == v2))(an equality chain, NOT[v1,v2].contains(p)— see the second look, S2-1) — and their NOT forms are pinned. Note:COUNT(x) IN (…)is rejected by the pre-existing type validator (BIGINTvsARRAY<BIGINT>);MAX(x) IN (…)is accepted and guarded.bucket_selectoris PRESENT for every HAVING shape (no vacuous pass). R2-5IS [NOT] NULLover an aggregate renders the null test alone (no contradictory outer guard). R2-6extractMetricNamesdrops__now__in both bridge copies — a nested-level HAVING withnow - interval …keeps its condition (pinned). R2-9auxiliaryAggsdedups against SELECT by expression; an alias naming a SELECT aggregate and a DIFFERENT HAVING/ORDER BY aggregate is a validation error. R2-11 docs updated (alias reference works; what is rejected).WHEREis now REJECTED (Aggregate functions are not allowed in WHERE (found …); use HAVING) instead of silently dropped — the house-style default, ratified by the lead.Second look (third and fourth commits — independent second-look review of
a5f376f2)HAVING MAX(age) IN (40, 50)rendered[40,50].contains(params.max_age), and PainlessList.containscompares the boxed Double abuckets_pathvalue arrives as (40.0) with the Integer literals throughequals— it NEVER matched (the live run returned the zero-bucket NULL row). Rendered as a disjunction of==(numeric promotion;equalsfor strings):(p == null ? false : (p == 40 || p == 50)); NOT IN =!(…). Pinned inAggregationNamingSpecand live on all five majors (IN (40, 50)→ Lyon, Marseille). Commitb09aaf27.DELETE … WHERE id = 1 ANDempties the index #280 data-loss family): the WHERE-aggregate reject lived only inSingleSearch.validate(), soDELETE FROM t WHERE COUNT(x) > 5still parsed asDELETE FROM t(index wiped) and the sameUPDATEhit every document. The reject moved intoWhere.validate(), which fires for SELECT, DELETE and UPDATE (and, since the third look, CREATE ENRICH POLICY's source WHERE);Update/Deletegained avalidate()over their WHERE (Parser.applyvalidates every parsed statement, so the parse path covers DML). Pinned (DELETE, UPDATE,id = 1 AND MAX(x) > 5; plain DML staysRight; every rejection assertsnot startWith Parser.InternalParseFailure) and live (DELETE + UPDATE rejected, count and ages unchanged). Commitb5a86cf9.negated()'s compound fallback commented as grammar-unreachable;scalafmtCheckAlland the 2.12 test compiles re-run on the final tree (evidence table).Third look (fifth commit
a363f579— independent third-look review ofb5a86cf9: APPROVE, no HIGH/MEDIUM residual)CreateEnrichPolicy.validate()never validated its sourceWHERE— an aggregate there was dropped and the policy enriched from every source document (match_all). It now uses the samewhere.map(_.validate()).getOrElse(Right(()))idiom asUpdate/Delete, so the S2-2 reject reaches the enrich path too. Pinned (rejection + the plainWHERE status = 'active'form still accepted,not startWith Parser.InternalParseFailure).COUNT(x) IN (1, 2)was rejected by the sharedExpression.validatecomparing the element with the list's ARRAY type (BIGINTvsARRAY<BIGINT>) while the untypedMAX(x) IN (…)passed throughAny.InExprnow overridesvalidate()and compares against the list's element type (contained: one override, no other validator behaviour moved). Pins: COUNT / COUNT NOT IN / MAX / string IN / a typed document-levelYEAR(...) IN (…), plus the genuine mismatchCOUNT(x) IN ('a','b')still rejected and naming'BIGINT'/'VARCHAR'.COUNT(x) IN (…)newly ACCEPTED, so a previously rejected shape now reaches the bucket-pipeline emission, and every existingINpin was aMAX(x)one (COUNT(x) INhad a parse-level pin only, which cannot see a wrong script). Added: emitted-JSON pins forHAVING COUNT(x) IN (1, 2)and itsNOT INtwin in BOTH bridge copies (guarded==chain overparams.count_x,buckets_pathkeycount_x -> count_x), both shapes in the structural guards' list (35 → 37), and one live case besideMAX(age) IN (40, 50)—HAVING COUNT(name) IN (2, 3)expects exactly Paris, so an empty result (avalue_countlong vs Integer literals — the S2-1 boxing class) and an all-pass guard both fail loudly. Commit10f3f8fc.Release-note items (0.23.0)
MAX(YEAR(x)),MAX(ABS(x)),MAX(DATE_TRUNC(x, …))) now execute correctly; they used to fail at Elasticsearch (doc[]in a bucket_selector) or emit an aggregation named"".HAVING COUNT(x) > 5 AND MAX(x) > 3) now filter on both; they used to be collapsed onto one — a silent wrong answer.JOIN UNNEST … GROUP BY e.domain HAVING COUNT(e.address) > 1) is now applied; it used to be silently dropped.MAX(x) - MIN(x) AS d) now works as documented (bucket_script with real operands); it used to fail at Elasticsearch.bucket_selectorcomparison —<,<=,=,!=,>=,>,BETWEEN,IN, and theirNOTforms — is null-guarded: a group whose compared metric is missing never passes a HAVING test, negated or not, and the request does not fail.x→count_x,salary→max_salary); they never appear in result rows. Anyone pinning generated JSON downstream must updatehaving_filterscripts to the guarded form.HAVING <alias>where the alias names a SELECT aggregate (COUNT(name) AS cnt … HAVING cnt > 1) or an arithmetic expression over aggregates (… AS age_range … HAVING age_range > 3) now filters; both used to return every group.HAVING A AND NOT Bnow negatesB(it negatedA).WHERE(useHAVING), arithmetic over aggregates written inline inHAVING(alias it inSELECT), and an alias naming one aggregate inSELECTand a different one inHAVING/ORDER BY. Behaviour change (lead-confirmed 2026-09-06): a statement with an aggregate inWHEREused to parse and silently mis-execute — the predicate was dropped, so aSELECTreturned every group and aDELETE/UPDATEmatched every document (data loss) — and now fails with a named 400 suggestingHAVING. It coversSELECT,DELETE,UPDATEandCREATE ENRICH POLICY's sourceWHERE(whose aggregate widened the enrich source query tomatch_all).Parser.applyreturns the updated AST, so a statement writtenHAVING cnt > 1re-renders asHAVING COUNT(name) > 1. It re-parses to the same AST; the visible effects are the REPL's AST→SQL echo and a one-time "definition changed → replace" on the firstCREATE OR REPLACE MATERIALIZED VIEWof an existing view written withHAVING <alias>(extensions compares the rendered SQL to the stored definition).params.<k>); with the guarded script it now yields an empty view silently — an extensions follow-up should assert everyparams.<k>is declared inbuckets_path.<aggregate> IN (…)type-checks the aggregate against the list's element type:HAVING COUNT(x) IN (1, 2)used to be rejected asBIGINTvsARRAY<BIGINT>while the untypedMAX(x) IN (…)passed. A genuine mismatch (COUNT(x) IN ('a','b')) stays rejected and now names the element types.WHEREofCREATE ENRICH POLICYis rejected:CreateEnrichPolicy.validate()never validated its WHERE, so the aggregate was dropped and the policy enriched from EVERY source document (match_all).21.3 interlock — assertions 21.3's AC-10 must re-run after this lands
metricSelectorForBucketwas rewritten (shared resolver). 21.3's AC-10 pin (an aggregate-freeHAVINGdegenerates to termsinclude/excludeand emits nobucket_selector) still holds by construction —MetricSelectorScript.metricSelectorreturns1 == 1for non-aggregate expressions andmetricSelectorForBucketstrips it to""(unchanged lines) — but 21.3 must re-run its emitted-JSON assertion on top of this branch, and re-runAggregationNamingSpec(both copies) after its own change. The exact assertions to re-run are listed in.epic-BIDC-dev-findings.md§ BIDC-2.Docs
documentation/sql/dql_statements.md(GROUP BY and HAVING): three bullets — HAVING/ORDER BY-only aggregates need no alias and may wrap a transform; arithmetic over aggregates; the null-metric contract. MDX twin to sync by the web maintainer:softclient4es-web/src/content/docs/sql/dql.mdx(not edited here).Test evidence
Unit (plain
sbt, default JDK):sql/testHavingAggregateResolutionSpecadded)core/testsoftclient4es-sql-bridge/test(template, ES 8 elastic4s)AggregationNamingSpec30 tests (27 JSON pins + 3 structural guards over 37 shapes) and the 10 movedhaving_filterfixtureses6bridge/test(hand-maintained)softclient4es7-sql-bridge/test(generated)b5a86cf9157/157softclient4es9-sql-bridge/test(generated,sbt17)b5a86cf9157/157softclient4es-sql-bridge+es6bridge*AggregationNamingSpecon the final head10f3f8fcCOUNT(x) INemission pins added by T3-4);sql/testOnly *HavingAggregateResolutionSpec7/7;softclient4es-core-testkit/compileandscalafmtAllclean+ sql/compile,+ softclient4es-sql-bridge/compile,++ 2.12.20 {sql,bridge,es6bridge}/Test/compile,softclient4es-core-testkit/compile++ 2.12.20 …/Test/compileandscalafmtCheckAllre-run on the final headb5a86cf9, all exit 0 (S2-4)+ sql/test+ core/test+ softclient4es-sql-bridge/test+ es6bridge/test(both legs, AC-6)Integration (real Elasticsearch via Testcontainers,
sbt17, one major per invocation,GatewayApiIntegrationSpec= 50 existing + 11 new BIDC-2 tests with exact row oracles, 61 per major; final head10f3f8fc):4368d9dc(57 tests)b5a86cf9(61 tests)10f3f8fc(61 tests)es8javaJavaClientb09aaf27, where the ES 8 leg found S2-1)es7restREST high-leveles9javaJavaClientes6restREST high-levelsupportsEnrichPoliciesassume (:1875, enrich needs ES 7.5+)es6jestJestThe final head re-runs all five majors because T3-3 changed what the emission RECEIVES (
COUNT(x) INis newly accepted) — and the S2-1 defect of the same family was found by a live leg, not by a unit test. ES 8 capture of the newly reached shape:"buckets_path":{"count_name":"count_name"},"script":{"source":"(params.count_name == null ? false : (params.count_name == 2 || params.count_name == 3))"}⇒ exactly Paris, as the oracle demands.The 11 new live tests (7 from the first commit, 4 from the follow-ups): aliased / un-aliased / HAVING-only
COUNT(name)(same rows);COUNT(age) >= 1 AND MAX(age) > 45(Marseille only — the collision case);MAX(YEAR(birthdate)) > 1990andMAX(ABS(age)) > 45 OR COUNT(*) > 1(exact bucket sets, the no-age bucket excluded);ORDER BY MAX(ABS(age)) DESC(exact order, missing metric last); the AC 4b contract (MAX(age) > 0ANDMAX(age) < 1000both drop the no-age bucket, no error);MAX(age) - MIN(age) AS age_rangealone and besideMAX(age) AS oldest(exact doubles); thenHAVING age_range > 3through the bucket_script alias (Paris only, R2-1);BETWEEN 40 AND 49/NOT BETWEEN/IN (40, 50)/COUNT(*) >= 1 AND NOT MAX(age) > 45(exact bucket sets, R2-2/R2-3/S2-1); an aggregate inWHERErejected with theuse HAVINGmessage;DELETE/UPDATE … WHERE MAX(age) > 5rejected with the snapshotn=5 / oldest=50 / youngest=25unchanged afterwards (S2-2). The first ES 8 run failed two of the original seven and found the alias-in-HAVING drop and the corebucket_scriptrejection described above; the ES 8 run of the review follow-up found S2-1 — each fixed, then all five majors re-run green on the final head.Falsification: with the six sql sources reverted to
origin/mainin place (bridge resolver kept),AggregationNamingSpecgoes red with the original symptoms — aggx,params.x > 5 && params.x > 3, the missingorderkey, nohaving_filterfor the alias form. Restored; green again.Review: first commit —
bmad-code-review's three layers run inline by the implementer (2 patches applied, 1 defer). Then an independent fresh-context review of4368d9dc(.review-BIDC-2.md: approve-with-fixes, 0 HIGH / 3 MEDIUM / 8 LOW → commita5f376f2) and an independent second look ofa5f376f2(S2-1..S2-4 → commitsb09aaf27,b5a86cf9); every disposition is in the findings log underBIDC-2 — review follow-up.🤖 Generated with Claude Code