Repository navigation
fix(sql): a qualified GROUP BY key resolves to its bucket in HAVING - #428
Merged
Merged
Conversation
…IDENT-4) A HAVING condition on a GROUP BY key is applied by the terms filter of the level it names. Which level that is used to be decided by the key's display name, which is not an identity: a qualified spelling (`GROUP BY g.status HAVING g.status = 'a'`, and either mixed spelling) was refused, a self-join's two levels shared one name, and so did two functions of one column. - One decision, `SingleSearch.bucketFor`, recorded when the identifier is resolved: the alias it was written with, else the GROUP BY key with the same correlation and expression (an unqualified name being the main table's), else the spelling lookup when it is compatible. It reads a positional list of the buckets that never collapses two of them. - One comparison, `Bucket.sameKeyAs` (display name, correlation, grouped expression), read by the three include sites and by `keyBucketOf`, which asks the recorded attachment first. An argument of a nameless GROUP BY key keeps its name match, so a JOIN grouping `a.c, UPPER(b.c)` is accepted as before. - A qualified key's copy is resolved like its level, so its metrics stay on their level after one resolution (a remembered schema miss, an unreadable mapping). - The function chain of a qualified column resolves with the column in the qualified-plain branch, as in every other branch. - Rule (c) keys a level by its GROUP BY rendering, so an OR across two functions of one column is refused like an OR across two keys; a MATCH is a leaf of every HAVING rule; a key filter on a date-histogram level (a DATE_TRUNC key, or any key whose function returns a date) is refused with the WHERE remedy. `Bucket.isDateHistogram` is the one predicate both bridges and the classifier read. - In a statement the relational engine evaluates (a JOIN, a derived table, a CTE), rule (c) compares mechanisms only and rules (b) / (b2) do not apply: nothing nests there, and the engine evaluates the condition after the join. - A WHERE comparison no longer reads whether its column is a GROUP BY key to choose its Painless: a temporal comparison stays a date comparison. Story IDENT-4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fupelaqu
marked this pull request as ready for review
October 20, 2026 04:40
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.
fix(sql): a qualified GROUP BY key resolves to its bucket in HAVING
Story IDENT-4.
Summary
BI tools qualify every column:
SELECT g.status, COUNT(*) … FROM idx g GROUP BY g.status HAVING g.status = 'a'. Which grouping level a HAVING key condition addresses used to be decided by thelevel's DISPLAY name, and a display name is not an identity:
GROUP BY g.status HAVING status = 'a',GROUP BY status HAVING g.status = 'a'), never matched its level, so the condition was refused;GROUP BY o.category, p.category) share a name after one resolution, soa condition on one correlation filtered both levels, or the wrong one;
GROUP BY YEAR(d), MONTH(d)) share a name, so acondition on
MONTH(d)also filtered theYEARlevel and the result was empty;be read), a qualified key's metrics never reached its level and came back NULL.
What changes:
SingleSearch.bucketFor, recorded when an identifier is resolved: the alias it waswritten with; else the GROUP BY key with the same correlation and the same grouped expression (an
unqualified name being the main table's column); else the spelling lookup, when it is compatible.
It reads a positional list of the buckets (
keyedBuckets) that never collapses two of them;bucketNamesis derived from it.Bucket.sameKeyAs(display name, correlation, grouped expression), read by thethree include sites and by
keyBucketOf, which now asks the recorded attachment first. An argumentof a nameless GROUP BY key (
UPPER(b.c),CONCAT(a.c, b.c)) keeps the name match it always had.after one resolution.
GenericIdentifier.update), as in every other branch: a key script readsdoc['d'], neverdoc['q.d'].GROUP BY rendering, so two functions of one column are two levels; a
MATCHis a leaf of everyrule; a key filter on a level Elasticsearch groups with a date histogram is refused, with the
WHERE remedy. That last refusal is structural, not a type rule: the level the bridges emit for a
DATE_TRUNCkey, or any key whose function returns a date, is a date histogram, which has no keyfilter, whatever the column's mapping.
Bucket.isDateHistogramis the one predicate both bridgesand the classifier read.
compares mechanisms only and the two key-filter rules (a direct comparison only; one union per
list) do not apply: nothing nests there, no terms filter is built, and the engine evaluates the
condition after the join.
Painless: a temporal comparison stays a date comparison (it took the numeric fallback, a raw
>ontwo dates, exactly when the column was grouped).
Before → after
FROM idx g, keyword key)GROUP BY g.k HAVING g.k = 'x'(key unaliased)GROUP BY g.k HAVING k = 'x'/GROUP BY k HAVING g.k = 'x'SELECT g.k AS a … GROUP BY g.k HAVING g.k = 'x'GROUP BY g.k HAVING COUNT(*) > 35GROUP BY g.k HAVING MATCH (g.k) AGAINST ('x') OR COUNT(*) > 35GROUP BY YEAR(d), MONTH(d) HAVING MONTH(d) = 1GROUP BY YEAR(d), MONTH(d) HAVING YEAR(d) = 2024 AND MONTH(d) = 1GROUP BY YEAR(d), MONTH(d) HAVING YEAR(d) = 2024 OR MONTH(d) = 1GROUP BY DATE_TRUNC(d, MONTH) HAVING DATE_TRUNC(d, MONTH) = '2024-01-01'GROUP BY o.k, p.k HAVING p.k = 'x'(self-join)plevel onlyGROUP BY c1, c2 HAVING c1 = 'x' OR c2 = 'y',UPPER(d.k) = 'X',d.k > 'm'"Before" is measured (RUN on 8.18.3 and 6.8.23 before this change, or RENDER where stated). "After"
is verified in process (PARSE / RENDER at both resolutions) and at RUN on 8.18.3 and 6.8.23 by
GroupByCompletenessSpecon the final code (concrete index, index pattern, one-resolution venue).Release notes
HAVINGnow filters, in every spelling:GROUP BY g.status HAVING g.status = 'a',GROUP BY g.status HAVING status = 'a'andGROUP BY status HAVING g.status = 'a'keep exactly the groupsGROUP BY status HAVING status = 'a'keeps (they were refused). Thisincludes a JOIN (for example HAVING <function>(<aggregate>) silently drops the entire HAVING clause — every group is returned #389's
GROUP BY d.dept_name HAVING d.dept_name = 'eng'), which therelational engine executes; JOIN execution is verified by the softclient4es-arrow JOIN tests after
merge. One exception: a key whose function renders its own argument (
CAST,TRY_CAST,SAFE_CAST,ISNULL,ISNOTNULL) is matched by its spelling, so its two mixed spellingsstay refused in a statement Elasticsearch groups (in a JOIN, a derived table or a CTE they are
accepted and left to the relational engine); write it the same way in
GROUP BYandHAVING.window, or an index whose mapping cannot be read — a qualified GROUP BY key's metrics are per
group again (they came back NULL), and a
HAVINGover them is answered(
GROUP BY g.category HAVING COUNT(*) > 35keeps its two groups — RUN at the one-resolutionvenue on 8.18.3 and 6.8.23). Index patterns are resolved twice since fix(sql): date arithmetic follows the SQL engines; type checks wait for the column types #412 and were already right.
GROUP BY YEAR(d), MONTH(d)): a condition on one nolonger empties the result, and the
ANDof conditions on both is answered at a one-resolutionvenue (it was refused); at a mapped index a date key fails for the reason recorded below.
included: an
ORacross two functions of one column (YEAR(d) = 2024 OR MONTH(d) = 1, whichElasticsearch ran as an
AND); a key condition on aDATE_TRUNCkey or on a key whose functionreturns a date (Elasticsearch groups it with a date histogram, which has no key filter: on a
DATE_TRUNCkey the condition was ignored, and the other keys failed with an Elasticsearcherror); a
MATCH … AGAINSTon a key combined byORwith an aggregate, or overtwo keys (
MATCH (g, h)), in a statement Elasticsearch groups.an aggregate"; a qualified key condition in a materialized view gets the view's own refusal; the
"DIFFERENT GROUP BY keys" refusal names the keys as the GROUP BY writes them; a materialized view
whose
HAVINGis aMATCHon its grouping key is refused (its transform has no key filter, sothe
MATCHwas dropped).DATE_PARSE(q.d, …)and every other registry function) readsthe resolved field after one resolution: its key script reads
doc['d'], notdoc['q.d'](render-level evidence; no answer is claimed while date keys at a mapped index fail for the
reason recorded below).
WHEREcomparison of a GROUP BY key column no longer depends on the column beinggrouped. With a computed date (
WHERE q.d > DATE_ADD(q.e, INTERVAL 1 DAY) … GROUP BY q.d),qualified or bare, it is rendered as a date comparison at both resolutions (render-level; the
bare spelling rendered a raw
>, which Painless refuses); at a one-resolution venue bothspellings keep the one group SQL keeps (RUN on 8.18.3; the rows run on Elasticsearch 7 and up,
7.17 and 9.0 in CI). A comparison of two date
columns (
WHERE q.d > q.e … GROUP BY q.d) is a date comparison once the mapping is read; at oneresolution neither column is typed, and it stays a raw
>, as on main. No claim is made forElasticsearch 6.8.
the softclient4es-arrow JOIN tests pass against this change after merge.
(
GROUP BY a.category, UPPER(b.category) HAVING UPPER(b.category) = 'X') is accepted, as inv0.23.0.
ORacross two GROUP BY keys (GROUP BY c1, c2 HAVING c1 = 'x' OR c2 = 'y'),refused on main since the key rules were added, is accepted again, as in v0.23.0: the relational
engine evaluates it after the join.
UPPER(d.dept_name) = 'ENG'), a range (d.dept_name > 'm'), anANDon one key — are accepted again, as in v0.23.0.Tests
Evidence levels: PARSE / VALIDATE / RENDER (the bytes sent) at one resolution (R1:
Parser.apply; aremembered schema miss, an unreadable mapping) and two (R2: a concrete index, an index pattern since
#412;
updateAllfor a JOIN); RUN on a real cluster.In process (plain sbt, Scala 2.13):
sql/testsoftclient4es-sql-bridge/testes6bridge/testcore/testsoftclient4es-core-testkit/compileQualifiedGroupKeySpec(new): a derived population — 3 FROM shapes (one table, two indices, aself-join) × 3 qualifiers × 11 groupings × 3 HAVING spellings × 8 operators = 264 cells, each at
R1 and R2, against an oracle stated from the SQL alone (168 accepted, 96 refused, 0 wrong), plus
the same population with the key aliased in the SELECT; the self-join in both directions and
after a second and a third resolution;
GROUP BY <alias>over a qualified column; two functionsof one column (the condition on one, the AND, the OR); every registry function key (387) in four
spellings; the wrapping venues (derived table, CTE, WHERE subquery, materialized view); the
date-histogram rule;
MATCHin every rule (including a view and a date key); one-key AND/ORmixes in every spelling; nameless JOIN keys; the JOIN rows the relational engine evaluates and
their single-index controls; UNNEST keys,
tableQualifierOfagainstGenericIdentifier.updateover a derived name population, window partition keys.
QualifiedGroupKeyEmissionSpec(new, both bridges): the bytes sent are the bare spelling's atboth resolutions; the self-join filter on its own level; the metrics on their level after one
resolution; every registry function key over
FROM t qreadsdoc['d']and neverdoc['q.d'](774 renders, 648 key scripts); the 40 keys whose function returns a date are emitted as a date
histogram, and a key condition on each is refused; a temporal WHERE comparison is the one its
ungrouped twin emits and a date comparison;
Bucket.isDateHistogramis true exactly where thebridge emits a date histogram. On the Elasticsearch 6.8 bridge, the 6.8 oracle keeps the same keys
for every spelling, and the 6.8 refusal set is refused by the serializer in every spelling.
BucketIncludesSpec(the integer-key check in every spelling),SchemaAttachSpec(the body aone-resolution venue sends carries the metrics and the key filter),
HavingNullAwareSelectorSpec(a
MATCHover two keys is now refused by the OR rule).Mutations (one decision each, the file restored by bytes and
cmp): 29 decisions, each RED except as stated — the three lookup rules, theidentity (correlation and expression), the qualifier-resolved copies, the mirror of
update'squalifier rule, the three conjuncts of
sameKeyAs, the attachment-firstkeyBucketOfand its namearm, the level key of the OR rule, the date-histogram arm, the three
MATCHarms, the chainresolution of the qualified branch, the WHERE comparison, the explicit two-correlation dedup, the
relational-engine scoping of the OR rule and of the two key-filter rules, the nameless-key
exception: 28 RED (1 to 22 failing tests each). The "no unqualified fast path" mutation is GREEN by
design (an optimisation; its evidence is the cost table).
Differential (27,234 inputs: the repository corpus, the population and its wrappers, every
registry function key in nine shapes, and every statement the tests write; this tree vs main and
main vs main): no statement without a GROUP BY moved; no render moved (the bytes a materialized
view persists are unchanged for every input); no
isScriptFieldmoved; the statements whose tworesolutions disagree went from 1,398 to 3 (none new); main vs main is byte-identical. Every move is
classified — refused → accepted: 367 qualified key conditions (and their wrappers), 29 JOIN key conditions as in
v0.23.0, 1 JOIN OR across two aliased keys, 1 AND of two functions of one column; refused → refused
with another named reason: 170 to "neither a GROUP BY key nor an aggregate", 121 to the date-histogram
refusal, 120 to the materialized view's key refusal, 4 others (a qualified one-key mix now refused as
its bare twin is; a qualified OR across two functions; two mixed spellings of
ISNULL/ISNOTNULLkeys, listed below); accepted → refused: listed below; accepted, body changed: 434 a qualified key's
metrics back on their level at one resolution, 24 a function key script reading the resolved field,
24 a qualified
MATCHchannel present at one resolution, 3 a condition no longer copied onto asibling function level, 2 the WHERE date comparison. The relational-engine statements newly refused:
18, each a
MATCHover a column no key of its table groups (the relational engine has noMATCH),plus the two derived-table / CTE bodies above (Elasticsearch groups the body). The WHERE change alone (this tree with and without it): no
aggs/bucket_selectorbyte moved on either bridge; the four WHERE moves are temporal comparisons of agrouped column. The differential's second resolution attaches no mapping (outside the population),
so a comparison of two date columns (
WHERE q.d > q.e GROUP BY q.d) is byte-identical to mainthere, a raw
>at both of its resolutions; its date comparison once the mapping is read isasserted by
QualifiedGroupKeyEmissionSpecat the second resolution with a date mapping (RENDER).Accepted before, refused now (each with what it answered before):
one-resolution venue answered the AND
[(3,3,6)]where SQL means three groups (both majors;fixture
group_by_one_resolution_dates);MATCHon the key OR an aggregate: RUN answered the intersection{cat_37}where SQL means{cat_36, cat_37}(concrete index and pattern, both majors), and an Elasticsearch error for thequalified spelling at one resolution (fixture
group_by_completeness);MATCHover two keys (GROUP BY g, h HAVING MATCH (g, h) AGAINST ('x'), whichHavingNullAwareSelectorSpecpinned as accepted): RENDER, the pattern on both nested levels, i.e.the AND of the two, where SQL means the OR; now refused by the OR rule, its message quoting the
MATCHas written;MATCHon its key: source level, the view's transform hasno key filter, so the
MATCHwas dropped (not run: the extensions venue);MATCHin a JOIN over a column no key of its table groups: it matched another table's key byname; the relational engine has no
MATCH;every one; RUN,
DATE_TRUNCreturned all three months (concrete, pattern, one resolution, bothmajors), the others fail with an Elasticsearch error.
Integration legs (Docker, sbt with JDK 17), final code, once each:
es8java/testOnly *JavaClientGroupByCompletenessSpec(8.18.3)es6rest/testOnly *RestHighLevelClientGroupByCompletenessSpec(6.8.23)Every IDENT-4 row passes on both majors against the same oracle — the four spellings × eight
operators at the concrete index, the index pattern and the one-resolution venue, a qualified key's metrics and a
HAVINGover them,GROUP BY <select alias>over a qualified column, the WHERE subquery, the alias remedy, the qualified fractional-key check, the Elasticsearch 6 refusalset, the date-level refusal and its WHERE remedy, the
MATCHrefusals, theORacross two functions of one column — except the twoone-resolution
DATE_ADDrows, which run on Elasticsearch 7 and up only.Cost
Median µs over 5 rounds, rotated this tree / main / main again (the last pair is the noise floor),
same session, warm JIT; the range is min–max over the rounds. Measured, never asserted.
GROUP BY a.category HAVING a.category = 'x'GROUP BY category HAVING a.category = 'x'… HAVING p.category = 'x'GROUP BY category HAVING category = 'x'(no qualifier)resolveWithSchema) · group by + havingGROUP BY e.code HAVING e.code = 'x'Every shape's range overlaps main's, except one emission shape where this tree is faster. The
largest median increases (
WHERE heavy: +21 µs parse, +33 µs in the resolution probe) are on astatement with no GROUP BY, where the new lookup returns at its first test, and they sit inside
main's own min–max.
The qualified shapes are refused on main, so their main column is the cost of a refusal, not of an
answer. Not measured: the row path (response parsing is untouched).
Recorded, not fixed
pattern or a comma list): once the mapping is attached the level is a
date_histogramwith nointerval unless the key is one
DATE_TRUNC, and Elasticsearch refuses it (8.18: "Invalid intervalspecified"; 6.8: "Zero or negative time interval not supported"). Pre-existing at a concrete index;
at a pattern it is new since fix(sql): date arithmetic follows the SQL engines; type checks wait for the column types #412 (v0.23.0 resolved a pattern once and sent
terms). This changedoes not widen it (an error stays an error). It is fixed by its own change before the 0.24.0 tag;
Bucket.isDateHistogramis the one predicate it changes. The RUN rows over date keys at thetwo-resolution venues are therefore not exercised here; they move to that change.
DATE_FORMAT,WEEKDAY,DAYOFWEEK,FORMAT,ISNULL, … over a column are emitted astermson the bare field (per-date groups, HTTP 200). Pre-existing, same change as above.
CAST,TRY_CAST,SAFE_CAST,ISNULL,ISNOTNULL, and an aggregate written as a key; anARRAY(…)key is refused in everyspelling, as on main, because it renders as an
ARRAY_AGGwindow): its grouped expression keepsthe qualifier (
CAST(t.d AS VARCHAR)), so its two MIXED spellings(
GROUP BY CAST(t.d AS VARCHAR) HAVING CAST(d AS VARCHAR) = 'v'and the reverse) are refused,as they were, in a statement Elasticsearch groups; in a JOIN, a derived table or a CTE they are
accepted and left to the relational engine (not run here: the softclient4es-arrow JOIN tests after
merge); the bare and fully-qualified spellings are answered. Not widened: the test asserts
that no mixed spelling is ever answered with a channel other than the bare spelling's (48 mixed
spellings of the 24 such keys over one index, all refused). For
ISNULL/ISNOTNULLthe refusal now names thedirect-comparison limit of the key filter, where it read "neither a GROUP BY key nor an aggregate".
WHERE q.d > q.e, no mapping): bothcolumns are untyped, so the comparison is a raw
>, grouped or not. Pre-existing.rangeon the other column's name ({"range":{"d":{"gt":"e||+1d"}}}); a temporalisAfteratone resolution fails with a Joda cast. Pre-existing.
GROUP BY UPPER(p.category) HAVING UPPER(p.category) = 'X'in a JOIN with no grouping of theargument's same-named column: refused as neither key nor aggregate, before and after.
MatchCriteria.includessends theMATCHtext as a literal include pattern. Pre-existing.PARTITION BYkey's include, the JOIN "Ambiguous column" message,the unused
GroupBy.bucketNames, a venue-dependent column name.PARTITION BYkey that is not a GROUP BY key passes validation and itsinclude lands on the window's partition level; a key filter also lands on a partition level of the
same key (both spellings alike). Pre-existing.
Select.fieldAliasesis keyed byidentifierName: after one resolution a self-join'so.category AS oc, p.category AS pcshare a key, so at the second resolution both levels displayas
pcand a HAVING onocloses its include. Pre-existing, JOINs only.ORmixing a key condition and an aggregate (HAVING k = 'a' OR COUNT(*) > 1) isstill refused although the relational engine could answer it. Pre-existing.
GROUP BY YEAR(o.d) HAVING MONTH(o.d) = 1: refused at parse on main; now accepted, andthe relational engine refuses it ("column must appear in the GROUP BY clause"), as 0.23.0 did.
Binary compatibility
Additive only —
SingleSearchgainskeyedBuckets,bucketFor,keyIdentityOf,tableQualifierOf,dateHistogramLevelOf,dateHistogramReason;FromgainsmainTableCorrelation;BucketgainssameKeyAsandisDateHistogram; one newprivate[query]class. No constructor or case-class arity changes, no public member removed (
javap -pof the threeclasses: only synthetic lambda bodies differ) ⇒ no downstream rebuild.
NOT RUN locally
ES 7.17 and 9.0 legs and the Elasticsearch 6.8 Jest client (CI runs them); the softclient4es-arrow
JOIN tests, which need this change published and run after merge.
The R2 date-key rows are not exercised at RUN (a date key at a mapped index fails on main for the
reason recorded above); they move to the change that fixes it.
The website copy of the documentation is not part of this PR.
🤖 Generated with Claude Code