## Linked issue
n/a — no Linear ticket; second of three PRs for cross-namespace
inheritance (first: #30577). The third PR re-keys contract `variants` by
discriminator value and adds multi-table-inheritance (MTI) variants in
other namespaces, which needs two tables with the same name in one
statement.
## At a glance
From
[packages/3-extensions/sql-orm-client/test/include-table-aliases.test.ts](packages/3-extensions/sql-orm-client/test/include-table-aliases.test.ts):
```ts
collection
.select('id')
.include('invitedUsers', (invited) =>
invited.select('id').where((user) => user.name.eq('Bob')),
)
```
```sql
SELECT "users"."id" AS "id",
(SELECT coalesce(json_agg(json_build_object('id', "invitedUsers__rows"."id")), json_build_array()) AS "invitedUsers"
FROM (SELECT "users_2"."id" AS "id"
FROM "public"."users" AS "users_2"
WHERE ("users_2"."invited_by_id" = "users"."id" AND "users_2"."name" = $1)) AS "invitedUsers__rows") AS "invitedUsers"
FROM "public"."users"
```
Before this PR the included table was aliased `invitedUsers__child`, and
the refinement's filter was written against `users` and then renamed by
a string rewrite of the finished expression.
## Summary
The SQL ORM named the tables of a statement with four unrelated alias
schemes and two string-based reference rewriters, and left several
repeated uses of a table unaliased. This PR replaces them with one table
scope held in collection state, so any table can appear more than once
in a statement under a distinct alias.
## Decision
This PR ships one naming mechanism for every table and derived table the
SQL ORM puts in a statement, in `packages/3-extensions/sql-orm-client`:
1. **A table scope that hands out aliases.**
`TableScope.alias(preferred)` returns the preferred alias on first use
and `<alias>_<n>` (from 2) on later uses, at most 63 bytes.
`aliasTable(storage)` returns an `AliasedTable`: the alias with the
table's storage coordinate, which produces column references and table
sources. The first use of a table gets an alias equal to its own name,
and no `AS` is written.
2. **Collection state holds the scope and the aliased tables.**
`CollectionState.tables` has the scope, the root table and one entry per
MTI variant table. Every planner, the model accessor, MTI joins, the row
decoder, MTI create and row locks take table sources and column
references from them.
3. **The old aliases and rewriters are removed.** `__orm_rel_<n>`,
`__orm_junction_<n>`, `<relation>__child`, `<table>__write_filter`, both
`createTableRefRemapper` copies and `resolveChildTableSource` no longer
exist.
4. **A bug fix that follows from it.** A relation declared on a variant
that targets its own hierarchy now returns correct rows.
5. **A changed exported surface.** `emptyState(tables)`, the required
`CollectionState.tables`, the `createModelAccessor` signature, the
optional `IncludeExpr.junction`, `IncludeExpr.localVariantName` in place
of `localTableName`, and new exports `createCollectionTables`,
`CollectionTables`, `AliasedTable`, `TableScope`,
`TableStorageCoordinate`.
## Notes for the reviewer
- **SQL alias text changes in cases that had no alias before.** Two
sibling relation filters over one table; names accumulating across
separate `where()` calls on one chain; an include that returns to an
ancestor's table (users → posts → author); a relation filter plus an
include of the same table; a derived-table name needed twice, which gets
`_<n>`. Names follow call order, so `where(...).include(...)` and
`include(...).where(...)` name the two uses in opposite order.
- **A relation call that renders no SQL still takes a name.**
`every({})` returns a constant-true expression, but the related table is
bound when the call runs, so the next use of that table is numbered one
higher. No test asserts this.
- **Count-mutation subquery.** In `updateAndCount` / `deleteAndCount` on
an MTI variant, the copy of the root table inside the `EXISTS` subquery
takes a fresh name and the stored filters reference the mutation target
directly, because neither renderer can alias an `UPDATE` / `DELETE`
target. See `buildCountMutationWhere` in
[packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts](packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts).
- **Values that were inline SQL literals are now parameters.** Shorthand
object filters, the discriminator filter and the filters nested writes
build are created as parameters with the column's codec. In the mutation
paths that never bound their filters this changes the SQL from a literal
to `$n`: `updateAndCount` / `deleteAndCount` on a variant collection,
the identity `UPDATE` of a nested update, child-owned `connect` /
`disconnect`, and the junction `DELETE` of a many-to-many `disconnect`.
The planner no longer re-binds stored filters, so a literal in a
hand-built `CollectionState.filters` is rendered as written.
- **The 63-byte limit is a constant in the scope** (`MAX_NAME_BYTES` in
[packages/3-extensions/sql-orm-client/src/table-scope.ts](packages/3-extensions/sql-orm-client/src/table-scope.ts)),
not an adapter capability. Only table and derived-table aliases are
bounded; projected column labels are not (see Follow-ups).
- **Largest diffs.**
[packages/3-extensions/sql-orm-client/src/query-plan-select.ts](packages/3-extensions/sql-orm-client/src/query-plan-select.ts)
(the include planner loses its remapper and the child-table-name
parameters) and
[packages/3-extensions/sql-orm-client/src/model-accessor.ts](packages/3-extensions/sql-orm-client/src/model-accessor.ts).
Most test changes are alias-text assertions and the new
`createModelAccessor` / `emptyState` arguments.
## How it fits together
1. **Codecs stop reading table names out of column references.** Filters
the ORM builds itself (shorthand object filters, cursor boundaries,
identity filters, the discriminator filter, nested-write filters) used
to find a parameter codec by treating `ColumnRef.table` as a storage
table name, which fails once that string is an alias. Each is now built
as a parameter with its codec, from the table's storage coordinate. The
binder serves only expressions passed to `where()`, and resolves an
alias through a map built from the collection's aliased tables. See
[packages/3-extensions/sql-orm-client/src/where-binding.ts](packages/3-extensions/sql-orm-client/src/where-binding.ts).
2. **The scope.**
[packages/3-extensions/sql-orm-client/src/table-scope.ts](packages/3-extensions/sql-orm-client/src/table-scope.ts)
holds a set of taken aliases: it does not read the contract and knows
nothing of relations, includes or variants. Its methods are `alias`,
`aliasTable` and `copy`. The model accessor aliases relation targets and
junction tables through it.
3. **The scope moves into collection state.**
[packages/3-extensions/sql-orm-client/src/collection-tables.ts](packages/3-extensions/sql-orm-client/src/collection-tables.ts)
aliases a model's root table and its MTI variant tables
(`createCollectionTables`). A collection's scope is never mutated:
`where()`, `orderBy()`, grouped `orderBy()` and the planners allocate on
a copy, and the copy is stored in the state they produce. Two
collections derived from one parent therefore get the same names for the
same calls and do not see each other's names.
4. **Includes take their aliases from the parent's scope.** `include()`
aliases the child's root, variant and junction tables in a copy of the
parent's scope, runs the refinement, and the parent adopts the resulting
scope so a later sibling cannot take the same name. `combine` branches
share the child's alias. The parent adopts the scope of the collection
the refinement was handed, so an alias a branch takes for itself stays
within that branch and a later sibling may reuse it. The six
derived-table names of the include planner (`<relation>__rows`,
`__distinct`, `__ranked`, `__scalar`, `__scalar_distinct`,
`__combine__<branch>`) are requested from the scope.
5. **No reference is renamed by table name.** Both rewriters are gone
and nothing replaces them. An expression passed to `where()` is used as
written.
6. **Database tests, docs and upgrade instructions.** A new PSL fixture
with a polymorphic model related to its own hierarchy, a README section
on the tables in collection state, and a pending upgrade fragment for
extension authors.
## Behavior changes & evidence
- **A variant-declared relation that targets its own hierarchy returns
correct rows.** The child's variant join was unaliased and captured the
parent correlation; the child's variant table now has its own alias.
- Implementation:
[packages/3-extensions/sql-orm-client/src/collection-tables.ts](packages/3-extensions/sql-orm-client/src/collection-tables.ts),
[packages/3-extensions/sql-orm-client/src/query-plan-source.ts](packages/3-extensions/sql-orm-client/src/query-plan-source.ts)
- Evidence:
[test/integration/test/sql-orm-client/polymorphism-self-relation.test.ts](test/integration/test/sql-orm-client/polymorphism-self-relation.test.ts),
fixture
[test/integration/test/sql-orm-client/fixtures/polymorphism-self-relation/contract.prisma](test/integration/test/sql-orm-client/fixtures/polymorphism-self-relation/contract.prisma)
- **Relation filters and relation orders name their tables `<table>`,
`<table>_2`, …** in place of `__orm_rel_<n>` / `__orm_junction_<n>`, and
two filters over one table get separate aliases.
- Implementation:
[packages/3-extensions/sql-orm-client/src/model-accessor.ts](packages/3-extensions/sql-orm-client/src/model-accessor.ts)
- Evidence:
[packages/3-extensions/sql-orm-client/test/relation-filter-aliases.test.ts](packages/3-extensions/sql-orm-client/test/relation-filter-aliases.test.ts),
[packages/3-extensions/sql-orm-client/test/table-scope.test.ts](packages/3-extensions/sql-orm-client/test/table-scope.test.ts)
- **Includes name their tables from the parent's scope** in place of
`<relation>__child`, including an include that returns to an ancestor's
table and sibling includes of one table.
- Implementation:
[packages/3-extensions/sql-orm-client/src/collection.ts](packages/3-extensions/sql-orm-client/src/collection.ts),
[packages/3-extensions/sql-orm-client/src/query-plan-select.ts](packages/3-extensions/sql-orm-client/src/query-plan-select.ts)
- Evidence:
[packages/3-extensions/sql-orm-client/test/include-table-aliases.test.ts](packages/3-extensions/sql-orm-client/test/include-table-aliases.test.ts),
[test/integration/test/sql-orm-client/self-relations.test.ts](test/integration/test/sql-orm-client/self-relations.test.ts)
- **Count mutations on an MTI variant name the subquery's table copy
`<table>_<n>`** in place of `<table>__write_filter`.
- Implementation:
[packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts](packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts)
- Evidence:
[packages/3-extensions/sql-orm-client/test/collection-tables.test.ts](packages/3-extensions/sql-orm-client/test/collection-tables.test.ts),
[test/integration/test/sql-orm-client/polymorphism-self-relation.test.ts](test/integration/test/sql-orm-client/polymorphism-self-relation.test.ts)
- **Filters the ORM builds are parameters with the column's codec**,
including in mutation statements that used to render the value inline,
and a literal in an expression passed to `where()` binds there,
including on an MTI variant column.
- Implementation:
[packages/3-extensions/sql-orm-client/src/where-binding.ts](packages/3-extensions/sql-orm-client/src/where-binding.ts)
- Evidence:
[packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts](packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts),
[packages/3-extensions/sql-orm-client/test/collection-tables.test.ts](packages/3-extensions/sql-orm-client/test/collection-tables.test.ts),
[test/integration/test/sql-orm-client/polymorphism-self-relation.test.ts](test/integration/test/sql-orm-client/polymorphism-self-relation.test.ts)
## Compatibility / migration / risk
- **No contract, emitter, adapter or Mongo change.** The shared SQL AST
is unchanged; a reference is still a plain string in `ColumnRef.table`.
- **Query results are unchanged** apart from the fix above. Generated
SQL text changes (aliases, and some inline values becoming parameters).
- **Exported surface** (re-exported by
`@prisma/orm-family-sql/orm-client`): code that builds
`CollectionState`, calls `emptyState()` or `createModelAccessor()`, or
writes a many-to-many `IncludeExpr` by hand must change.
- **Upgrade instructions** are pending for extension authors, covering
the changed state shape only:
[upgrade-instructions/pending/collection-state-tables/extension/instructions.md](upgrade-instructions/pending/collection-state-tables/extension/instructions.md).
Generated SQL text is not public API and has no upgrade entry.
## Testing performed
On the current branch:
- `sql-orm-client` package: lint, typecheck and test (120 files, 1529
tests)
- Integration: typecheck, `test/sql-orm-client` (117 files, 822 tests)
and the planner golden
- `pnpm lint:deps`
- `pnpm fixtures:check`
- `pnpm check:upgrade-coverage`
The gates above ran after merging the latest `main` into the branch. The
full `pnpm test:packages` and `pnpm test:integration` last ran before
the rebases onto rc.16 and rc.17. Their only failures were
local-environment ones: the Prisma 7 engine download returning 404 on
NixOS, the `ERR_PNPM_TRUST_DOWNGRADE` tarball tests, and broken symlinks
in the local `node_modules`. CI is the first full run on the rebased
branch.
## Skill update
No skill update is required, and the diff touches no skill. The change
is an internal naming mechanism. No public query API changes; an
extension author who builds ORM state by hand sees the changed state
shape, which the upgrade fragment and the README section cover.
## Follow-ups
Known gaps this PR does not change:
- `updateAll` / `deleteAll` with a filter on an MTI variant field: the
filter reaches a base-table statement that does not join the variant
table.
- Cursor pagination ordered by an MTI variant field: the cursor column
is bound to the base table.
- The value a grouped `having` comparison is checked against is still an
inline literal, because the grouped planner rejects a parameter there.
- Base relations are not typed on a variant collection:
`tasks.variant('feature').include('subtasks')` is a type error although
it works at runtime.
- Projected column labels (`<alias>__<column>`) are not bounded to 63
bytes.
## Alternatives considered
- **Name tables in a pass at statement assembly.** References would have
to stay unresolved until the statement is complete, which means a change
to the shared SQL AST or a rename of stored references. Naming a table
when it is bound keeps `ColumnRef.table` a plain string and leaves
callback timing and error timing as they are.
- **A unique reference per `combine` branch.** The branches are sibling
subqueries that cannot see each other, so one shared reference is valid
SQL. Unique names would require renaming references already stored in
each branch's state.
- **Keep one rename for expressions passed to `where()` on an aliased
child.** On `main`, the rewriter moved such an expression's references
to `<relation>__child`. Keeping that would leave one string rename in
the package for a case no code in the repo uses, so the expression is
used as written; the extension upgrade note says so.
- **Keep shorthand and discriminator filters as literals and bind them
at plan time.** That leaves the SQL of the unbound mutation paths
unchanged, but those paths write the value into the SQL text without the
column's codec. Building the parameter where the filter is built fixes
that and removes plan-time re-binding.
## Checklist
- [x] All commits are signed off (`git commit -s`) per the
[DCO](../CONTRIBUTING.md#developer-certificate-of-origin-dco). The DCO
status check will block merge if any commit is missing a
`Signed-off-by:` trailer.
- [x] I read [CONTRIBUTING.md](../CONTRIBUTING.md) and the change is
scoped to one logical concern.
- [x] Tests are updated (or `n/a` if the change is doc-only / refactor
with no behavioural delta).
- [ ] The PR title is in `TML-NNNN: <sentence-case title>` form (Linear
ticket prefix + concise title naming the concrete deliverable). See
`.claude/skills/create-pr/SKILL.md` for the full convention. — Not
ticked: no Linear ticket exists for this work, so the title has no
`TML-` prefix.
- [x] The **Skill update** section above is filled in (or stated `n/a —
internal only`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Steven McClankerton <tatarintsev@prisma.io>
Co-authored-by: Steven McClankerton <tatarintsev@prisma.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>