024. Specialize reachable PostgreSQL JSON container equality

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

Problem and mechanism

For root equality and inequality (= and !=) between two JSON-backed values with finite compiled root unions containing Lists, Maps, or Structs, PostgreSQL could normalize both complete values through recursive JSON walks before comparing them. For bounded schemas, the translator can instead use the compiled types at each stored position and compare only the container categories and value families that can occur there.

Lists are compared by ordinal position, with a full join detecting unequal lengths. Objects are compared by their actual keys with a full join, retaining Map/Struct cross-shape key-set behavior. Struct fields keep their individual types; Map fallback values use their merged per-key type where available, so different fields such as an Int and a Float are not combined into one ambiguous value type. A nested Any value retains the generic recursive equality path at that position.

Stored-value representability checks and JSONB metadata requirements remain in force before specialization; consistency between stored values and sidecar metadata remains the caller's responsibility. Nested Float normalization failures still contribute the same null state as the general path. This specialization covers root = and !=; in(), contains(), and unique() still use their existing equality paths. The optimization does not expand support for Bytes, Duration, or Timestamp inside generic JSON containers.

Applicability

Workload Applied / Partial / Not applied Reason
Bounded String/List unions with known element families Applied Ordinal equality specializes the element comparison and omits whole-document recursive normalization.
Bounded Struct unions with overlapping fields Applied Known fields keep per-key types while actual object keys are compared.
Map/Struct comparisons with per-key Map fallback Applied The fallback remains per key; distinct typed Struct fields are not merged together.
Overlapping List unions with metadata-backed Float values Partial Equality specializes known positions, but two recursive metadata reconstructions remain.
Nested Any fields with metadata Partial Known fields specialize; unconstrained Any leaves retain generic recursion.
Unconstrained Any at an equality boundary Not applied Full dynamic recursive equality remains necessary.

Reproduction

The baseline is commit 1ec85ae. Each shape fixture compiles left=right and binds both values to JSONB columns; the metadata column is added where marked:

Fixture Left schema Right schema Float metadata
string_list_int String|List<Int> String|List<Int> No
string_list_int_float String|List<Int> String|List<Float> No
string_map_struct String|Map<Int> String|Struct{a:Int?} No
overlap_struct String|Struct{i:Int,f:Float?}|Struct{i:Int,label:String?} same No
overlap_list String|List<Int>|List<Float> same Yes
nested_any String|Struct{payload:Any,id:Int} same Yes

In these Struct schemas, ? marks an optional field that may be omitted; it does not make the field nullable.

For example, the first profile's SQL shape can be rebuilt with this small program:

package main

import (
	"context"
	"fmt"
	"strings"

	"github.com/ornew/hff"
	hffsql "github.com/ornew/hff/backends/sql"
	"github.com/ornew/hff/capabilities"
	"github.com/ornew/hff/engine"
	"github.com/ornew/hff/schema"
)

func main() {
	ctx := context.Background()
	str := schema.StringType
	leftType := schema.Union(str, schema.ListOf(schema.IntType))
	rightType := schema.Union(str, schema.ListOf(schema.IntType))
	e, err := engine.New(engine.WithSchema(schema.Struct(
		schema.Field("left", leftType),
		schema.Field("right", rightType),
	)), engine.WithCapabilities(capabilities.Standard()))
	if err != nil {
		panic(err)
	}
	p, err := e.Compile(ctx, `left=right`)
	if err != nil {
		panic(err)
	}
	q, err := hffsql.Build(ctx, p,
		hffsql.WithDialect(hffsql.PostgreSQL),
		hffsql.WithBindings(
			hffsql.BindJSON(hff.Path{"left"}, hffsql.Column("records", "left"), hffsql.JSONB),
			hffsql.BindJSON(hff.Path{"right"}, hffsql.Column("records", "right"), hffsql.JSONB),
		),
	)
	if err != nil {
		panic(err)
	}
	fmt.Printf("WHERE bytes=%d barriers=%d recursive=%d args=%d\n",
		len(q.Where()), strings.Count(q.Where(), "OFFSET 0"),
		strings.Count(q.Where(), "WITH RECURSIVE"), len(q.Args()))
}

The SQL-shape test source is backends/sql/schema_pair_test.go; the baseline and candidate shape outputs are retained outside Git at /private/tmp/hff-container-pair-{baseline,candidate}.shape. The shape counts below report WHERE bytes, OFFSET 0 barriers, recursive JSON walks, and SQL parameters, in that order.

The focused PostgreSQL/local tests are in backends/sql/schema_pair_test.go and backends/sql/schema_pair_integration_test.go:

GOTOOLCHAIN=go1.27.1 go test ./backends/sql \
  -run '^TestSchemaPair(UsesBoundedContainerEquality|KeepsAnyLeafFallback|DoesNotMergeUnrelatedNumericFields)$' \
  -count=1
HFF_POSTGRES_CONTAINER=<container> GOTOOLCHAIN=go1.27.1 go test -tags=integration ./backends/sql \
  -run '^TestPostgreSQLSchemaPair(MatchesLocal|FloatFailureIsNestedNull)$' \
  -count=1
GOTOOLCHAIN=go1.27.1 go test ./...
GOTOOLCHAIN=go1.27.1 go vet ./...

The new focused integration cases covered 656 JSON/JSONB pair comparisons and 12 Float-failure comparisons. Existing dynamic-pair, metadata, physical spelling, and Bytes-tagged controls also passed. These checks establish semantic parity, not runtime speed.

For plan-shape inspection, the String|List<Int> profile used a temporary table with JSONB left and right columns and these five rows: ('"x"','"x"'), ('[1,2]','[1,2]'), ('[1]','[2]'), ('[]','[]'), and (NULL,NULL). The query was EXPLAIN (COSTS OFF) SELECT 1 FROM records WHERE <generated WHERE>; it did not use ANALYZE. The baseline plan had 69 lines and 18 Nested Loop nodes; the candidate had 21 lines and 5 Nested Loop nodes. Both plans retained two jsonb_array_elements scans. The plan script and captures are retained outside Git at /private/tmp/hff-schema-pair-explain.go and /private/tmp/hff-schema-pair-{baseline,candidate}.plan. These are plan-shape observations only; no execution-time measurement was collected.

Results

Shape Baseline WHERE bytes / barriers / recursive walks / args Candidate bytes / barriers / recursive walks / args
String/List 6,611 / 18 / 1 / 0 2,044 / 6 / 0 / 0
String/List vs String/List 7,784 / 18 / 1 / 0 3,335 / 13 / 0 / 0
String/Map/Struct 6,748 / 18 / 1 / 1 2,101 / 6 / 0 / 1
Overlapping Struct alternatives 10,359 / 18 / 1 / 6 3,959 / 8 / 0 / 3
Overlapping List alternatives with metadata 12,857 / 18 / 3 / 0 8,634 / 18 / 2 / 0
Nested Any with metadata 10,573 / 18 / 3 / 4 9,920 / 16 / 3 / 2

These are generated SQL-shape comparisons. No timing, benchmark, or execution speed claim is made.

Correctness and tradeoffs

Parity coverage includes ordered list comparisons, empty and unequal-length lists, Map/Struct cross-shape keys, missing versus stored null, null roots, null/no-value observations, nested Any leaves, metadata-backed Float values, and Float normalization failures that become nested nulls. Tests also ensure that an Int field and a Float field at different Struct keys remain independently typed. Schema representability and metadata requirements remain in force. Stored document and sidecar consistency is a caller contract, not a row validation performed by this path.

Some recursive work remains where a stored position is Any, where multiple overlapping alternatives require metadata restoration, or where the general path is required. Reductions in generated SQL do not establish execution speed or a PostgreSQL plan for other schemas or data.

Decision and limits

Use bounded recursive equality for reachable JSON container positions while retaining generic equality at unconstrained values. Preserve the existing normalization, metadata, key-set, order, and null contracts. Do not extend the result to unsupported container value types or infer runtime gains from SQL size alone.

Explore HFF
Search documentation

Search the language specification and guides.