Fix HAVING-to-WHERE pushdown with mismatched operator families

First seen: 2026-05-26 08:48:24+00:00 · Messages: 5 · Participants: 2

Latest Update

2026-06-01 · claude-opus-4-6

Incremental Update: v2 Patch Unifies the Full Bug Class

Key Development

Richard (guofenglinux@gmail.com) has posted a v2 patch that significantly expands the scope of the fix from the original HAVING-to-WHERE case to cover the entire class of qual-pushdown-past-grouping-boundary bugs in a unified manner.

Technical Architecture of v2

The v2 patch introduces a shared walker function called expression_has_grouping_conflict that generalizes the detection logic. Key design elements:

  1. Unified walker: Rather than sprinkling ad-hoc checks in each pushdown path (HAVING→WHERE, qual→subquery past DISTINCT, qual→subquery past PARTITION BY, qual→subquery past set operations), a single expression tree walker handles all cases.

  2. Callback-based design: The walker takes a callback that maps each Var to the grouping equality operator for its column (returns InvalidOid for non-grouping Vars). This abstraction allows the same walker to work across different grouping contexts — GROUP BY, DISTINCT, DISTINCT ON, window PARTITION BY, and set-operation deduplication — by simply providing a different callback for each context.

  3. Detects both conflict types: The walker checks for both opfamily mismatches AND nondeterministic collation mismatches in a single pass, treating them as two manifestations of the same fundamental problem (equivalence relation disagreement between the qual and the grouping layer).

Scope of Cases Now Covered

The v2 patch addresses qual pushdown across ALL grouping boundaries:

Confirmation of Window Function Case

Thom confirms that the window PARTITION BY case discussed previously (the rank() example) is indeed the specific scenario he was asking about — pushing a qual past a window function's PARTITION BY boundary. He endorses the v2 approach of treating all cases as one bug class.

History (1 prior analysis)
2026-05-27 · claude-opus-4-6

Fix HAVING-to-WHERE Pushdown with Mismatched Operator Families

Core Problem

This thread exposes a family of optimizer correctness bugs where the planner incorrectly pushes filter clauses past grouping/deduplication boundaries when the clause's comparison semantics differ from those used by the grouping operation itself.

The Fundamental Invariant Being Violated

When the planner pushes a HAVING clause down to WHERE (i.e., evaluates it before aggregation rather than after), it relies on the assumption that if a row satisfies the pushed-down predicate, then every row that would be grouped together with it also satisfies the predicate. This assumption holds only when the HAVING clause's equality/comparison operator uses the same equivalence relation as the GROUP BY's equality operator.

When operator families differ, two values that are "equal" under the GROUP BY's opfamily (e.g., 100 and 100.0 under record_ops which uses numeric equality semantics) may NOT be equal under the HAVING clause's operator (e.g., *= which uses record_image_ops — byte-level comparison where 100100.0). Pushing the HAVING clause to WHERE means it filters individual rows before grouping, eliminating rows that should have been counted in the group.

Concrete Demonstration

-- record_ops groups (100,1) and (100.0,1) together (numeric equality)
-- record_image_ops (*= operator) treats them as different (byte-level)
select r, count(*) from t_having group by r having r *= row(100, 1)::t_rec;

The correct answer is count = 2 (both (100,1) and (100.0,1) fall into the same group). But if the HAVING clause is pushed to WHERE, the *= operator filters out (100.0,1) before grouping, yielding count = 1.

Historical Context

Commit f76686ce7 previously fixed a narrower version of this bug: it prevented pushdown when the HAVING clause's collation disagreed with a nondeterministic collation used by GROUP BY. The current patch generalizes this to also detect opfamily mismatches, which is the same shape of semantic conflict but through a different mechanism.

Scope of the Bug — Broader Than HAVING

The thread reveals that the problem extends beyond HAVING-to-WHERE pushdown. The same class of incorrect optimization occurs when restriction clauses are pushed into subqueries that contain:

  1. DISTINCT / DISTINCT ON — The subquery deduplicates using one equality relation, but the pushed-down WHERE clause uses a different one. This can cause rows to appear that should have been eliminated by DISTINCT, or vice versa.

  2. Window PARTITION BY — Pushing a filter past a window function boundary when the filter uses different equality semantics than the PARTITION BY can produce incorrect rank/row_number values, because rows are evaluated against the filter after window computation, but pushing them down changes the window partitions.

  3. Collation mismatches in subquery pushdown — Not just opfamily differences, but collation disagreements also affect the DISTINCT ON and window function cases. The example with ci (case-insensitive) collation vs "C" collation demonstrates this clearly.

Partition pruning is noted as NOT vulnerable because match_clause_to_partition_key() already validates both collation and opfamily compatibility.

Proposed Solution

Architecture of the Fix

The patch mirrors the structure of f76686ce7:

  1. Detection phase: Before flatten_group_exprs() transforms the HAVING clause (which replaces GROUP Vars with their underlying expressions), the code inspects each HAVING clause. It checks whether the clause's operator belongs to the same opfamily as the corresponding GROUP BY expression's equality operator.

  2. Recording phase: Indices of "unsafe" clauses (those with opfamily mismatches) are recorded in a Bitmapset.

  3. Consultation phase: The existing pushdown loop checks this Bitmapset and skips pushdown for flagged clauses.

The timing is critical — detection must happen before flatten_group_exprs() because after flattening, the GROUP Vars that identify which grouping expression each clause references are no longer present.

Back-patch Considerations

  • The bug exists back to v14 (and likely earlier).
  • The fix depends on RTE_GROUP infrastructure to identify grouping expressions via GROUP Vars in the pre-flattened havingQual.
  • RTE_GROUP was introduced relatively recently, so the fix can only be back-patched to v18, same as f76686ce7.

Open Work

The subquery pushdown cases (DISTINCT, DISTINCT ON, window PARTITION BY) with both opfamily and collation mismatches represent additional bugs that likely need separate patches. These appear to be newly identified in this thread.

Technical Insights

Why record_ops vs record_image_ops Matters

  • record_ops compares composite types field-by-field using each field's type-specific equality operator. For numeric, 100 = 100.0.
  • record_image_ops compares the binary/datum representation directly. 100 and 100.0 have different internal representations and are thus unequal.

These two opfamilies define fundamentally different equivalence classes over the same data type, making them a perfect test case for opfamily-mismatch bugs.

The General Pattern

Any optimizer transformation that moves a predicate across a "grouping boundary" (GROUP BY, DISTINCT, PARTITION BY) must verify that the predicate's equality semantics are compatible with the boundary's equality semantics. This is a correctness invariant that PostgreSQL has historically enforced incompletely — the collation check was added in f76686ce7, and the opfamily check is being added now.

Why This Is Architecturally Important

The planner's pushdown optimizations are critical for performance (evaluating predicates early reduces the number of rows flowing through expensive operations like sorting and aggregation). But each pushdown transformation has semantic preconditions. As PostgreSQL supports more complex type systems (composite types, nondeterministic collations, custom opfamilies), the space of potential semantic mismatches grows, making these safety checks increasingly important.