fix(sql): a MATERIALIZED VIEW refuses a HAVING its transform cannot express - #398
Merged
Merged
Conversation
…xpress
A materialized view SILENTLY DROPPED a HAVING clause its transform has no
mechanism for - or deployed a bucket_selector that rejects every group - and
answered HTTP 200. Seven rules: six specific ones and a unified backstop, each
measured at RENDER level on the generated TransformConfig.
1 a condition on a GROUPING key. A search applies one through the terms
aggregation's include / exclude list; a transform's group_by has no such
channel, so the condition vanished. With a metric conjunct beside it only
the KEY half vanished - a partial filter answering 200 with wrong groups.
2 a HAVING with no GROUP BY. The pivot is built from the GROUP BY alone, so
there was nothing for a bucket_selector to hang on.
3 an aggregate a view's transform cannot compute. toTransformAggregation has
arms for MIN / MAX / SUM / AVG / COUNT and answers None otherwise;
buildAggregations flatMaps that None away while extractAggregatePaths keys
on *is this an aggregate*, so the two DIVERGE. STDDEV, VARIANCE and
PERCENTILE_CONT - even WITH the aggregate in the SELECT list - produced a
buckets_path naming an aggregation that is never created.
4 a SELECT bucket_script alias (MAX(x) - MIN(x) AS d). This repo's pivot model
has a bucketSelector field and no bucketScript one, and such an item is not
an aggregate, so buckets_path came back empty.
5 an aggregate on the RIGHT of a comparison THAT NO LEAF NAMES ON ITS LEFT.
extractAggregatePaths walks expr.identifier only and never expr.maybeValue,
while core's own extractAllMetricsPath walks both - so an aggregate reached
only through a value side lands in buildAggregations and NEVER in
buckets_path. The selector IS built, reads a null parameter, and every
bucket is rejected: EMPTY view, 200. Both halves of that sentence are
load-bearing and each was measured after a gate found the rule too wide:
extractAggregatePaths declares the LEFT identifier of EVERY leaf in the
whole clause, so a sibling conjunct naming the same aggregate DECLARES it
and the view runs correctly (firing on mere presence over-refused 74 cells
that are correct on main); and when nothing names it on the left but the
SELECT list does not publish it either, adding the SELECT alias is what
makes the selector declare it, so rule 6 owns that family and this rule
stands down. HAVING MIN(amount) > 1 AND MAX(amount) > MIN(amount) is
ACCEPTED once MIN(amount) AS mn is published; HAVING MAX(amount) >
MIN(amount) is not.
6 an aggregate the transform could compute but the SELECT list does not
publish in the spelling the HAVING uses.
7 THE BACKSTOP - a HAVING leaf naming no aggregation the pivot creates. Two
families the six are structurally blind to. (a) An aggregate with NO SELECT
alias: core mints a generated alias for an unaliased item and the script
reads it, while RequiredField.apply names the aggregation after the USER
alias - the two never meet, buckets_path is EMPTY and the clause is silently
dropped. Invariant across every computable aggregate, every connective, one
and two grouping keys and a JOIN body. (b) A nested / child / parent
predicate beside a metric conjunct: extractAggregatePaths has case _ => acc
for a relation and havingLeaves excludes them, so the selector is emitted
reading the metric alone.
The refusal lives in CreateMaterializedView.validate(), beside the three arms
that already refuse a set operation, a derived table and a WHERE subquery: one
place, a parse-time 400 with a reason and a remedy instead of a 500 from the
extension, and every venue rather than one. It is chained AFTER dql.validate()
because these are additional constraints on an already valid SELECT.
MV-ONLY by construction, asserted in both directions: every refused body is
CORRECT as a plain search and stays accepted, still reaching the terms channel
and still creating its auxiliary aggregation.
The backstop is LAST and the rule set is deliberately NOT collapsed into it. It
subsumes rules 1, 3 and 4 entirely and 5 and 6 in part, but those rules exist
for their SPECIFIC REMEDIES and one generic message would lose all of them.
Rule order is load-bearing and measured: the rules above the SELECT-list rule
run first because its remedy is "add it to the SELECT list", and for each of
their populations applying that remedy lands somewhere WORSE. The converse is
equally measured and is why rule 5 asks a counterfactual rather than "is this
parameter declared today": where some leaf names the right-hand aggregate on
its left, that remedy DOES end the journey, so rule 5 stands down and rule 6
speaks. Precedence is not a fixed order between two rules - it is whichever
remedy reaches a view that deploys.
Every remedy that puts an aggregate in the SELECT list says AS <alias>, and that
is not politeness: a view's HAVING can only read an aggregate that carries a
SELECT alias, so "spell it exactly as the HAVING spells it" was - on its own -
the instruction that produced the broken artefact, because a HAVING never spells
an alias. Applying each remedy literally is a test.
The derivations ask the real thing rather than copying it: notPublishedBySelect
is auxiliaryAggs' own filter hoisted; the unbuildable rule calls
toTransformAggregation itself; the right-operand rule is expressed as the VALUE
SIDE, the operand the consumer omits; and the backstop's names come from
Field.outputName, which is the consumer's formula character for character - what
matters there is the INPUT, raw select.fields rather than the computed aliases.
Attribution is deliberate: these are limitations of this engine's transform
model, not of Elasticsearch. TransformPivot carries a bucketSelector, so a
pipeline aggregation reaches a pivot; MIN/MAX/SUM/AVG/COUNT is
toTransformAggregation's arm set. The messages say so.
There is deliberately NO rule relaying Having.unrepresentable: it would run
after dql.validate(), which already refuses every un-expressible HAVING
unconditionally, so such an arm is unsatisfiable for every possible input.
Release note: a CREATE MATERIALIZED VIEW now fails at CREATE, where it
previously succeeded and materialized every group - or deployed a selector that
rejects every group - when its HAVING filters on a grouping key, has no GROUP
BY, names an aggregate a view's transform cannot compute (only MIN, MAX, SUM,
AVG and COUNT including COUNT DISTINCT are computable), names an expression over
aggregates, compares an aggregate with an aggregate NO OTHER CONDITION names on
its left, names an aggregate the SELECT list does not publish in the same
spelling, reads any aggregate that does not carry a SELECT alias, or contains a
nested / child / parent predicate. The last is the one most users will meet: a
view's HAVING can only read an aggregate written in the SELECT list with an
alias. Comparing two aggregates is NOT refused when some other condition in the
same HAVING names the right-hand one on its left (HAVING SUM(x) > MAX(x) AND
MAX(x) > 1) - that view deploys and runs correctly on every version, and it is
accepted. The identical statement as a plain SELECT is unaffected in every
case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fupelaqu
marked this pull request as ready for review
September 25, 2026 19:48
fupelaqu
added a commit
that referenced
this pull request
Sep 26, 2026
…ss (#398) `materialized_views.md` mentioned `HAVING` exactly once — inside the worked aggregation example — while a released build refuses seven shapes of it, so a user meeting the refusal had nowhere to read about it. The new `### HAVING in a materialized view` says, in this order: the case almost everyone meets first (an aggregate with no `SELECT` alias — the view's transform names each aggregation after that alias, so an unaliased one leaves the group filter naming a metric the view never creates); why a view differs from a search at all (five mechanisms carry a `HAVING`, a transform's pivot offers one — no `terms` `include`/`exclude` channel, no `bucket_script`, and only MIN/MAX/SUM/AVG/COUNT computed); the eight refusals with a reason and a remedy each; what still works; and what changed. Every SQL statement published here was MEASURED against the merged guard, in the exact spelling the page prints (semicolon and `CREATE OR REPLACE` forms included): the refused ones are refused, the accepted ones accepted, and the page's pre-existing JOIN example is still accepted unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
A
MATERIALIZED VIEWwhoseHAVINGan Elasticsearch transform cannot express is now refused at parse, instead of deploying a view that silently materialises the wrong groups.Why a view differs from a search
The same
HAVINGis correct in aSELECTand unbuildable in a view. A search applies a key predicate through thetermsinclude/excludechannel and arithmetic over aggregates through abucket_script; a transform's pivot has neither —TransformGroupBy.TermsGroupByemits{"terms":{"field":…}}and nothing else, andAggregateConversion.toTransformAggregationcomputes only MIN / MAX / SUM / AVG / COUNT. So the only correct answer inside the engine is to refuse, and to say why.Every refused shape currently deploys a broken view: a
bucket_selectorreading aparams.*itsbuckets_pathnever declares (which rejects every bucket — the view materialises empty), or no selector at all (the filter vanishes — every group materialises). All at HTTP 200.The seven rules
HAVINGwith noGROUP BYgroup_byhas no include/exclude channelSTDDEV,VARIANCE, percentiles, ranking)toTransformAggregationanswersNone; the path would name an aggregation never createdSELECTbucket_scriptalias (MAX(x) - MIN(x) AS d … HAVING d > 3)buckets_pathcomes back emptybuckets_pathRules 2–6 stay in front of the backstop for their specific remedies, though it subsumes several: a generic message would lose the actionable part.
Coverage — derived, not sampled
A Cartesian derivation over 2,917 statements (aggregate leaves enumerated from the sealed type by reflection; operand position × publication × expression shape × reference form × grouping × connective; the extensions consumer transcribed from source and re-transcribed independently for the re-run) classified every cell as CORRECT / REFUSED-RIGHTLY / UNCOVERED / OVER-REFUSED.
04b1c6941,821 of the control's 1,835 silent mis-deployments are closed, and nothing sound is refused.
mainthat the rules refused, because the first corpus contained no case where the right-hand aggregate is also declared by a sibling conjunct. That defect is fixed, and the table above is measured on the corpus that caught it. It is not a third independent derivation; if you want one before this leaves draft, say so.Rule 5's sole-owner population went 99 → 25 cells, all 25 unsound — it now owns only what is genuinely broken.
Nothing sound is refused and no statement that behaved correctly changes: every refused shape was already broken on
main.🔴 Each rule's remedy is applied literally in the test suite and asserted to produce an accepted statement. Three rounds running, a refusal's own advice was the defect — including "spelled exactly as the HAVING spells it", which is precisely the instruction that produced a broken artefact, because a
HAVINGnever spells an alias. That is now pinned, not rediscovered.MV-only, in both directions
These rules apply to
CREATE MATERIALIZED VIEWand to nothing else. Every refused body, as a plainSELECT, is still accepted and emits a byte-identical query tomain— measured across the whole corpus, and guarded by a mutation that leaks the rules intoSingleSearch.validate()and reddens 6 tests.Verification
sql1661 ·core1146 · bridge 238 · es6bridge 234 — green.+ compileon 2.12.20 and 2.13.16. The exact CI lint line. Integration on real Elasticsearch 8.18.3 (47/47) and 6.8.23 (47/47). GrammarDiffProbe 18,532 inputs → 0 differing; emission differential over 1,624 GROUP BY/HAVING inputs → 0 differing. 22 of 24 mutations red; the 2 greens are second-line-of-defence guards measured unreachable because an earlier rule refuses their population first — documented at both sites, reported as green-with-reason rather than as passes.Cost: 6 interleaved rounds with the run order rotated and a byte-identical second control as the must-be-zero floor — no signal on any instrument. (A fixed order manufactured a phantom +8 % earlier in this work; rotation collapsed it. Worth knowing for anyone measuring this way.)
Release note
A materialized view whose
HAVINGfilters on the grouping key, has noGROUP BY, references an aggregate the view does not publish or cannot compute, compares two aggregates, or reads aSELECTbucket-script alias, now fails atCREATEwhere it previously succeeded and silently materialised the wrong groups. The commonest case by far is an aggregate with noSELECTalias —SELECT city, SUM(amount) FROM t GROUP BY city HAVING SUM(amount) > 5— which is fixed by writingSUM(amount) AS total. Every refusal names the clause and the remedy.Residual, measured and NOT fixed
14 cells —
MATCH (…) AGAINST (…)in aHAVING.MultiMatchCriteriais neither aPredicate, anExpressionnor anElasticRelation, so it falls into thecase _arm of three separate walks. Not a regression and not view-specific: the same conjunct is dropped in a plainSELECTtoo, identically onmain(HAVING SUM(x) > 5 AND MATCH (name) AGAINST ('y')renders only theSUMhalf;HAVING MATCH(...)alone renders1 == 1). That is #389's conflation still live forMultiMatchCriteria, and it belongs to the search surface rather than to this change.Recorded, not fixed — extensions-side, and the honest root
Stage.extractAggregatePathswalksexpr.identifierand neverexpr.maybeValue, while core'sExpression.extractAllMetricsPathwalks both. That asymmetry is why rule 5 exists.HAVINGidentifier, extensions names the aggregation from the user alias or the source field. Refusing is the workaround; making the two agree is the repair.toTransformAggregationdropsDISTINCToutsideCOUNT, soSUM(DISTINCT x)emits a plainSUM— in the view column as well as the filter. Pre-existing and wider thanHAVING.Neither candidate repair in (1) or (2) has been tested. (1) is insufficient alone — it declares the right operand only when that operand is published — and (2) has no obviously-correct side: naming the aggregation after the generated alias would put
__cNin the view's schema. They are recorded as measurements, not as a plan.🤖 Generated with Claude Code