Status: first slice built (issue #79; docs/relations.md). one and many relations over
single-column keys, with to any depth, where and orderBy per level, limit and offset on
the top level. Relation filters and counts (issue #80) are in section 6. Section 5 lists what is
not built.
Read a row with its related rows in one call, as Drizzle's relational queries
(db.query.users.findMany({ with: { posts: true } })) and Prisma's include do. Keep what the
rest of the library gives a query:
- rows decoded through the column codecs, at every level;
- a tenant scope derived for every statement, which
requireTenantScopechecks; - a result type derived from the
withtree.
const schema = Db.defineRelations({ users: Users, posts: Posts }, (r) => ({
users: { posts: r.many("posts", { from: "id", to: "authorId" }) },
posts: { author: r.one("users", { from: "authorId", to: "id" }) },
}))
Db.findMany(schema.users, { where: ($) => [$.orgId.eq(orgId)], with: { posts: { with: { author: true } } } })- One callback over all tables, as Drizzle v2
defineRelations. A relation names its target by key, not by value, so tables that point at each other need no thunks. - Relations live beside the tables, not in them.
table()stays as it is, and the DDL, the diff and the importers do not change. A Prisma importer can write thedefineRelationscall. findManylives in/database. It runs more than one statement, so it needs theDatabaseservice.defineRelationssits next to it, so the dialect entries do not change.- Checked in the types. The target must be a table key,
toa column of the target,froma column of the source, and both must decode tostring | number | bigint. A relation name must not be a column name.
The issue proposed one statement with JSON aggregation (jsonb_agg / json_object), as Drizzle
RQB v2 does. This slice runs one statement per level instead:
SELECT <all columns> FROM <target> WHERE <to> IN (<keys from the level above>) [AND <tenant pin>] [AND <where>] [ORDER BY ...]. The loader then groups the rows by key in memory. Prisma does the
same by default.
Why:
- Decoding stays correct for free. Each level is an ordinary builder query, decoded by its
own derived row codec. With JSON aggregation, every column comes back in its JSON form, not
its wire form. An
int8becomes a JSON number and loses precision past 2^53, atimestamptzbecomes an ISO string in a different format,byteabecomes hex, and anumericcan change. Each column type would need a second "from JSON" codec per dialect, or a cast to text in the SQL. That is a large change to the type system for one feature. - Tenant scope needs no new analysis. Each level is a query the existing analysis already
understands. A nested JSON subquery would need correlated-subquery evidence through
jsonb_agg, which the analysis does not have yet. - Every dialect. The SQL is only
IN (...), so the same code runs on Postgres, on ClickHouse, SQLite and D1, and later on MySQL. JSON aggregation needs a different function set per dialect, and ClickHouse has nojsonb_agg. - The statement count is fixed. It is the depth of the
withtree, not the row count, so there is no N+1. The one exception is a level whose keys pass the dialect's bound-value limit (see "LongINlists" below).
The costs:
- More round trips. Three levels are three statements. On a server close to the app this is small. JSON aggregation is better on a slow link.
- No single snapshot. Outside a transaction, a write can land between two levels. The docs
say to use
Db.transaction({ isolationLevel: "repeatable read" }).findManydoes not open a transaction itself: ClickHouse and D1 have none, and the caller may already be in one. - Long
INlists. A level's keys are split into batches that fit the dialect'smaxParametersnext to the level's other bindings (D1 allows 100), one statement per batch. Each parent key is in one batch, so the rows of one parent and their order do not change.
JSON aggregation can come later as a second strategy behind the same API, per dialect, once the column types have JSON codecs. The result type and the tenant rule would not change.
Each level goes through Database.run, so it is checked like any other statement. A nested
level filters on the parent keys, which says nothing about the tenant. So the loader adds a pin:
When the parent table and the target table both declare a
tenantColumn, and every parent row carries the same tenant valueV, the nested query also gets<target tenant column> = V.
This is sound. The pin is an ordinary eq on a literal, so the existing analysis proves
"single-tenant". V comes from rows the parent query read, so a level never reads a tenant its
parent did not read. The pin also hides bad data: a child row whose tenant differs from its
parent's.
No pin when the parent table is untenanted, or the parent rows carry more than one tenant. The
level is then "cross-tenant", and requireTenantScope refuses it, which is the safe default. A
level whose to column is the target's tenant column, with one key, is scoped by the one-value
IN already. crossTenant: true declares every level .crossTenant().
The pin does not pass through an untenanted level: a grandparent's tenant says nothing about a grandchild reached through a shared table.
| What | Why not now |
|---|---|
| JSON aggregation strategy | Needs JSON codecs per column type (section 3) |
columns per level |
Small, but changes the row type of every level; next slice |
limit per parent on a nested level |
Needs row_number() over a partition, or a lateral join |
| Composite keys | The loader would match tuples; no issue asks for it yet |
findFirst |
findMany with limit: 1; cardinality helpers are #69 |
_count of a one relation |
r.author.some() says the same; no call site needs a 0/1 count |
Relation filters in update / delete |
Their accessor is not qualified, so there is no alias to correlate on; refused |
orderBy a relation count |
Select count() on a builder query and order by its alias |
Params in nested where |
findMany takes no params; values go in as literals |
| MySQL | Not on main yet; the SQL here is plain IN, EXISTS and count(*), so no dialect work is expected |
Db.findMany(schema.users, {
where: ($, r) => [$.orgId.eq(orgId), r.posts.some(($) => [$.published.eq(false)])],
_count: { posts: true },
})
PG.from(Users).select(($) => ({ id: $.id, posts: Db.related(schema.users, $).posts.count() }))- Filters are correlated subqueries.
someisEXISTS,noneisNOT EXISTS,everyisNOT EXISTS (… AND NOT (where)), as Prisma compiles them. A filter is a condition inside one statement, so it cannot be a separate level: the outer rows it filters do not exist yet. The subquery reads the target under the alias<outer alias>__<relation>, which differs from every enclosing alias, so a self-relation needs nothing special. Db.related(schema.table, $)is the one entry point. It builds the filters from the$of any select over the table, so the builder andfindManyshare one implementation.findManypasses it as the second argument of everywhere._countinfindManyis a grouped level, not a subquery.SELECT <to>, count(*) … WHERE <to> IN (<keys>) GROUP BY <to>, with the same tenant pin as a loaded level. This keeps #79's model: no correlation, so it runs on every dialect and every ClickHouse version, and the count decodes through one codec.r.posts.count()is the correlated form, for builder queries, wrapped incoalesce(…, 0)because ClickHouse returns NULL for a correlated count over no rows.
A subquery's scope counts as one more source of the outer query (docs/tenant-scoping.md), so a
subquery that pins no tenant made every filter "cross-tenant". Writing the tenant value into
the subquery is not possible: the filter is built before the outer where is known, and the
value can be a param.
So when both tables declare a tenant column, the filter writes
<inner tenant> = <outer tenant> into the subquery and marks the subquery with the outer column
(markCorrelatedTenant, internal). Compiled as a subquery of the outer query, the marked query's
main source starts bound to a token for that outer column. If the subquery is single-tenant on
that token, the outer compile counts it as a source on the outer tenant column, which the outer
where binds like any other. Nested filters chain: the inner filter's token resolves against the
middle subquery's own main column.
This is sound because only the filters set the mark, and they always write the equality the mark
claims. A subquery whose own where pins a literal tenant is bound to that literal instead, and
the outer query must pin the same one. A marked query compiled alone ignores the mark. The write
paths (insert, update, delete) do not read the mark, so a marked subquery there stays
"cross-tenant".
Correlated subqueries run on ClickHouse 25 and later. On 26.8 (CI): EXISTS / NOT EXISTS,
nested, and a correlated count in WHERE work. A correlated scalar subquery in SELECT next to a
WHERE fails with NOT_IMPLEMENTED unless the correlated outer columns are also selected. The
docs say to select them, or to use _count, which needs no correlation.