@vantreeseba/drizzle-graphql 7.4.0 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -237,6 +237,96 @@ table becomes readable but not insertable — and warns on the console, since th
237
237
  often a mistake than a plan.
238
238
 
239
239
  > `exclude` is a static decision: the column is absent for every caller. Deciding per
240
+ > request *which fields* a caller may reach is a job for
241
+ > [`graphql-shield`](https://the-guild.dev/graphql/shield) or an equivalent layer above the
242
+ > schema. Deciding per request *which rows* a caller may reach is `scope`, below — that one a
243
+ > layer above cannot do, because a nested relation field never passes through a root resolver.
244
+
245
+ ## Row scoping and multi-tenancy
246
+
247
+ `scope` attaches a predicate, derived from the request context, to every read and write of a
248
+ table:
249
+
250
+ ```Typescript
251
+ import { and, eq } from 'drizzle-orm'
252
+
253
+ const { schema } = buildSchema(db, {
254
+ scope: {
255
+ posts: (ctx, table) => eq(table.orgId, ctx.orgId),
256
+ // A filter object works too — same shape as the generated `where` argument:
257
+ comments: (ctx) => ({ post: { orgId: { eq: ctx.orgId } } }),
258
+ },
259
+ })
260
+ ```
261
+
262
+ Keyed by the **Drizzle schema key**. `ctx` is your GraphQL context value; `table` is the table
263
+ the current statement runs against — for a relational read that is Drizzle's *aliased* copy, so
264
+ always build the predicate from that argument rather than from the imported table object.
265
+
266
+ The predicate is ANDed on **last**, after whatever the client sent, so a client filter can only
267
+ narrow the scope, never widen it. It reaches every path that touches the table:
268
+
269
+ | Path | Effect |
270
+ | --- | --- |
271
+ | `posts`, `postsSingle` | out-of-scope rows are not returned |
272
+ | `postsAggregate`, `postsGroupBy` | counted over in-scope rows only |
273
+ | `user.posts`, `user.postsAggregate` | scoped on both the batched and the eager (`with:`) path |
274
+ | cursor pages | the keyset page is taken within the scope |
275
+ | `updatePosts`, `updatePostsMany`, `deletePosts` | an out-of-scope row is simply not matched |
276
+ | `upsertPosts` (conflict branch) | a conflicting row outside the scope is left alone, not taken over |
277
+ | nested `connect` / `disconnect` / `set` | only in-scope rows can be attached or detached |
278
+
279
+ Two things it deliberately does not do:
280
+
281
+ - **It does not refuse.** A scoped `deletePosts(where: { id: { eq: 4 } })` on another
282
+ tenant's row returns `[]`, the same answer as a row that does not exist. Telling the caller
283
+ "that row exists but is not yours" is the leak the scope is there to close.
284
+ - **It does not apply to a plain insert.** There is no existing row to filter. `contextValues`
285
+ is the write-side half.
286
+
287
+ Returning `undefined` means *no restriction for this context*, so one hook can let an
288
+ administrator through:
289
+
290
+ ```Typescript
291
+ scope: {
292
+ posts: (ctx, table) => (ctx.isAdmin ? undefined : eq(table.orgId, ctx.orgId)),
293
+ }
294
+ ```
295
+
296
+ A table with no entry is unscoped, and a build with no `scope` at all emits exactly the SQL it
297
+ did before.
298
+
299
+ ### Columns the server owns
300
+
301
+ `contextValues` supplies a column's value from the request context instead of accepting it from
302
+ the client:
303
+
304
+ ```Typescript
305
+ const { schema } = buildSchema(db, {
306
+ contextValues: {
307
+ posts: {
308
+ orgId: (ctx) => ctx.orgId,
309
+ authorId: (ctx) => ctx.userId,
310
+ },
311
+ },
312
+ })
313
+ ```
314
+
315
+ - The column is **removed from `CreatePostsInput` and `UpdatePostsInput`**, so a client cannot
316
+ send it at all — the SDL itself says the column is server-owned. It stays readable on the
317
+ object type.
318
+ - Every insert stamps it, including rows created by a nested `create`.
319
+ - Updates never write it, so an update cannot hand a row to another owner.
320
+
321
+ Together the two close the loop: `contextValues` decides which tenant a new row belongs to, and
322
+ `scope` decides which rows a request can see and change afterwards.
323
+
324
+ > **MySQL caveat.** `ON DUPLICATE KEY UPDATE` takes no predicate, so a MySQL upsert's conflict
325
+ > branch cannot be scoped. Scope the table's `update` and rely on `contextValues` for the insert
326
+ > half.
327
+
328
+ Names that match nothing throw at build time, the same as `exclude` — a renamed column fails the
329
+ build instead of quietly ceasing to be scoped.
240
330
  > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
241
331
  > or an equivalent layer above the schema, which has the request context this library
242
332
  > does not.
@@ -1487,6 +1577,105 @@ request-scoped batch loader, so the field still works even before you override i
1487
1577
  and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
1488
1578
  of `entities.fieldResolvers`.
1489
1579
 
1580
+ ## Writing your own resolver over a generated type
1581
+
1582
+ When you replace or wrap a generated resolver — to add a permission check that needs the
1583
+ row, to run a write inside your own transaction, to enforce a rule the schema cannot
1584
+ express — you become responsible for returning a row shaped like the type you replaced.
1585
+ That means translating the GraphQL selection set into the relational query builder's
1586
+ `with:` tree: relation field resolvers only fill in relations the parent did not already
1587
+ provide.
1588
+
1589
+ Two exports do that for you, alongside the already-exported `extractFilters` and
1590
+ `extractOrderBy`:
1591
+
1592
+ ```Typescript
1593
+ import { resolveSelection, selectionToWith } from '@vantreeseba/drizzle-graphql'
1594
+
1595
+ // The whole read: selected columns, relation arguments, eager loading, cursor
1596
+ // pagination, and the transport remapping of the result — against any executor.
1597
+ const rows = await resolveSelection(info, {
1598
+ db,
1599
+ table: 'Users',
1600
+ where: { id: { eq: userId } },
1601
+ executor: tx, // defaults to `db`; pass a transaction to see its own writes
1602
+ })
1603
+
1604
+ // Or just the `with:` tree, if you are building the query yourself.
1605
+ const withTree = selectionToWith(info, { db, table: 'Users' })
1606
+ const row = await tx.query.Users.findFirst({ where: { id: userId }, with: withTree })
1607
+ ```
1608
+
1609
+ `selectionToWith` resolves fragments and inline fragments against `info.fragments`,
1610
+ intersects the selection against the relations config (so scalars and any fields you added
1611
+ yourself drop out instead of becoming invalid `with:` keys), follows aliases back to the
1612
+ relation they select, compiles each relation's `where` / `orderBy` / `limit` / `offset`
1613
+ into its nested entry, and leaves `<relation>Aggregate` selections out — they are not
1614
+ `with:` entries. It returns `undefined` when the selection asks for no relations.
1615
+
1616
+ `resolveSelection` additionally accepts `single`, `orderBy`, `limit`, `offset` and `after`,
1617
+ and returns the rows already remapped to their GraphQL transport forms.
1618
+
1619
+ Both take a `typeNameMapper` option. If the schema was built with one, pass the same
1620
+ function here — the translation matches selections against GraphQL type names, so a build
1621
+ that renames its types has to say so.
1622
+
1623
+ ## Identifying generated fields
1624
+
1625
+ Every generated field and object type publishes what it is under `extensions.drizzle`, so
1626
+ wrappers — `graphql-middleware`, envelop, `graphql-shield`, an audit log, a cache
1627
+ invalidator — can ask the schema instead of parsing field names. Names are configurable
1628
+ (`prefixes`, `suffixes`, `typeNameMapper`), which makes name parsing ambiguous by
1629
+ construction; this is not.
1630
+
1631
+ ```Typescript
1632
+ import { drizzleExtension, identifyRows } from '@vantreeseba/drizzle-graphql'
1633
+
1634
+ const field = info.parentType.getFields()[info.fieldName]
1635
+ const meta = drizzleExtension(field)
1636
+ // {
1637
+ // table: 'Users', // the Drizzle schema key
1638
+ // kind: 'mutation', // 'query' | 'mutation' | 'relation' | 'aggregate' | 'type'
1639
+ // operation: 'update', // 'select' | 'insert' | 'update' | 'updateMany' | 'upsert'
1640
+ // // | 'delete' | 'aggregate' | 'groupBy' | 'relation'
1641
+ // // | 'relationAggregate'
1642
+ // single: true, // one row, or a list
1643
+ // targetArg: 'where', // 'where' | 'values' | 'updates' — where the rows live
1644
+ // primaryKey: ['id'], // key columns of `table`, in schema order
1645
+ // }
1646
+
1647
+ if (meta?.kind === 'mutation') await audit(meta.table, meta.operation, args[meta.targetArg])
1648
+ ```
1649
+
1650
+ `drizzleExtension` returns `undefined` for anything this library did not generate, which is
1651
+ the check to make before treating a field as one of ours. Relation fields (and
1652
+ `<relation>Aggregate` fields) additionally carry `relation` and `parentTable`, with `table`
1653
+ naming the relation's **target** — so `Users.posts` reads as
1654
+ `{ table: 'Posts', parentTable: 'Users', relation: 'posts' }`. Object types carry
1655
+ `{ table, kind: 'type', primaryKey }`, which is what a stitching layer needs to map a type
1656
+ back to its source.
1657
+
1658
+ Because the row identity of a mutation lives in a different argument for every operation,
1659
+ `identifyRows` reads whichever one applies and hands back primary-key values:
1660
+
1661
+ ```Typescript
1662
+ const target = identifyRows(field, args)
1663
+ // { table: 'Users', primaryKey: ['id'], rows: [{ id: 3 }], complete: true }
1664
+
1665
+ if (target?.complete) await invalidate(target.table, target.rows)
1666
+ ```
1667
+
1668
+ It understands `values` (one object or a list), `where` (`eq` for one row, `inArray` for
1669
+ many, an equality on every column of a composite key), and the per-entry `where` inside a
1670
+ batch update's `updates`. Check `complete` before treating `rows` as the affected set: it
1671
+ is `false` when the arguments do not pin the rows down — an insert whose key the database
1672
+ generates, a filter on something other than the primary key, an unbounded update, or a
1673
+ table with no primary key at all.
1674
+
1675
+ > [!NOTE]
1676
+ > `extensions.drizzle` is stable API. The point of publishing it is that consumers stop
1677
+ > parsing names, which only holds if the shape does not move: fields are added to it, never
1678
+ > removed or repurposed.
1490
1679
  ## Configuration reference
1491
1680
 
1492
1681
  Every key of the second argument to `buildSchema(db, config?)`. Each links to the section
package/dist/README.md CHANGED
@@ -237,6 +237,96 @@ table becomes readable but not insertable — and warns on the console, since th
237
237
  often a mistake than a plan.
238
238
 
239
239
  > `exclude` is a static decision: the column is absent for every caller. Deciding per
240
+ > request *which fields* a caller may reach is a job for
241
+ > [`graphql-shield`](https://the-guild.dev/graphql/shield) or an equivalent layer above the
242
+ > schema. Deciding per request *which rows* a caller may reach is `scope`, below — that one a
243
+ > layer above cannot do, because a nested relation field never passes through a root resolver.
244
+
245
+ ## Row scoping and multi-tenancy
246
+
247
+ `scope` attaches a predicate, derived from the request context, to every read and write of a
248
+ table:
249
+
250
+ ```Typescript
251
+ import { and, eq } from 'drizzle-orm'
252
+
253
+ const { schema } = buildSchema(db, {
254
+ scope: {
255
+ posts: (ctx, table) => eq(table.orgId, ctx.orgId),
256
+ // A filter object works too — same shape as the generated `where` argument:
257
+ comments: (ctx) => ({ post: { orgId: { eq: ctx.orgId } } }),
258
+ },
259
+ })
260
+ ```
261
+
262
+ Keyed by the **Drizzle schema key**. `ctx` is your GraphQL context value; `table` is the table
263
+ the current statement runs against — for a relational read that is Drizzle's *aliased* copy, so
264
+ always build the predicate from that argument rather than from the imported table object.
265
+
266
+ The predicate is ANDed on **last**, after whatever the client sent, so a client filter can only
267
+ narrow the scope, never widen it. It reaches every path that touches the table:
268
+
269
+ | Path | Effect |
270
+ | --- | --- |
271
+ | `posts`, `postsSingle` | out-of-scope rows are not returned |
272
+ | `postsAggregate`, `postsGroupBy` | counted over in-scope rows only |
273
+ | `user.posts`, `user.postsAggregate` | scoped on both the batched and the eager (`with:`) path |
274
+ | cursor pages | the keyset page is taken within the scope |
275
+ | `updatePosts`, `updatePostsMany`, `deletePosts` | an out-of-scope row is simply not matched |
276
+ | `upsertPosts` (conflict branch) | a conflicting row outside the scope is left alone, not taken over |
277
+ | nested `connect` / `disconnect` / `set` | only in-scope rows can be attached or detached |
278
+
279
+ Two things it deliberately does not do:
280
+
281
+ - **It does not refuse.** A scoped `deletePosts(where: { id: { eq: 4 } })` on another
282
+ tenant's row returns `[]`, the same answer as a row that does not exist. Telling the caller
283
+ "that row exists but is not yours" is the leak the scope is there to close.
284
+ - **It does not apply to a plain insert.** There is no existing row to filter. `contextValues`
285
+ is the write-side half.
286
+
287
+ Returning `undefined` means *no restriction for this context*, so one hook can let an
288
+ administrator through:
289
+
290
+ ```Typescript
291
+ scope: {
292
+ posts: (ctx, table) => (ctx.isAdmin ? undefined : eq(table.orgId, ctx.orgId)),
293
+ }
294
+ ```
295
+
296
+ A table with no entry is unscoped, and a build with no `scope` at all emits exactly the SQL it
297
+ did before.
298
+
299
+ ### Columns the server owns
300
+
301
+ `contextValues` supplies a column's value from the request context instead of accepting it from
302
+ the client:
303
+
304
+ ```Typescript
305
+ const { schema } = buildSchema(db, {
306
+ contextValues: {
307
+ posts: {
308
+ orgId: (ctx) => ctx.orgId,
309
+ authorId: (ctx) => ctx.userId,
310
+ },
311
+ },
312
+ })
313
+ ```
314
+
315
+ - The column is **removed from `CreatePostsInput` and `UpdatePostsInput`**, so a client cannot
316
+ send it at all — the SDL itself says the column is server-owned. It stays readable on the
317
+ object type.
318
+ - Every insert stamps it, including rows created by a nested `create`.
319
+ - Updates never write it, so an update cannot hand a row to another owner.
320
+
321
+ Together the two close the loop: `contextValues` decides which tenant a new row belongs to, and
322
+ `scope` decides which rows a request can see and change afterwards.
323
+
324
+ > **MySQL caveat.** `ON DUPLICATE KEY UPDATE` takes no predicate, so a MySQL upsert's conflict
325
+ > branch cannot be scoped. Scope the table's `update` and rely on `contextValues` for the insert
326
+ > half.
327
+
328
+ Names that match nothing throw at build time, the same as `exclude` — a renamed column fails the
329
+ build instead of quietly ceasing to be scoped.
240
330
  > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
241
331
  > or an equivalent layer above the schema, which has the request context this library
242
332
  > does not.
@@ -1487,6 +1577,105 @@ request-scoped batch loader, so the field still works even before you override i
1487
1577
  and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
1488
1578
  of `entities.fieldResolvers`.
1489
1579
 
1580
+ ## Writing your own resolver over a generated type
1581
+
1582
+ When you replace or wrap a generated resolver — to add a permission check that needs the
1583
+ row, to run a write inside your own transaction, to enforce a rule the schema cannot
1584
+ express — you become responsible for returning a row shaped like the type you replaced.
1585
+ That means translating the GraphQL selection set into the relational query builder's
1586
+ `with:` tree: relation field resolvers only fill in relations the parent did not already
1587
+ provide.
1588
+
1589
+ Two exports do that for you, alongside the already-exported `extractFilters` and
1590
+ `extractOrderBy`:
1591
+
1592
+ ```Typescript
1593
+ import { resolveSelection, selectionToWith } from '@vantreeseba/drizzle-graphql'
1594
+
1595
+ // The whole read: selected columns, relation arguments, eager loading, cursor
1596
+ // pagination, and the transport remapping of the result — against any executor.
1597
+ const rows = await resolveSelection(info, {
1598
+ db,
1599
+ table: 'Users',
1600
+ where: { id: { eq: userId } },
1601
+ executor: tx, // defaults to `db`; pass a transaction to see its own writes
1602
+ })
1603
+
1604
+ // Or just the `with:` tree, if you are building the query yourself.
1605
+ const withTree = selectionToWith(info, { db, table: 'Users' })
1606
+ const row = await tx.query.Users.findFirst({ where: { id: userId }, with: withTree })
1607
+ ```
1608
+
1609
+ `selectionToWith` resolves fragments and inline fragments against `info.fragments`,
1610
+ intersects the selection against the relations config (so scalars and any fields you added
1611
+ yourself drop out instead of becoming invalid `with:` keys), follows aliases back to the
1612
+ relation they select, compiles each relation's `where` / `orderBy` / `limit` / `offset`
1613
+ into its nested entry, and leaves `<relation>Aggregate` selections out — they are not
1614
+ `with:` entries. It returns `undefined` when the selection asks for no relations.
1615
+
1616
+ `resolveSelection` additionally accepts `single`, `orderBy`, `limit`, `offset` and `after`,
1617
+ and returns the rows already remapped to their GraphQL transport forms.
1618
+
1619
+ Both take a `typeNameMapper` option. If the schema was built with one, pass the same
1620
+ function here — the translation matches selections against GraphQL type names, so a build
1621
+ that renames its types has to say so.
1622
+
1623
+ ## Identifying generated fields
1624
+
1625
+ Every generated field and object type publishes what it is under `extensions.drizzle`, so
1626
+ wrappers — `graphql-middleware`, envelop, `graphql-shield`, an audit log, a cache
1627
+ invalidator — can ask the schema instead of parsing field names. Names are configurable
1628
+ (`prefixes`, `suffixes`, `typeNameMapper`), which makes name parsing ambiguous by
1629
+ construction; this is not.
1630
+
1631
+ ```Typescript
1632
+ import { drizzleExtension, identifyRows } from '@vantreeseba/drizzle-graphql'
1633
+
1634
+ const field = info.parentType.getFields()[info.fieldName]
1635
+ const meta = drizzleExtension(field)
1636
+ // {
1637
+ // table: 'Users', // the Drizzle schema key
1638
+ // kind: 'mutation', // 'query' | 'mutation' | 'relation' | 'aggregate' | 'type'
1639
+ // operation: 'update', // 'select' | 'insert' | 'update' | 'updateMany' | 'upsert'
1640
+ // // | 'delete' | 'aggregate' | 'groupBy' | 'relation'
1641
+ // // | 'relationAggregate'
1642
+ // single: true, // one row, or a list
1643
+ // targetArg: 'where', // 'where' | 'values' | 'updates' — where the rows live
1644
+ // primaryKey: ['id'], // key columns of `table`, in schema order
1645
+ // }
1646
+
1647
+ if (meta?.kind === 'mutation') await audit(meta.table, meta.operation, args[meta.targetArg])
1648
+ ```
1649
+
1650
+ `drizzleExtension` returns `undefined` for anything this library did not generate, which is
1651
+ the check to make before treating a field as one of ours. Relation fields (and
1652
+ `<relation>Aggregate` fields) additionally carry `relation` and `parentTable`, with `table`
1653
+ naming the relation's **target** — so `Users.posts` reads as
1654
+ `{ table: 'Posts', parentTable: 'Users', relation: 'posts' }`. Object types carry
1655
+ `{ table, kind: 'type', primaryKey }`, which is what a stitching layer needs to map a type
1656
+ back to its source.
1657
+
1658
+ Because the row identity of a mutation lives in a different argument for every operation,
1659
+ `identifyRows` reads whichever one applies and hands back primary-key values:
1660
+
1661
+ ```Typescript
1662
+ const target = identifyRows(field, args)
1663
+ // { table: 'Users', primaryKey: ['id'], rows: [{ id: 3 }], complete: true }
1664
+
1665
+ if (target?.complete) await invalidate(target.table, target.rows)
1666
+ ```
1667
+
1668
+ It understands `values` (one object or a list), `where` (`eq` for one row, `inArray` for
1669
+ many, an equality on every column of a composite key), and the per-entry `where` inside a
1670
+ batch update's `updates`. Check `complete` before treating `rows` as the affected set: it
1671
+ is `false` when the arguments do not pin the rows down — an insert whose key the database
1672
+ generates, a filter on something other than the primary key, an unbounded update, or a
1673
+ table with no primary key at all.
1674
+
1675
+ > [!NOTE]
1676
+ > `extensions.drizzle` is stable API. The point of publishing it is that consumers stop
1677
+ > parsing names, which only holds if the shape does not move: fields are added to it, never
1678
+ > removed or repurposed.
1490
1679
  ## Configuration reference
1491
1680
 
1492
1681
  Every key of the second argument to `buildSchema(db, config?)`. Each links to the section