027. Reuse bounded equality in PostgreSQL membership

  • Status: Applied
  • Date: 2026-10-14

Problem and mechanism

PostgreSQL JSON membership helpers could normalize bounded dynamic values into recursive JSON documents before comparing them. For schema-bounded List, Map, and Struct alternatives, the translator can instead use the existing schema-guided recursive equality after the existing schema representability checks and JSONB metadata requirements. This removes whole-value normalization and JSON aggregate construction while retaining ordinal list comparison, actual object keys, nested-null and missing-key behavior, callback scopes, and ordering.

The optimization does not expand supported stored types or tagged unions. Any at the root keeps the generic path, and nested Any remains generic at that leaf. JSONB numeric metadata and existing rejection behavior remain in force. In particular, a JSONB/non-numeric comparisonDocumentAgainst raw-number bypass declines this specialization. Representation validation failures fall back only for typed backend errors; cancellation and unexpected errors propagate.

Applicability

Membership shape Status Reason
Bounded structural candidates Applied Reuses typed equality after schema checks.
Bounded equality inside a callback Applied Preserves callback scope and removes document construction.
Any root Not applied Generic equality remains at the root.
Bounded container with an Any leaf Partial Generic equality remains at that unconstrained leaf.
JSONB raw-number comparison against a nonnumeric candidate Not applied Retains the existing bypass without introducing null matches.
JSONB without required numeric metadata Not applied Existing checks and rejections remain.
Simple String|List<Int> control Not applied It already avoided recursive normalization.

Reproduction

The baseline is commit e0e5d77. Use Go 1.27.1 and PostgreSQL 18.6 with extra_float_digits=1. The durable fixtures are the schema membership unit tests and PostgreSQL/local integration tests.

The SQL-shape profiles use a records table with JSON columns value, needle, nullable items, and groups. The value and needle fields use the same compiled type in each profile; items is a nullable list of that type, and groups is a list of lists of that type. The measured profiles and queries are:

Profile HFF value type Query
list_in String|List<Int>|List<Float> value:in(needle)
list_contains String|List<Int>|List<Float> items:contains(needle)
list_unique String|List<Int>|List<Float> items:unique
struct_contains String|Struct{i:Int,f?:Float}|Struct{i:Int,label?:String} items:contains(needle)
map_struct_unique String|Map<Int>|Struct{fixed:Float} items:unique
nested_capture String|List<Int>|List<Float> groups:any(xs=>xs:contains(needle))
any_control Any value:in(needle)
simple_control String|List<Int> items:contains(needle)

Here ? marks an optional field, not a nullable field. The shape profiles bind these fields as native JSON. The parity fixtures also exercise JSONB with numeric type metadata where required, JSON null and SQL NULL, and both sides of supported and rejected representation boundaries. Plain EXPLAIN uses an empty temporary table with those JSON columns; it is plan-shape inspection only and does not use ANALYZE.

Results

Each WHERE size is bytes. Counts are OFFSET 0 barriers, recursive CTEs, and JSON aggregate calls (json_agg plus json_object_agg), respectively.

Profile Baseline bytes / barriers / CTEs / aggregates Candidate bytes / barriers / CTEs / aggregates
list_in 8,869 / 16 / 1 / 2 5,337 / 18 / 0 / 0
list_contains 8,984 / 16 / 1 / 2 5,453 / 18 / 0 / 0
list_unique 8,932 / 14 / 1 / 2 5,416 / 16 / 0 / 0
struct_contains 9,936 / 16 / 1 / 2 4,188 / 8 / 0 / 0
map_struct_unique 9,486 / 14 / 1 / 2 6,040 / 16 / 0 / 0
nested_capture 9,544 / 20 / 1 / 2 5,986 / 22 / 0 / 0
any_control 4,972 / 16 / 1 / 0 4,972 / 16 / 1 / 0
simple_control 2,250 / 6 / 0 / 0 2,250 / 6 / 0 / 0

The barrier count increases in several optimized profiles; the change removes recursive document construction rather than reducing every SQL-shape count. For the first six profiles, plain EXPLAIN plan lines / Nested Loop nodes / Recursive Union nodes changed, respectively, from 65/16/2 to 45/15/0, 68/16/2 to 48/15/0, 66/15/2 to 46/14/0, 68/16/2 to 28/6/0, 66/15/2 to 46/14/0, and 80/20/2 to 60/19/0. The two controls remained 59/16/2 and 24/5/0. These are fixture-specific plan observations, not timing or speed claims.

Correctness and tradeoffs

The focused PostgreSQL/local suite passed 3,888 comparisons on both the pinned baseline and candidate, plus 12 native-JSON Float-failure checks and 24 asymmetric JSON/JSONB raw-number checks. Full unit tests, go vet ./..., integration-tagged SQL vet, affected SQL race tests, and callback-scope tests passed. Existing schema-pair, dynamic-contains, no-numeric-metadata, metadata-alias, guard, JSON parity, Bytes, Duration, and native-Float controls also passed. These results establish semantic parity, not execution speed.

The direct path still uses schema-guided recursive equality for reachable containers. It does not remove upstream normalization of an entire receiver before a membership helper; that is a separate optimization question. The number of projection barriers rises in some profiles, so SQL size and plan shape should be read together and not treated as runtime measurements.

Decision and limits

Reuse bounded structural equality for membership only after existing representability checks pass. Keep generic equality for Any, preserve metadata and cancellation behavior, and retain the prior fallback for typed backend errors. Do not infer broader union support, remove whole-receiver normalization, or claim execution gains from these SQL and plan observations.

Explore HFF
Search documentation

Search the language specification and guides.