Skip to content

DDL support for Indexes over unnestedRecordTypes - #4467

Open
g31pranjal wants to merge 14 commits into
FoundationDB:mainfrom
g31pranjal:syn_relational
Open

DDL support for Indexes over unnestedRecordTypes#4467
g31pranjal wants to merge 14 commits into
FoundationDB:mainfrom
g31pranjal:syn_relational

Conversation

@g31pranjal

@g31pranjal g31pranjal commented Aug 17, 2026

Copy link
Copy Markdown
Member

Motivation

CREATE INDEX ... AS SELECT and CREATE INDEX ... ON <view> can already index an unnesting of an array, by emitting a fan-out key expression on the stored table:

CREATE INDEX mv1 AS SELECT SQ.x, t.p
  FROM T AS t, (SELECT x FROM t.a) SQ ORDER BY SQ.x, t.p
concat( field("A").nest(field("values", FanOut).nest("X")), field("P") )

That works for most shapes, but there is a class of index it simply cannot express, which was previously rejected outright:

Index with multiple disconnected references to the same column are not supported

This PR defines those indexes on an UnnestedRecordType instead, where each unnested array becomes a nested constituent and the key expression navigates from a constituent alias. A constituent is navigated with FanType.None and holds exactly one array element per synthetic record, so it can be referenced at any number of key positions.

The rule: a synthetic type is created only when a fan-out cannot express the index

A fan-out is fine as long as every column read through one unnesting sits in a contiguous run of the index key — those columns are emitted under a single navigation into the array and yield one index entry per element.

A synthetic type is required iff two or more columns reached through the same unnesting are non-adjacent in the index key.

Shape Example Representation
One column per element SELECT SQ.x, t.p fan-out on stored table
Several adjacent columns of one element T1.col5, X.col3, X.col4 fan-out (merged into one navigation)
Two independent unnestings FROM T1, T1.A X, T1.A YX.col3, Y.col4 fan-out
Chained, columns adjacent b.x, c.y fan-out
Same unnesting, split X.col2, T1.col5, X.col3 synthetic type
Chained, split b.x, a.k, c.y synthetic type

Two notes on rows 3 and 4, since both look like they might need correlation but do not:

  • Two independent unnestings each get their own fan-out, producing an n² cross-product. That is the intended meaning — FROM T1, T1.A X, T1.A Y is a cross join and no correlation between X and Y is wanted. A synthetic type would produce the same n² via two constituents, so it adds nothing.
  • Chained but adjacent columns share a single outer navigation, so y is already correlated to the element that supplied x — one entry per (p, q) pair.

Because of this, the discriminator is the unnesting, not the array field: X and Y above target the same field A yet are distinct unnestings. Every unnesting a column is read through counts, not just the innermost — under chained unnesting b.x and c.y have different innermost unnestings but both traverse the outer one.

What is now supported

Index shapes. Every row of the table above now produces working metadata; the two marked synthetic type are what previously failed. Beyond the split cases, a synthetic type also covers chained unnesting — (SELECT * FROM b.q) c where b is itself unnested — and several independent unnestings in one index. One constituent is registered per unnested struct array, in a deterministic order, and each constituent’s parent is either the stored record or the constituent it is unnested from, so the constituents always form a proper chain. A scalar array never becomes a constituent: it stays a fan-out under whichever constituent owns it, which is what lets struct and scalar arrays be mixed in a single index.

Syntax. All of the above is reachable from either DDL form, CREATE INDEX ... ON <view> and CREATE INDEX ... AS SELECT, with the unnesting written either as a correlated subquery (SELECT x FROM t.a) SQ or as a PartiQL path t.a AS M. The four combinations converge on the same generator and produce identical metadata.

Carried through. Everything the fan-out path already supported still applies on a synthetic type: VALUE and VERSION index types (??), keyWithValue split points, and serialization to RecordMetaData for either array storage form — a plain repeated field, or the nullable { repeated T values; } wrapper, where the constituent is the element type and the nesting expression steps through values.

Left alone. An index that does not need a synthetic type keeps its previous representation; every pre-existing IndexTest expectation for fan-out shapes passes unchanged.

Limitations

Predicates are not supported on an index over a synthetic type. Explicitly left unsupported for now.

Aggregate indexes always use the stored-table fan-out, unchanged.

Future work

Planner support. Nothing yet matches a query against an index defined on a synthetic type, so these indexes are written but never chosen. Needed for both unnested and joined synthetic types.

JoinedRecordType. Joined synthetic types would take the same DDL route as unnested ones. RecordLayerSyntheticTable is sealed and currently has a single subclass, RecordLayerUnnestedSyntheticTable; a joined type would be the second.

Implementation

  • RecordLayerSyntheticTable — sealed base implementing View; a synthetic type is a virtual, SQL-defined, indexed type. RecordLayerUnnestedSyntheticTable is the one subclass.
  • RecordLayerSchemaTemplate keeps synthetic tables in their own set, with addSyntheticTable and name-collision checks shared with tables/views/routines. accept() visits them explicitly, since getViews() returns only plain views.
  • MaterializedViewIndexGenerator decides representation after the key columns and their order are known, then either registers a synthetic type and builds constituent-alias paths, or falls through to the existing fan-out path.
  • A scalar array never needs a constituent: its elements have no fields, so a scalar unnesting contributes at most one key column and can therefore never be the non-adjacent reference that forces correlation. It stays a fan-out, emitted under the constituent that owns the array when the array sits inside an unnested struct element. The serializer keeps a defensive assert for the invariant.
  • A constituent stores the nesting KeyExpression, not an array field name — the record layer persists and evaluates that expression, so keeping it verbatim round-trips any shape, including two-hop paths like map.entry that a name cannot hold. The serializer walks the expression’s field path to find the element descriptor, replacing the wrapped/unwrapped special case.
  • GenerationResultIndexGenerationResult, extracted to its own file. Both index generators and the visitor chain return it, so a synthetic type is always plumbed to a caller that can register it.
  • Explode identity is carried by a marker stamped onto an AnnotatedAccessor, so two unnestings of the same array field stay distinct. Constituent parents are resolved by dereferencing the collection value rather than trusting its raw correlation, which for a chained explode points at the enclosing subquery quantifier rather than at the constituent.

@g31pranjal
g31pranjal marked this pull request as draft August 17, 2026 20:11
@g31pranjal g31pranjal added the enhancement New feature or request label Aug 17, 2026
@g31pranjal
g31pranjal marked this pull request as ready for review August 19, 2026 16:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant