Skip to content

fix(sql): a MATERIALIZED VIEW refuses a HAVING its transform cannot express - #398

Merged
fupelaqu merged 1 commit into
mainfrom
fix/mv-having-unemittable
Sep 25, 2026
Merged

fupelaqu merged 1 commit into
mainfrom
fix/mv-having-unemittable

Conversation

@fupelaqu

Copy link
Copy Markdown
Contributor

A MATERIALIZED VIEW whose HAVING an 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 HAVING is correct in a SELECT and unbuildable in a view. A search applies a key predicate through the terms include/exclude channel and arithmetic over aggregates through a bucket_script; a transform's pivot has neither — TransformGroupBy.TermsGroupBy emits {"terms":{"field":…}} and nothing else, and AggregateConversion.toTransformAggregation computes 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_selector reading a params.* its buckets_path never 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

# refused because
1 HAVING with no GROUP BY no pivot exists, so no selector can
2 a condition on a grouping key a transform's group_by has no include/exclude channel
3 an aggregate no transform can compute (STDDEV, VARIANCE, percentiles, ranking) toTransformAggregation answers None; the path would name an aggregation never created
4 a SELECT bucket_script alias (MAX(x) - MIN(x) AS d … HAVING d > 3) a pivot has no bucket_script channel; buckets_path comes back empty
5 an aggregate on the value side of a comparison the path builder walks the identifier side only, so the right operand is never declared
6 an aggregate the SELECT list does not publish empty or partial buckets_path
7 backstop — any HAVING leaf naming a metric the pivot does not create catches what the six miss, relation predicates included

Rules 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.

CORRECT REFUSED-RIGHTLY UNCOVERED OVER-REFUSED
control 04b1c694 185 0 1,835 0
this branch 185 1,821 14 0

1,821 of the control's 1,835 silent mis-deployments are closed, and nothing sound is refused.

⚠️ Honest provenance of those numbers. An earlier derivation over a different corpus reported 0 over-refusals for a six-rule version; an independent re-derivation with a rebuilt corpus then found 74 — statements correct on main that 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.

⚠️ Rules 1, 3 and 4 own zero cells the backstop does not also catch. They are kept for their specific remedies, by decision, not because they add coverage — and the scaladoc now says so with the numbers, so nobody mistakes seven rules for seven rules' worth of protection.

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 HAVING never spells an alias. That is now pinned, not rediscovered.

MV-only, in both directions

These rules apply to CREATE MATERIALIZED VIEW and to nothing else. Every refused body, as a plain SELECT, is still accepted and emits a byte-identical query to main — measured across the whole corpus, and guarded by a mutation that leaks the rules into SingleSearch.validate() and reddens 6 tests.

Verification

sql 1661 · core 1146 · bridge 238 · es6bridge 234 — green. + compile on 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 HAVING filters on the grouping key, has no GROUP BY, references an aggregate the view does not publish or cannot compute, compares two aggregates, or reads a SELECT bucket-script alias, now fails at CREATE where it previously succeeded and silently materialised the wrong groups. The commonest case by far is an aggregate with no SELECT alias — SELECT city, SUM(amount) FROM t GROUP BY city HAVING SUM(amount) > 5 — which is fixed by writing SUM(amount) AS total. Every refusal names the clause and the remedy.

Residual, measured and NOT fixed

14 cells — MATCH (…) AGAINST (…) in a HAVING. MultiMatchCriteria is neither a Predicate, an Expression nor an ElasticRelation, so it falls into the case _ arm of three separate walks. Not a regression and not view-specific: the same conjunct is dropped in a plain SELECT too, identically on main (HAVING SUM(x) > 5 AND MATCH (name) AGAINST ('y') renders only the SUM half; HAVING MATCH(...) alone renders 1 == 1). That is #389's conflation still live for MultiMatchCriteria, and it belongs to the search surface rather than to this change.

Recorded, not fixed — extensions-side, and the honest root

  1. Stage.extractAggregatePaths walks expr.identifier and never expr.maybeValue, while core's Expression.extractAllMetricsPath walks both. That asymmetry is why rule 5 exists.
  2. The name-identity disagreement behind the no-alias family: core mints a generated alias that reaches the HAVING identifier, extensions names the aggregation from the user alias or the source field. Refusing is the workaround; making the two agree is the repair.
  3. toTransformAggregation drops DISTINCT outside COUNT, so SUM(DISTINCT x) emits a plain SUM — in the view column as well as the filter. Pre-existing and wider than HAVING.
  4. Adjacent, outside this surface: a view selecting only an uncomputable aggregate deploys a pivot with no aggregations at all, so the metric column is silently absent.

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 __cN in the view's schema. They are recorded as measurements, not as a plan.

🤖 Generated with Claude Code

…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
fupelaqu marked this pull request as ready for review September 25, 2026 19:48
@fupelaqu
fupelaqu merged commit 9bf185f into main Sep 25, 2026
4 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant