@vantreeseba/drizzle-graphql 9.1.0 → 10.0.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/dist/README.md DELETED
@@ -1,2216 +0,0 @@
1
- # Drizzle-GraphQL
2
-
3
- Automatically create GraphQL schema or customizable schema config fields from Drizzle ORM schema
4
-
5
- ## Usage
6
-
7
- - Pass your drizzle database instance and schema into builder to generate `{ schema, entities }` object
8
- - Use `schema` if pre-built schema already satisfies all your neeeds. It's compatible witn any server that consumes `GraphQLSchema` class instance
9
-
10
- Example: hosting schema using [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
11
-
12
- ```Typescript
13
- import { createServer } from 'node:http'
14
- import { createYoga } from 'graphql-yoga'
15
- import { buildSchema } from 'drizzle-graphql'
16
-
17
- // db - your drizzle instance
18
- import { db } from './database'
19
-
20
- const { schema } = buildSchema(db)
21
-
22
- const yoga = createYoga({ schema })
23
-
24
- server.listen(4000, () => {
25
- console.info('Server is running on http://localhost:4000/graphql')
26
- })
27
- ```
28
-
29
- - If you want to customize your schema, you can use `entities` object to build your own new schema
30
-
31
- ```Typescript
32
- import { createServer } from 'node:http'
33
- import { GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema } from 'graphql'
34
- import { createYoga } from 'graphql-yoga'
35
- import { buildSchema } from 'drizzle-graphql'
36
-
37
- // Schema contains 'Users' and 'Customers' tables
38
- import { db } from './database'
39
-
40
- const { entities } = buildSchema(db)
41
-
42
- // You can customize which parts of queries or mutations you want
43
- const schema = new GraphQLSchema({
44
- query: new GraphQLObjectType({
45
- name: 'Query',
46
- fields: {
47
- // Select only wanted queries out of all generated
48
- users: entities.queries.users,
49
- customer: entities.queries.customersSingle,
50
-
51
- // Create a custom one
52
- customUsers: {
53
- // You can reuse and customize types from original schema
54
- type: new GraphQLList(new GraphQLNonNull(entities.types.UsersItem)),
55
- args: {
56
- // You can reuse inputs as well
57
- where: {
58
- type: entities.inputs.UsersFilters
59
- }
60
- },
61
- resolve: async (source, args, context, info) => {
62
- // Your custom logic goes here...
63
- const result = await db.select(schema.Users).where()...
64
-
65
- return result
66
- }
67
- }
68
- }
69
- }),
70
- // Same rules apply to mutations
71
- mutation: new GraphQLObjectType({
72
- name: 'Mutation',
73
- fields: entities.mutations
74
- }),
75
- // In case you need types inside your schema
76
- types: [...Object.values(entities.types), ...Object.values(entities.inputs)]
77
- })
78
-
79
- const yoga = createYoga({
80
- schema
81
- })
82
-
83
- server.listen(4000, () => {
84
- console.info('Server is running on http://localhost:4000/graphql')
85
- })
86
- ```
87
-
88
- ## Naming the generated types and fields
89
-
90
- By default every name comes from the table's key in the Drizzle schema: `tasks` gives type
91
- `Tasks`, queries `tasks` / `tasksSingle`, mutations `createTasks` / `createTasksSingle`.
92
- Most schemas use plural table keys and want singular type names, so that pairing is shipped
93
- as a preset:
94
-
95
- ```Typescript
96
- const { schema } = buildSchema(db, { typeNameMapper: 'singularize' })
97
- ```
98
-
99
- `tasks` then gives type `Task`, queries `tasks` (list) and `task` (single), mutations
100
- `createTasks` (array) and `createTask` (single), `updateTask`, `deleteTask`. Irregular
101
- plurals go through `pluralize`, so `people` gives `Person` / `people` / `person` and
102
- `addresses` gives `Address` rather than the `addresss` a naive suffix would produce. A table
103
- key that is already singular is pluralized for the list side, and a capitalized key is
104
- uncapitalized for the field side, so `tasks`, `task` and `Tasks` all land on the same names.
105
-
106
- For anything else, pass your own function — it is asked per table and may return `undefined`
107
- to leave that table with the default naming. The preset is exported as `singularizeMapper`
108
- so it can be wrapped:
109
-
110
- ```Typescript
111
- import { buildSchema, singularizeMapper } from 'drizzle-graphql'
112
-
113
- const { schema } = buildSchema(db, {
114
- typeNameMapper: (table) => (table === 'auditLog' ? undefined : singularizeMapper(table)),
115
- })
116
- ```
117
-
118
- With a mapper in play the singular/plural forms are what keep the list and single fields
119
- apart, so `suffixes` may be empty on both sides. Without one, they must still differ.
120
-
121
- ## Choosing what gets generated
122
-
123
- Every generated operation is on by default. `features` turns individual ones off, which
124
- keeps them out of the schema, out of `entities`, and out of the type map — a schema for a
125
- read-only API, or one that never exposes aggregates, does not pay for types nobody can
126
- reach:
127
-
128
- ```Typescript
129
- const { schema } = buildSchema(db, {
130
- features: {
131
- aggregates: false, // <plural>Aggregate root queries
132
- groupBy: false, // <plural>GroupBy root queries
133
- relationAggregates: false, // <relation>Aggregate fields on object types
134
- distinct: false, // the `distinct` argument on list queries
135
- insert: false, // create<Table> / create<Table>Single mutations
136
- update: false, // update<Table> / update<Table>Single mutations
137
- updateMany: false, // update<Table>Many batch mutation (needs `update`)
138
- delete: false, // delete<Table> / delete<Table>Single mutations
139
- upsert: true, // upsert<Table> / upsert<Table>Single mutations (off by default)
140
- nestedWrites: true, // relation fields on the create/update inputs (off by default)
141
- fieldUpdateOperations: true, // increment/push operations on the update input (off by default)
142
- countMutations: true, // update<Table>Count / delete<Table>Count mutations (off by default)
143
- requireWhere: true, // make `where` non-null on plural update/delete (off by default)
144
- uniqueKeyFilters: true, // a `where` field per compound unique constraint (off by default)
145
- },
146
- })
147
- ```
148
-
149
- - Any flag left out keeps its default of `true`, so `{ features: { delete: false } }`
150
- changes nothing else
151
- - `upsert`, `nestedWrites`, `fieldUpdateOperations`, `countMutations`, `requireWhere` and
152
- `uniqueKeyFilters` are the exceptions: they default to `false`, so they only exist if you
153
- ask for them
154
- - `groupBy` needs `aggregates`: it reuses those output types, so turning `aggregates` off
155
- turns the group-by queries off with it
156
- - Turning off `insert` or `update` also drops the input type that only that mutation
157
- used (`Create<Type>Input` / `Update<Type>Input`)
158
- - Turning off all three mutation features omits the `Mutation` type entirely, the same as
159
- `mutations: false`
160
- - List and single queries are always generated, so `Query` is never empty
161
-
162
- ### Per-table operations
163
-
164
- The choice is rarely build-wide: a schema usually has a handful of tables that must not be
165
- deleted through the API, a handful whose writes belong to a hand-written resolver that
166
- enforces something, and a majority that want everything. Any flag can be a predicate
167
- instead of a boolean, asked once per table with the table's key in the Drizzle schema:
168
-
169
- ```Typescript
170
- const { schema } = buildSchema(db, {
171
- features: {
172
- delete: (table) => table !== 'auditLog',
173
- update: (table) => !ownedByCustomResolver.has(table),
174
- upsert: (table) => table === 'settings',
175
- },
176
- })
177
- ```
178
-
179
- - The table is still fully readable — this decides which operations it offers, not whether
180
- it is in the schema at all. For that, use `exclude` below
181
- - `nestedWrites` is the one flag that stays build-wide: its plans are computed once over
182
- the whole relation graph, and a nested write reaches a second table by definition
183
- - `Mutation` is omitted when *no* table generates a mutation, and kept when one still does
184
- - Some operations are built out of others, and the generator knows which. A table that
185
- asks for `upsert` while its `insert` or `update` is off — an upsert is a second write
186
- path past the operation you turned off — warns on the console at build time, naming the
187
- tables, rather than leaving that to be discovered later. So does asking for `updateMany`
188
- without `update`, `groupBy` without `aggregates`, `countMutations` with neither plural
189
- write, or `nestedWrites` alongside a table whose own writes are off
190
-
191
- ## Excluding tables and columns
192
-
193
- `features` decides which *kinds* of operation exist. `exclude` decides which tables and
194
- columns exist at all — for the table holding session tokens, or the column holding a
195
- password hash:
196
-
197
- ```Typescript
198
- const { schema } = buildSchema(db, {
199
- exclude: {
200
- tables: ['magicLinks', 'sessions'],
201
- columns: { apiKeys: ['keyHash'] },
202
- },
203
- })
204
- ```
205
-
206
- Both lists are keyed by the **Drizzle schema key**, not the database name and not the
207
- generated GraphQL name.
208
-
209
- An excluded **table** generates nothing: no object type, no queries, no mutations, no
210
- aggregates, and no relation field on any other table that points at it. `Users.sessions`
211
- is gone, and so is `users(where: { sessions: { … } })` — the table is unreachable, not
212
- merely unnamed.
213
-
214
- An excluded **column** disappears from every surface at once:
215
-
216
- | Surface | Effect |
217
- | --- | --- |
218
- | `<Type>` object type | field removed |
219
- | `Create<Type>Input` / `Update<Type>Input` | field removed (nested-write payloads too) |
220
- | `<Type>Filters` | field removed |
221
- | `<Type>OrderBy` | field removed |
222
- | `<Type>MinAggregate` / `<Type>MaxAggregate` / … | field removed |
223
- | `<Type>DistinctColumn` / `<Type>GroupByColumn` | value removed |
224
- | `<Type>ConflictColumn` (upsert targets) | value removed |
225
-
226
- Two details worth stating plainly:
227
-
228
- - **The exclusion is all-or-nothing on purpose.** A column you can filter on but not
229
- select is an oracle: `where: { keyHash: { eq: '…' } }` confirms a value without ever
230
- returning it. Ordering and grouping leak the same way, more slowly. There is no
231
- read-only or write-only variant of `exclude` for this reason.
232
- - **Nothing changes below the schema.** An excluded column is still written by its
233
- default, still read by the primary-key and cursor machinery, and still usable by your
234
- own resolvers. Only the generated schema stops mentioning it.
235
-
236
- Names that match nothing throw at build time, so renaming a column fails the build instead
237
- of quietly un-hiding it. Excluding a `NOT NULL` column with no default is allowed — the
238
- table becomes readable but not insertable — and warns on the console, since that is more
239
- often a mistake than a plan.
240
-
241
- > `exclude` is a static decision: the column is absent for every caller. Deciding per
242
- > request *which fields* a caller may reach is a job for
243
- > [`graphql-shield`](https://the-guild.dev/graphql/shield) or an equivalent layer above the
244
- > schema. Deciding per request *which rows* a caller may reach is `scope`, below — that one a
245
- > layer above cannot do, because a nested relation field never passes through a root resolver.
246
-
247
- ## Row scoping and multi-tenancy
248
-
249
- `scope` attaches a predicate, derived from the request context, to every read and write of a
250
- table:
251
-
252
- ```Typescript
253
- import { and, eq } from 'drizzle-orm'
254
-
255
- const { schema } = buildSchema(db, {
256
- scope: {
257
- posts: (ctx, table) => eq(table.orgId, ctx.orgId),
258
- // A filter object works too — same shape as the generated `where` argument:
259
- comments: (ctx) => ({ post: { orgId: { eq: ctx.orgId } } }),
260
- },
261
- })
262
- ```
263
-
264
- Keyed by the **Drizzle schema key**. `ctx` is your GraphQL context value; `table` is the table
265
- the current statement runs against — for a relational read that is Drizzle's *aliased* copy, so
266
- always build the predicate from that argument rather than from the imported table object.
267
-
268
- The predicate is ANDed on **last**, after whatever the client sent, so a client filter can only
269
- narrow the scope, never widen it. It reaches every path that touches the table:
270
-
271
- | Path | Effect |
272
- | --- | --- |
273
- | `posts`, `postsSingle` | out-of-scope rows are not returned |
274
- | `postsAggregate`, `postsGroupBy` | counted over in-scope rows only |
275
- | `user.posts`, `user.postsAggregate` | scoped on both the batched and the eager (`with:`) path |
276
- | cursor pages | the keyset page is taken within the scope |
277
- | `updatePosts`, `updatePostsMany`, `deletePosts` | an out-of-scope row is simply not matched |
278
- | `upsertPosts` (conflict branch) | a conflicting row outside the scope is left alone, not taken over |
279
- | nested `connect` / `disconnect` / `set` | only in-scope rows can be attached or detached |
280
-
281
- Two things it deliberately does not do:
282
-
283
- - **It does not refuse.** A scoped `deletePosts(where: { id: { eq: 4 } })` on another
284
- tenant's row returns `[]`, the same answer as a row that does not exist. Telling the caller
285
- "that row exists but is not yours" is the leak the scope is there to close.
286
- - **It does not apply to a plain insert.** There is no existing row to filter. `contextValues`
287
- is the write-side half.
288
-
289
- Returning `undefined` means *no restriction for this context*, so one hook can let an
290
- administrator through:
291
-
292
- ```Typescript
293
- scope: {
294
- posts: (ctx, table) => (ctx.isAdmin ? undefined : eq(table.orgId, ctx.orgId)),
295
- }
296
- ```
297
-
298
- A table with no entry is unscoped, and a build with no `scope` at all emits exactly the SQL it
299
- did before.
300
-
301
- ### Columns the server owns
302
-
303
- `contextValues` supplies a column's value from the request context instead of accepting it from
304
- the client:
305
-
306
- ```Typescript
307
- const { schema } = buildSchema(db, {
308
- contextValues: {
309
- posts: {
310
- orgId: (ctx) => ctx.orgId,
311
- authorId: (ctx) => ctx.userId,
312
- },
313
- },
314
- })
315
- ```
316
-
317
- - The column is **removed from `CreatePostsInput` and `UpdatePostsInput`**, so a client cannot
318
- send it at all — the SDL itself says the column is server-owned. It stays readable on the
319
- object type.
320
- - Every insert stamps it, including rows created by a nested `create`.
321
- - Updates never write it, so an update cannot hand a row to another owner.
322
-
323
- Together the two close the loop: `contextValues` decides which tenant a new row belongs to, and
324
- `scope` decides which rows a request can see and change afterwards.
325
-
326
- > **MySQL caveat.** `ON DUPLICATE KEY UPDATE` takes no predicate, so a MySQL upsert's conflict
327
- > branch cannot be scoped. Scope the table's `update` and rely on `contextValues` for the insert
328
- > half.
329
-
330
- Names that match nothing throw at build time, the same as `exclude` — a renamed column fails the
331
- build instead of quietly ceasing to be scoped.
332
- > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
333
- > or an equivalent layer above the schema, which has the request context this library
334
- > does not.
335
- ## Naming
336
-
337
- Type and field names are derived from the Drizzle schema keys. Three options change them.
338
-
339
- `prefixes` and `suffixes` rename operations without touching type names:
340
-
341
- ```Typescript
342
- const { schema } = buildSchema(db, {
343
- prefixes: { insert: 'add', update: 'edit', delete: 'remove' }, // default: create / update / delete
344
- suffixes: { list: 'All', single: '' }, // default: '' / 'Single'
345
- })
346
- ```
347
-
348
- The list and single suffixes cannot be identical unless a `typeNameMapper` is also given —
349
- `users` and `usersSingle` would otherwise collapse onto one field name, and `buildSchema`
350
- throws rather than generating a schema that silently drops one of them.
351
-
352
- `typeNameMapper` renames the table itself, which is what a plural-keyed schema usually wants:
353
- `export const users = pgTable('users', …)` otherwise produces `type Users` and a
354
- `usersSingle` query.
355
-
356
- ```Typescript
357
- import pluralize from 'pluralize'
358
-
359
- const { schema } = buildSchema(db, {
360
- typeNameMapper: (table) => ({ singular: pluralize.singular(table), plural: table }),
361
- })
362
- // type User; queries `users` / `user`; mutations `createUsers` / `createUser`
363
- ```
364
-
365
- Return `undefined` for any table that should keep its default naming.
366
-
367
- ### Renaming the derived types
368
-
369
- `typeNameMapper` names the table. Everything derived from it — `UsersFilters`, `UsersOrderBy`,
370
- `CreateUsersInput`, `UsersAggregate`, and the shared inputs like `InnerOrder` and `StringFilter` —
371
- follows the default template. Two schemas built from different databases therefore collide the
372
- moment they are stitched into one gateway: both publish a `StringFilter`, and the two are not the
373
- same type.
374
-
375
- `typeNamePrefix` and `typeNameSuffix` move a whole build into its own namespace:
376
-
377
- ```Typescript
378
- const { schema } = buildSchema(db, { typeNamePrefix: 'Shop' })
379
- // type ShopUsers; input ShopUsersFilters, ShopCreateUsersInput, ShopInnerOrder, ShopStringFilter…
380
- ```
381
-
382
- The prefix must be a valid GraphQL name start (`/^[_A-Za-z][_0-9A-Za-z]*$/`) and the suffix a valid
383
- continuation (`/^[_0-9A-Za-z]+$/`); anything else throws at build time rather than producing a
384
- schema graphql-js will reject.
385
-
386
- For control over one kind of type rather than all of them, `derivedTypeNameMapper` is asked for
387
- every type the build constructs:
388
-
389
- ```Typescript
390
- const { schema } = buildSchema(db, {
391
- typeNamePrefix: 'Shop',
392
- derivedTypeNameMapper: ({ kind, defaultName, table }) =>
393
- kind === 'filter' ? `${table}Where` : undefined,
394
- })
395
- // input UsersWhere; everything it declined keeps the prefix — ShopUsers, ShopUsersOrderBy…
396
- ```
397
-
398
- `kind` is one of `object`, `filter`, `listRelationFilter`, `orderBy`, `createInput`, `updateInput`,
399
- `updateManyInput`, `nestedWriteInput`, `fieldUpdateInput`, `aggregate`, `having`, `groupBy`,
400
- `groupKeys`, `onConflict`, `uniqueKey`, `columnEnum`, `columnFilter` and `shared`. `table` is the
401
- Drizzle schema key the type belongs to, where it belongs to one, and `operation` further identifies
402
- it (the aggregate's `avg`, the column filter's `String`, the unique key's field name). An answer is
403
- used **verbatim** — the prefix and suffix are the fallback, not a wrapper around it. Return
404
- `undefined` to fall back.
405
-
406
- Custom scalars (`BigInt`, `Decimal`, …), PostgreSQL enum types and `PgGeometryObject` keep their own
407
- names under all three options: their definition does not vary between builds, so a gateway merges
408
- them rather than colliding on them.
409
-
410
- The [`resolveSelection` and `selectionToWith` helpers](#writing-your-own-resolver-over-a-generated-type) match selections
411
- against GraphQL type names, so an override in a renamed build passes the same three options back:
412
-
413
- ```Typescript
414
- resolveSelection(info, { db, table: 'Users', typeNamePrefix: 'Shop' })
415
- ```
416
-
417
- ## Soft delete
418
-
419
- `softDelete` turns a table's `delete` mutation into an UPDATE that marks the row, and hides
420
- marked rows from every read:
421
-
422
- ```Typescript
423
- const { schema } = buildSchema(db, {
424
- softDelete: {
425
- posts: 'deletedAt',
426
- // The NOT NULL form has to spell out both values:
427
- flags: { column: 'isArchived', deletedValue: true, restoredValue: false },
428
- },
429
- })
430
- ```
431
-
432
- Keyed by the **Drizzle schema key**, like `scope` and `exclude`. A function form is also
433
- accepted, for a convention applied across the schema:
434
-
435
- ```Typescript
436
- softDelete: (table, tableName) => ('deletedAt' in getTableColumns(table) ? 'deletedAt' : undefined)
437
- ```
438
-
439
- What a declared table gets:
440
-
441
- | Path | Effect |
442
- | --- | --- |
443
- | `deletePosts`, `deletePostsSingle` | UPDATE that writes the marker; returns the rows as they now stand — or a real DELETE under [`hard: true`](#purging-a-row), where the table opted in |
444
- | `restorePosts`, `restorePostsSingle` | the inverse — matches **only** marked rows and clears the marker |
445
- | `posts`, `postsSingle`, cursor pages | marked rows are not returned |
446
- | `postsAggregate`, `postsGroupBy` | counted over unmarked rows only |
447
- | `user.posts`, `user.postsAggregate` | hidden on both the batched and the eager (`with:`) path |
448
- | `updatePosts`, `updatePostsMany`, a second `deletePosts` | a marked row is simply not matched |
449
- | nested `connect` / `disconnect` / `set` | a marked row cannot be attached or detached |
450
- | `CreatePostsInput`, `UpdatePostsInput` | the marker column is **removed** — only the mutations write it |
451
-
452
- Every read path also gains a `deleted` argument for the cases the default hides:
453
-
454
- ```graphql
455
- {
456
- live: posts { id } # the default — EXCLUDE
457
- trash: posts(deleted: ONLY) { id } # the trash view, no second field needed
458
- all: posts(deleted: INCLUDE) { id }
459
- }
460
- ```
461
-
462
- The marker column stays **readable and filterable** — it is only the write inputs that lose it,
463
- the same trade `contextValues` makes.
464
-
465
- Defaults come from the column's type: `new Date()` for a timestamp, an ISO string for text,
466
- `Date.now()` for a number, `true` for a boolean. A NOT NULL column has no "absent" state, so
467
- the predicate has to compare against a constant: any NOT NULL column other than a boolean must
468
- give `deletedValue` (a constant, not a function) and `restoredValue`, or the build throws.
469
-
470
- **What counts as marked** follows the config, not the column's nullability. With no
471
- `deletedValue`, a nullable column reads as "holding a value at all means deleted" — the
472
- timestamp form, `deletedAt IS NOT NULL`. Name a constant and reads compare against it instead,
473
- on a nullable column as much as a NOT NULL one:
474
-
475
- ```Typescript
476
- // `is_deleted boolean DEFAULT false` — nullable, because it was added to an existing table
477
- softDelete: { docs: { column: 'isDeleted', deletedValue: true, restoredValue: false } }
478
- ```
479
-
480
- Here a row is deleted when the column holds `true`; `false` **and NULL** are both alive, so the
481
- rows written before the column existed are not swept into the trash view.
482
-
483
- ### What a relation field sees
484
-
485
- By default the scope covers the table's own query fields **and** every relation field that
486
- points at it, with one exception: a *required* to-one relation (`optional: false`, so the
487
- field is `Kind!`) reads marked rows. Hiding one there can only ever produce `Cannot return
488
- null for non-nullable field Item.kind` and take the whole parent down with it — there is no
489
- usable result to protect.
490
-
491
- Marking a lookup row usually means *retired*: keep it off the pickers and the lists, keep
492
- rendering it on the historical rows that reference it. `scope: 'root'` says exactly that:
493
-
494
- ```Typescript
495
- softDelete: {
496
- kinds: { column: 'isRetired', deletedValue: true, restoredValue: false, scope: 'root' },
497
- }
498
- ```
499
-
500
- | | `scope: 'all'` (default) | `scope: 'root'` |
501
- | --- | --- | --- |
502
- | `kinds`, `kindsSingle`, `kindsAggregate` | marked rows hidden | marked rows hidden |
503
- | `item.kind` (required to-one) | **shown** | shown |
504
- | `item.altKind` (nullable to-one) | hidden (resolves `null`) | shown |
505
- | `kind.items`, `kind.itemsAggregate` | hidden | shown |
506
-
507
- The `deleted` argument is generated on relation fields either way, so a request can override
508
- whichever default applies — `altKind(deleted: EXCLUDE)` under `'root'`, `altKind(deleted:
509
- INCLUDE)` under `'all'`.
510
-
511
- `scope` changes reads only. A nested `connect` / `disconnect` / `set` still refuses to attach a
512
- marked row under either setting, and the table's own writes are unaffected.
513
-
514
- ### Purging a row
515
-
516
- Soft delete and purge are not alternatives — emptying the trash, reclaiming a unique key and
517
- clearing a table in development all need the row actually gone. `hardDelete: true` puts a
518
- `hard` argument on the table's delete mutations:
519
-
520
- ```Typescript
521
- softDelete: { articles: { column: 'deletedAt', hardDelete: true } }
522
- ```
523
-
524
- ```graphql
525
- mutation {
526
- trash: deleteArticles(where: { id: { eq: 1 } }) { id } # UPDATE — marks the row
527
- purge: deleteArticles(where: { id: { eq: 3 } }, hard: true) { id } # DELETE — removes it
528
- }
529
- ```
530
-
531
- `hard: true` reads at `INCLUDE` rather than `EXCLUDE`, so it reaches rows that are **already
532
- marked** — which is the case it mostly exists for; a `scope` still confines what it can reach,
533
- exactly as it confines every other write. `deleteArticles(hard: true)` with no `where` empties
534
- the table, marked rows included.
535
-
536
- Opt-in per table, since it is destructive: no `hard` argument is generated where it was not
537
- asked for, so the schema itself says which tables can be purged. `restore` never takes one.
538
-
539
- One thing to weigh: an argument is harder to gate than a field — schema-level permission
540
- middleware registers rules per field name, so `deleteArticles` with and without `hard: true`
541
- is one rule that has to inspect arguments. The opted-in fields say so on their marker, so a
542
- wrapper can find them without parsing the schema's arguments:
543
-
544
- ```Typescript
545
- const meta = drizzleExtension(field) // { operation: 'delete', hardDelete: true, … }
546
- ```
547
-
548
- Three things it deliberately does not do:
549
-
550
- - **It does not cascade.** Marking a post does not mark its comments. A comment whose post is
551
- marked is still returned by `comments`; its `post` field resolves to `null` (or to the
552
- marked row, when the field is non-null or the table is `scope: 'root'` — see above).
553
- Declare the child table too if that is not what you want.
554
- - **It does not free the row's unique keys.** A marked row still occupies its primary key and
555
- every unique index, so inserting a new row with the same natural key fails the constraint.
556
- A partial unique index (`WHERE deleted_at IS NULL`) is the usual answer; `hardDelete`
557
- (above) is the other one.
558
- - **It does not refuse.** Like `scope`, an unreachable row answers the same as a row that does
559
- not exist.
560
-
561
- The soft-delete predicate is ANDed **before** a `scope` predicate, both after the client's
562
- `where` — so a scope still confines what `deleted: INCLUDE` can reach.
563
-
564
- > **MySQL caveat.** `ON DUPLICATE KEY UPDATE` takes no predicate, so an upsert whose conflict
565
- > target hits a marked row updates it — reviving it — rather than leaving it alone. On
566
- > PostgreSQL and SQLite the conflict branch carries the predicate and updates nothing.
567
-
568
- A table with no declaration keeps a real `DELETE`, gets no `restore` mutation and no `deleted`
569
- argument, and a build with no `softDelete` at all emits exactly the SQL it did before.
570
-
571
-
572
- ## Scalars
573
-
574
- Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
575
- generated SDL says what a field actually holds instead of falling back to `String`:
576
-
577
- | Drizzle column | GraphQL type | Transported as |
578
- | ------------------------------------------ | ------------ | ------------------------------------ |
579
- | `json` / `jsonb` (and `mode: 'json'`) | `JSON` | the parsed value |
580
- | `bigint` (and `mode: 'bigint'`) | `BigInt` | a decimal string |
581
- | `uuid` | `UUID` | a validated UUID string |
582
- | `timestamp` / `datetime` | `DateTime` | an ISO-8601 string |
583
- | `date` | `Date` | a `YYYY-MM-DD` string |
584
-
585
- ```graphql
586
- mutation {
587
- createDocumentSingle(values: { id: "11111111-1111-4111-8111-111111111111", payload: { tags: ["a"], views: 3 }, counter: "9007199254740993" }) {
588
- payload
589
- counter
590
- }
591
- }
592
- ```
593
-
594
- - **`JSON`** carries the value itself — objects, arrays, numbers, strings, `true`/`false`.
595
- Reads return the parsed value, not a stringified one, and writes take a literal or a
596
- variable rather than a string of JSON
597
- - **`BigInt`** is always a decimal string in both directions, so values past
598
- `Number.MAX_SAFE_INTEGER` survive the round-trip. Integer literals are accepted on input;
599
- floats and non-numeric strings are rejected
600
- - **`UUID`** validates the format on the way in, including inside `where` filters
601
-
602
- The scalars are exported if you need them in a hand-written schema:
603
-
604
- ```Typescript
605
- import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQLUUID } from 'drizzle-graphql'
606
- ```
607
-
608
- `GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
609
- called in SDL, to avoid clashing with the language's own `BigInt`.
610
-
611
- ### Overriding a column's scalar
612
-
613
- Built-in detection is a default, not a ceiling. `scalars` overrides named columns, and
614
- `mapColumnType` applies a rule across every column at build time — the tool for a convention
615
- ("every `numeric` column is `Money`") or for a custom column type detection does not know:
616
-
617
- ```Typescript
618
- const { schema } = buildSchema(db, {
619
- scalars: {
620
- users: { email: EmailAddressScalar }, // by table key, then column key
621
- },
622
- mapColumnType: (column) => (column.columnType === 'PgNumeric' ? MoneyScalar : undefined),
623
- })
624
- ```
625
-
626
- Either may return a single scalar, or an `{ output, input }` pair when the read and write
627
- types differ. `mapColumnType` returning `undefined` keeps the default, and a column matched
628
- by both is decided by `scalars` alone.
629
-
630
- ## Enum types
631
-
632
- A column declared with a database enum becomes a GraphQL enum named after **the enum**, not
633
- after the column that happens to use it:
634
-
635
- ```Typescript
636
- export const status = pgEnum('status', ['active', 'archived'])
637
-
638
- export const Users = pgTable('users', { status: status('status') })
639
- export const Posts = pgTable('posts', { status: status('status') })
640
- ```
641
-
642
- ```graphql
643
- enum StatusEnum {
644
- active
645
- archived
646
- }
647
-
648
- type Users {
649
- status: StatusEnum!
650
- }
651
- type Posts {
652
- status: StatusEnum!
653
- }
654
- ```
655
-
656
- - One type, one filter input (`StatusEnumFilter`), so a client variable typed `StatusEnum!`
657
- is passable to either table
658
- - The declared name is PascalCased and suffixed: `user_status` → `UserStatusEnum`
659
- - A column that lists its values inline (`text('tier', { enum: ['free', 'paid'] })`) has no
660
- declared name to share, so it keeps a per-column type — `UsersTierEnum`
661
- - Two different enums that would land on the same GraphQL name (`status` in two Postgres
662
- schemas) fail the build rather than silently sharing a type
663
-
664
- `enumNameMapper` overrides the name. Returning `undefined` keeps the default, so a mapper
665
- only has to handle the cases it cares about:
666
-
667
- ```Typescript
668
- const { schema } = buildSchema(db, {
669
- // `schema` is the Postgres schema the enum was declared in, if any; `values` is its
670
- // value list. Everything but `enumName` is always present.
671
- enumNameMapper: ({ enumName, schema }) =>
672
- schema === 'reporting' ? `Reporting${enumName}Enum` : undefined,
673
- })
674
- ```
675
-
676
- Two columns the mapper sends to the same name share one type, as long as their values match —
677
- which is also how inline enums can be unified. Sending the same name to two different value
678
- lists is the build-time error above.
679
-
680
- > **Upgrading:** before this, every enum column got its own `<Table><Column>Enum`. A column
681
- > backed by a declared enum now reports the shared name instead (`UsersStatusEnum` →
682
- > `StatusEnum`). To keep the old names, return them from the mapper:
683
- >
684
- > ```Typescript
685
- > enumNameMapper: ({ tableName, columnName }) =>
686
- > `${tableName}${columnName[0]!.toUpperCase()}${columnName.slice(1)}Enum`
687
- > ```
688
-
689
- ## Documenting the schema
690
-
691
- Four hooks put descriptions and deprecations on the generated types. Each is called per
692
- column (or per table / per relation) and returns `undefined` to say nothing:
693
-
694
- ```Typescript
695
- const { schema } = buildSchema(db, {
696
- describeColumn: (column, { tableName, columnName }) => docs[tableName]?.[columnName],
697
- describeTable: (tableName) => docs[tableName]?._table,
698
- describeRelation: (tableName, relationName) => `The ${relationName} of this ${tableName}`,
699
- deprecateColumn: (column, { columnName }) => (columnName === 'legacyId' ? 'Use `id`' : undefined),
700
- })
701
- ```
702
-
703
- - `describeColumn` reaches every place the column appears: the object type, the create and
704
- update inputs, the filter input's field, `Min`/`Max` aggregates and `GroupKeys`
705
- - The hook receives the drizzle column itself, so a project that keeps its documentation in
706
- the schema (a `.$type()` brand, a custom column property, a comment table) can read it
707
- straight off
708
- - `deprecateColumn` marks output fields and *optional* input fields. A required input field
709
- is skipped: GraphQL forbids deprecating one, and emitting it would make the schema invalid
710
- - Filter and `orderBy` fields are never deprecated — filtering on a deprecated column is how
711
- a caller finds the rows still using it
712
-
713
- ## Relation filters
714
-
715
- A table's `where` input also exposes its relations, so you can filter rows by what they're
716
- related to instead of pulling everything and filtering client-side.
717
-
718
- A **to-one** relation takes the target table's filter input directly:
719
-
720
- ```graphql
721
- {
722
- posts(where: { author: { name: { eq: "FifthUser" } } }) {
723
- id
724
- }
725
- }
726
- ```
727
-
728
- A **to-many** relation takes a `some` / `none` / `every` wrapper
729
- (`<Target>ListRelationFilter`):
730
-
731
- ```graphql
732
- {
733
- # users who wrote at least one post containing "drizzle"
734
- users(where: { posts: { some: { content: { like: "%drizzle%" } } } }) {
735
- id
736
- }
737
-
738
- # users with no posts at all
739
- users(where: { posts: { none: {} } }) {
740
- id
741
- }
742
-
743
- # users all of whose posts are published (users with no posts match vacuously)
744
- users(where: { posts: { every: { isPublished: { eq: true } } } }) {
745
- id
746
- }
747
- }
748
- ```
749
-
750
- - Relation filters compile to correlated `EXISTS` subqueries — the related rows are never
751
- fetched, and no join duplicates the parent rows
752
- - They nest arbitrarily (`users(where: { posts: { some: { author: { … } } } })`) and combine
753
- freely with column filters (implicit `AND`) and with `OR`
754
- - They're accepted anywhere a filter is — list and single queries, aggregate queries,
755
- `update`/`delete` mutations, and the `where` argument on a relation field
756
- - `some: {}` means "at least one related row exists"; `none: {}` means "none exist"
757
- - Several modes may be given at once and are `AND`ed together
758
- - A relation whose name collides with a column name is skipped — the column keeps the field
759
- - Many-to-many relations declared with `.through()` are filterable too: the `EXISTS`
760
- subquery joins the junction table to the target, so
761
- `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
762
- to-many relation
763
-
764
- ## Lookup by a unique key
765
-
766
- A row with a two-column natural key — a stock line identified by item and location, a
767
- membership identified by user and team — has to be spelled as two ordinary filters that
768
- happen to match one row. Nothing says they belong together, and the query stays valid when
769
- half of it goes missing. `features: { uniqueKeyFilters: true }` adds one `where` field per
770
- compound unique constraint, named after its columns:
771
-
772
- ```graphql
773
- {
774
- stockSingle(where: { itemId_locationId: { itemId: "widget", locationId: "eu" } }) {
775
- quantity
776
- }
777
- }
778
- ```
779
-
780
- ```graphql
781
- input StockItemIdLocationIdKey {
782
- itemId: String!
783
- locationId: String!
784
- }
785
- ```
786
-
787
- - Every member is non-null, so a half-supplied key is a query-validation error rather than
788
- a filter that quietly matches more rows than you meant
789
- - The field lives on the table's `Filters` input, so it is accepted anywhere a filter is —
790
- list and single queries, aggregates, `update<Table>Single` / `delete<Table>Single` — and
791
- composes with column filters, relation filters, `OR` / `AND` / `NOT`
792
- - Composite primary keys get a field like any other unique constraint
793
- - Single-column constraints do not: `eq` on the column already names the row
794
- - A field name a column or relation already holds is skipped, the same way a relation
795
- yields to a same-named column
796
- - It compiles to exactly the equalities you would have written by hand; the guarantee is in
797
- the input type, not in the SQL
798
-
799
- ## Case-insensitive matching
800
-
801
- Every filter on a string column carries an `insensitive` flag alongside its operators. Set it
802
- and each comparison in that object compares `lower(column)` against `lower(operand)`:
803
-
804
- ```graphql
805
- {
806
- users(where: { email: { eq: "Dan@Example.com", insensitive: true } }) {
807
- id
808
- }
809
-
810
- users(where: { username: { inArray: ["dan", "SAM", "Alex"], insensitive: true } }) {
811
- id
812
- }
813
- }
814
- ```
815
-
816
- This is what a natural key — an email, a username, a slug, an account code, a SKU — actually
817
- wants, and `ilike` is the wrong tool for it: `%`, `_` and `\` in an `ilike` operand are
818
- wildcards, so `100%_off` would silently match rows it should not unless the caller escapes them
819
- by hand. Under `insensitive` the operand stays a literal and stays a bound parameter.
820
-
821
- - It covers every operator beside it: `eq` / `ne`, the ordering comparisons, `inArray` /
822
- `notInArray`, `like` / `notLike`, and the safe `startsWith` / `endsWith` / `contains`
823
- (the `i`-prefixed forms are already case-insensitive and are unaffected)
824
- - It applies to the operators in its own object only. A nested `AND` / `OR` / `NOT` branch is
825
- a separate object and sets its own flag
826
- - `lower()` is the same function the case-insensitive LIKE operators already fall back to on
827
- the dialects without a native `ILIKE`, so the behaviour is identical on all three. Because
828
- the left side is a plain `lower(column)`, a `lower(column)` expression index can serve the
829
- lookup — which `ilike '%…%'` never can
830
- - The flag appears only on filters whose column can take string operators, so it is absent
831
- from the numeric, uuid and boolean filters
832
-
833
- ## Matching lists of values
834
-
835
- `inArray` / `notInArray` take a list of candidate values and compile to SQL `IN` / `NOT IN`.
836
- Two things about them are worth stating explicitly.
837
-
838
- **An empty list is answered, not rejected.** A caller building a filter from a variable often
839
- ends up with an empty list, and that list has a well-defined meaning: nothing is `IN ()` and
840
- everything is `NOT IN ()`.
841
-
842
- ```graphql
843
- {
844
- # no rows
845
- posts(where: { id: { inArray: [] } }) {
846
- id
847
- }
848
-
849
- # every row
850
- posts(where: { id: { notInArray: [] } }) {
851
- id
852
- }
853
- }
854
- ```
855
-
856
- The same rule applies to the Postgres array-column operators: `hasSome: []` matches nothing
857
- (overlapping with no elements is never true) and `hasEvery: []` matches everything (every
858
- array contains all zero of them).
859
-
860
- **The list's own order can be the sort order.** SQL returns `IN` matches in whatever order it
861
- likes, so a caller that fetches rows by a ranked list of ids normally has to re-sort them
862
- client-side. Setting `matchFilterOrder` on an `orderBy` entry sorts by the row's position in
863
- that column's `inArray` list instead of by the column's value:
864
-
865
- ```graphql
866
- {
867
- posts(
868
- where: { id: { inArray: [7, 3, 12] } }
869
- orderBy: { id: { direction: asc, priority: 1, matchFilterOrder: true } }
870
- ) {
871
- id # → 7, 3, 12
872
- }
873
- }
874
- ```
875
-
876
- - `direction: asc` keeps the list's order; `desc` reverses it
877
- - It needs an `inArray` filter on the same column, at the top level of `where` — an
878
- `inArray` buried inside an `OR` branch raises an error rather than being guessed at
879
- - It interleaves with the other `orderBy` entries by `priority` like any ordering key, and
880
- works on relation fields as well as root lists
881
- - It compiles to a `CASE` ladder over the list values, so it behaves identically on all
882
- three dialects and every value stays a bound parameter
883
- - It cannot be combined with `after` or `distinct`: a row's position in the request's own
884
- filter list is not a value of the row, so it cannot be encoded into a cursor or rebuilt
885
- against a subquery. Selecting `cursor` under it yields `null` rather than an error
886
-
887
- ## Filtering inside JSON documents
888
-
889
- `json` / `jsonb` columns get a `path` filter that compares the value at one path inside the
890
- document, alongside the whole-document `eq` / `ne` / `contains` operators:
891
-
892
- ```graphql
893
- {
894
- users(where: { meta: { path: { path: ["profile", "level"], gt: 2 } } }) {
895
- id
896
- }
897
- }
898
- ```
899
-
900
- `path` walks the document key by key from the root; an all-digits key indexes an array
901
- (`["tags", "0"]`). Several predicates may be given at once and are `AND`ed:
902
-
903
- ```graphql
904
- {
905
- users(
906
- where: {
907
- meta: {
908
- path: [
909
- { path: ["profile", "role"], eq: "admin" }
910
- { path: ["profile", "level"], gte: 3 }
911
- ]
912
- }
913
- }
914
- ) {
915
- id
916
- }
917
- }
918
- ```
919
-
920
- Available operators are `eq` / `ne` / `lt` / `lte` / `gt` / `gte`, the safe string matchers
921
- `startsWith` / `endsWith` / `contains` (plus their case-insensitive `i`-prefixed forms), and
922
- `isNull` / `isNotNull`. A missing key and a JSON `null` both read as null.
923
-
924
- **How the value is compared** is decided by the operand: a GraphQL number compares
925
- numerically, a boolean as a boolean, and anything else as text. Pass `as: TEXT | NUMBER |
926
- BOOLEAN` when the operand's type doesn't match the document's — comparing a numeric field
927
- against a `String` variable, say:
928
-
929
- ```graphql
930
- {
931
- users(where: { meta: { path: { path: ["profile", "level"], as: NUMBER, eq: "3" } } }) {
932
- id
933
- }
934
- }
935
- ```
936
-
937
- A value of the wrong shape never matches rather than failing the query: `gt: 0` against a
938
- document whose path holds `"admin"` returns no rows on every dialect. That takes a guard on
939
- each one — Postgres errors outright on a bad `::numeric`, MySQL quietly casts a non-numeric
940
- string to `0`, and SQLite's cross-type ordering would otherwise sort every string above every
941
- number.
942
-
943
- Note that `contains` means different things at the two levels, because a path names a scalar:
944
- on the column it is structural JSON containment (Postgres `@>` / MySQL `JSON_CONTAINS`),
945
- inside a `path` filter it is substring matching on the extracted value.
946
-
947
- Path filters combine with `AND` / `OR` / `NOT` like any other operator, and are accepted
948
- everywhere a filter is — list, single and aggregate queries, `update` / `delete`, and the
949
- `where` argument on a relation field.
950
-
951
- ## Aggregate queries
952
-
953
- Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
954
- following the same naming rules as the other generated queries:
955
-
956
- ```graphql
957
- {
958
- postsAggregate(where: { authorId: { eq: 1 } }) {
959
- count
960
- avg {
961
- views
962
- }
963
- sum {
964
- views
965
- }
966
- min {
967
- createdAt
968
- }
969
- max {
970
- createdAt
971
- title
972
- }
973
- countNonNull {
974
- publishedAt
975
- }
976
- countDistinct {
977
- authorId
978
- }
979
- }
980
- }
981
- ```
982
-
983
- - `count` — number of matching rows (`Int!`)
984
- - `avg` / `sum` — one nullable `Float` field per numeric column
985
- - `min` / `max` — one field per orderable column (numbers, strings, enums, dates, bigints),
986
- typed exactly like that column is in the table's own type
987
- - `countNonNull` — `Int!` per column: how many matching rows have a non-null value there.
988
- Every column qualifies, since `count(col)` is valid whatever the type
989
- - `countDistinct` — `Int!` per column: how many distinct non-null values there are. Limited to
990
- the same columns as `min` / `max`, because counting distinct values needs an equality operator
991
-
992
- Columns that have no meaningful ordering — booleans, arrays, JSON, buffers, and geometry —
993
- are left out of these types, and the `avg` / `sum` / `min` / `max` fields themselves are
994
- omitted when no column qualifies.
995
-
996
- The optional `where` argument takes the same filter input as the table's list query, and is
997
- applied to every aggregate in the selection. All requested aggregates are computed in a
998
- single `SELECT`, and on an empty result set `count` is `0` while the other values are `null`.
999
-
1000
- Add `groupBy` to get the same numbers one row per group — see [Group by](#group-by).
1001
-
1002
- ## Group by
1003
-
1004
- Every table with aggregates also gets a `<tableName>GroupBy` query (e.g. `postsGroupBy`): the
1005
- same aggregates as `<tableName>Aggregate`, computed once per distinct combination of the
1006
- columns you group by.
1007
-
1008
- ```graphql
1009
- {
1010
- postsGroupBy(
1011
- groupBy: [authorId, published]
1012
- where: { createdAt: { gte: "2024-01-01T00:00:00Z" } }
1013
- having: { count: { gte: 5 } }
1014
- ) {
1015
- group {
1016
- authorId
1017
- published
1018
- }
1019
- count
1020
- avg {
1021
- views
1022
- }
1023
- max {
1024
- createdAt
1025
- }
1026
- }
1027
- }
1028
- ```
1029
-
1030
- - `groupBy` is required and takes one or more values of the table's `<Type>GroupByColumn`
1031
- enum. It holds the orderable columns plus booleans — anything the database can group on.
1032
- An empty list is an error, and repeated columns are ignored
1033
- - `group` is a `<Type>GroupKeys!` object with one nullable field per groupable column, typed
1034
- like the table's own column. Columns the query did not group by come back as `null`, which
1035
- a column whose grouped value really is `NULL` is indistinguishable from
1036
- - Every other field is the same one `<tableName>Aggregate` returns — `count`, `avg`, `sum`,
1037
- `min`, `max`, `countNonNull`, `countDistinct` — with the identical types and rules
1038
- - `where` filters rows before grouping; `having` filters groups after aggregating
1039
- - The result is `[<Type>GroupBy!]!`, one row per group, in whatever order the database
1040
- returns them. Add your own ordering client-side if you need it
1041
-
1042
- `having` mirrors the aggregate selection, with an `AggregateNumberFilter` (`eq`, `ne`, `gt`,
1043
- `gte`, `lt`, `lte`, all `Float`) in place of each value:
1044
-
1045
- ```graphql
1046
- {
1047
- postsGroupBy(groupBy: [authorId], having: { count: { gt: 3 }, avg: { views: { gte: 100 } } }) {
1048
- group {
1049
- authorId
1050
- }
1051
- count
1052
- }
1053
- }
1054
- ```
1055
-
1056
- - `having: { count: … }` filters on the row count; `avg` / `sum` / `min` / `max` /
1057
- `countNonNull` / `countDistinct` take one filter per column, over the same columns the
1058
- matching aggregate type exposes
1059
- - Every entry in a `having` is ANDed together
1060
- - A group filter does not need to be in the selection — `having: { count: { gt: 3 } }` works
1061
- whether or not you asked for `count`
1062
-
1063
- The whole thing is one `SELECT … GROUP BY … HAVING …`. Set `features: { groupBy: false }` to
1064
- leave these queries (and their `<Type>GroupBy`, `<Type>GroupKeys`, `<Type>GroupByColumn` and
1065
- `<Type>Having` types) out of the schema; turning off `aggregates` removes them too.
1066
-
1067
- ## Relation aggregates
1068
-
1069
- Every to-many relation also gets an `<relationName>Aggregate` field on the parent type, so you
1070
- can count or summarise related rows without fetching them:
1071
-
1072
- ```graphql
1073
- {
1074
- users {
1075
- id
1076
- postsAggregate {
1077
- count
1078
- }
1079
- publishedPosts: postsAggregate(where: { published: { eq: true } }) {
1080
- count
1081
- max {
1082
- createdAt
1083
- }
1084
- }
1085
- }
1086
- }
1087
- ```
1088
-
1089
- - The field returns the target table's own `<Type>Aggregate` type — the same one the root
1090
- `<tableName>Aggregate` query returns, so `count` / `avg` / `sum` / `min` / `max` behave
1091
- identically
1092
- - `where` takes the target table's filter input, including [relation filters](#relation-filters),
1093
- and applies only to the related rows
1094
- - A parent with no related rows gets `count: 0` and `null` for every other aggregate
1095
- - To-one relations get no aggregate field — there is nothing to aggregate over
1096
- - The field is skipped if its name would collide with a column or another relation
1097
-
1098
- All parents in a selection are aggregated with a single
1099
- `SELECT <fk>, … WHERE <fk> IN (…) GROUP BY <fk>` per request, so `postsAggregate` on a list of
1100
- users is one extra query, not one per user. Differently-aliased selections with different
1101
- `where` arguments are batched separately.
1102
-
1103
- ## Distinct
1104
-
1105
- List queries take a `distinct` argument — a list of columns from the `<Type>DistinctColumn`
1106
- enum. Rows sharing the same combination of those columns collapse to one:
1107
-
1108
- ```graphql
1109
- {
1110
- # the first post of each author
1111
- posts(distinct: [authorId], orderBy: { createdAt: { direction: asc, priority: 1 } }) {
1112
- id
1113
- authorId
1114
- createdAt
1115
- }
1116
- }
1117
- ```
1118
-
1119
- - Which row survives each group is decided by `orderBy` — the first one wins. With no
1120
- `orderBy`, that's the lowest primary key
1121
- - `where` is applied **before** rows are collapsed; `limit` and `offset` are applied
1122
- **after**, so `limit: 10` returns ten distinct rows
1123
- - Several columns are treated as one combined key, not as independent ones
1124
- - `distinct` is also available on to-many relation fields, where it means one row per
1125
- distinct value **per parent** — the window is partitioned by the foreign key as well as
1126
- the requested columns
1127
- - `distinct` is not on single queries — those return one row either way
1128
-
1129
- It runs as an extra `row_number() over (partition by …)` query that picks the surviving
1130
- rows' primary keys, after which the main query is narrowed to them — so it needs the same
1131
- window-function support as per-parent paginated relations (**PostgreSQL**, **MySQL 8.0+**,
1132
- or **SQLite 3.25+**), and a table with no primary key cannot use it.
1133
-
1134
- ## Pagination ordering
1135
-
1136
- SQL gives no ordering guarantee for a query that has no `ORDER BY`, so paging through an
1137
- unordered result can return the same row twice or skip one entirely. To keep pages stable,
1138
- a query that returns only part of a table is ordered by its primary key when the request
1139
- supplies no `orderBy`:
1140
-
1141
- - list queries with `limit` and/or `offset`
1142
- - `<tableName>Single` queries, which are an implicit `limit 1`
1143
- - to-many relation fields with `limit` and/or `offset` (as a tiebreak appended to any
1144
- `orderBy` you do supply, so per-parent slices are deterministic)
1145
-
1146
- An explicit `orderBy` always takes precedence, composite primary keys are ordered by every
1147
- key column, and an unpaginated list query is left unordered so no sort is paid for.
1148
-
1149
- ## Default ordering
1150
-
1151
- A table usually has one ordering that is right nearly every time it is read — newest first,
1152
- priority first, name alphabetical. `defaults` names that ordering once, and every query that
1153
- does not ask for its own gets it:
1154
-
1155
- ```ts
1156
- buildSchema(db, {
1157
- defaults: {
1158
- Todos: { orderBy: { priority: 'desc', createdAt: 'desc' } },
1159
- Users: { orderBy: { name: 'asc' } },
1160
- },
1161
- });
1162
- ```
1163
-
1164
- ```graphql
1165
- # ordered by priority desc, then createdAt desc — nothing in the query says so
1166
- { todos { id title } }
1167
- ```
1168
-
1169
- Keys are the table's key in your Drizzle schema and the column's property name, so a typo in
1170
- either fails at `buildSchema` time rather than silently doing nothing. Each column takes
1171
- `'asc'`, `'desc'`, or a `{ direction, priority? }` object mirroring the `orderBy` argument.
1172
- Columns sort highest `priority` first, and the shorthand leaves it unset, so the shorthand
1173
- falls back to the order the keys are written in — the object form is there for when you would
1174
- rather state the precedence than depend on that:
1175
-
1176
- ```ts
1177
- defaults: {
1178
- Todos: { orderBy: { createdAt: { direction: 'desc', priority: 1 }, priority: { direction: 'desc', priority: 2 } } },
1179
- }
1180
- ```
1181
-
1182
- It applies to list queries, `<tableName>Single` queries, and to-many relation fields on both
1183
- the eager and the lazy relation paths — a relation takes the default of the table it *reads*,
1184
- not its parent's. To-one relation fields are a single row and take no ordering.
1185
-
1186
- Two details worth stating plainly:
1187
-
1188
- - **Any `orderBy` in the request wins outright.** The default is a fallback for a missing
1189
- argument, not a base that client ordering is appended to. To ask for *no* ordering, pass
1190
- `orderBy: {}` — an empty object is still an argument, so it suppresses the default.
1191
- - **A default ordering makes an unpaginated list query pay for a sort.** That is the point,
1192
- but it is a cost a table with no `defaults` entry does not pay; a build with no `defaults`
1193
- behaves exactly as before.
1194
-
1195
- Cursor pagination composes with it: a default-ordered page is ordered by those columns plus
1196
- the primary key tiebreak, and its `cursor` values encode that same total order, so paging works
1197
- without the client restating the ordering.
1198
-
1199
- ## Cursor pagination
1200
-
1201
- `OFFSET` pagination degrades linearly and shifts under concurrent writes. List queries also
1202
- support keyset pagination: every row returned by a list query exposes an opaque `cursor`
1203
- field, and passing it back as `after` resumes strictly after that row.
1204
-
1205
- ```graphql
1206
- {
1207
- posts(orderBy: { createdAt: { direction: desc, priority: 1 } }, limit: 10) {
1208
- id
1209
- createdAt
1210
- cursor
1211
- }
1212
- }
1213
-
1214
- # next page — same orderBy, plus the last row's cursor
1215
- {
1216
- posts(
1217
- orderBy: { createdAt: { direction: desc, priority: 1 } }
1218
- limit: 10
1219
- after: "eyJvIjogW1siY3JlYXRlZEF0IiwgImRlc2MiXSwgWyJpZCIsICJhc2MiXV0sIC4uLn0"
1220
- ) {
1221
- id
1222
- createdAt
1223
- cursor
1224
- }
1225
- }
1226
- ```
1227
-
1228
- - The cursor encodes the row's position in the query's **total order** — your `orderBy`
1229
- columns plus the primary key as an ascending tiebreak — so pages are stable even when
1230
- the ordered columns aren't unique, and inserts or deletes mid-scroll don't shift the
1231
- window
1232
- - `after` compiles to a keyset predicate built from drizzle's `and`/`or`/`gt`/`lt`
1233
- comparisons (not SQL row-value syntax), so mixed-direction `orderBy` works
1234
- - `NULL`s in ordered columns follow each dialect's native `ORDER BY` placement —
1235
- PostgreSQL sorts them largest (last in `asc`), MySQL and SQLite smallest (first in
1236
- `asc`) — and the predicate matches, so rows with `NULL` in an ordered column are paged
1237
- through, not skipped
1238
- - Dates and bigints round-trip losslessly through the cursor
1239
- - A cursor is only valid under the ordering it was issued for — reusing it with a
1240
- different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
1241
- - `after` cannot be combined with `distinct` or with `matchFilterOrder`, and a table with
1242
- no primary key cannot use cursor pagination (its `cursor` field resolves to `null`)
1243
- - If a table has a real column named `cursor`, the column wins and the meta field is
1244
- skipped
1245
-
1246
- To-many relation fields take `after` and `distinct` too, with the same meaning they have on
1247
- a root list of the same table — a relation field is a list of the target table, so it gets
1248
- that table's whole pagination surface:
1249
-
1250
- ```graphql
1251
- {
1252
- users {
1253
- id
1254
- posts(orderBy: { createdAt: { direction: desc, priority: 1 } }, limit: 10, after: "…") {
1255
- id
1256
- cursor
1257
- }
1258
- }
1259
- }
1260
- ```
1261
-
1262
- Drizzle's `with:` clause cannot express either one — keyset needs a predicate over the
1263
- request's own total order, `distinct` needs a window pass, and a `cursor` has to be computed
1264
- from the raw row. So a relation field that is passed `after` or `distinct`, or that selects
1265
- `cursor`, drops out of the eager fetch and resolves through the request-scoped batch loader
1266
- instead, which implements all three. That is still **one query per relation** across every
1267
- parent in the batch (`distinct` adds its key-selection pass, as on a root list) — never one
1268
- per parent. Nothing about the arguments changes; only how many round-trips the request
1269
- costs.
1270
-
1271
-
1272
- ## Upsert
1273
-
1274
- `features.upsert` adds a pair of mutations per table that insert rows, or update the ones
1275
- that already exist:
1276
-
1277
- ```graphql
1278
- mutation {
1279
- upsertUsersSingle(values: { id: 1, name: "Dan", email: "dan@example.com" }) {
1280
- id
1281
- name
1282
- }
1283
- }
1284
- ```
1285
-
1286
- With no `onConflict`, a conflict on the **primary key** overwrites every column the request
1287
- supplied. `onConflict` changes that:
1288
-
1289
- ```graphql
1290
- mutation {
1291
- upsertUsers(
1292
- values: [
1293
- { email: "dan@example.com", name: "Dan", visits: 1 }
1294
- { email: "sam@example.com", name: "Sam", visits: 1 }
1295
- ]
1296
- onConflict: {
1297
- target: [email] # must be a unique constraint; defaults to the primary key
1298
- action: UPDATE # or NOTHING, to keep the existing row
1299
- update: [name] # columns to overwrite; defaults to every supplied column
1300
- where: { isConfirmed: { eq: true } } # only overwrite rows that match
1301
- }
1302
- ) {
1303
- id
1304
- name
1305
- }
1306
- }
1307
- ```
1308
-
1309
- - A batch upsert updates each row with **its own** values (`excluded.<column>`), not with
1310
- the last row's
1311
- - Columns the request did not supply are never overwritten — a partial upsert does not null
1312
- out the rest of the row. Listing an unsupplied column in `update` is an error rather than
1313
- a silent no-op
1314
- - `target` must match one of the table's unique constraints exactly; anything else is
1315
- rejected with the list of valid targets, instead of the database's opaque "no unique or
1316
- exclusion constraint matching" error
1317
- - `action: NOTHING` inserts nothing and returns nothing for the conflicting row. An `UPDATE`
1318
- with no columns left to write degrades to the same thing
1319
- - `values` is the same `Create<Type>Input` the insert mutations take, so turning `insert`
1320
- off does not remove it
1321
-
1322
- ### A null key is an absent key
1323
-
1324
- Which half of an upsert runs is decided by the key the request supplies, but a GraphQL client
1325
- cannot leave an input field out based on a variable's value. So one document carries both
1326
- halves with one nullable variable:
1327
-
1328
- ```graphql
1329
- mutation Save($id: Int, $name: String!) {
1330
- upsertUsersSingle(values: { id: $id, name: $name }) {
1331
- id
1332
- }
1333
- }
1334
- ```
1335
-
1336
- `$id: null` inserts a new row; `$id: 1` overwrites row 1. An explicit `null` for a NOT NULL
1337
- column is treated as if the field had been left out, so the column's default fills it in —
1338
- the column cannot store null, so null cannot have been meant as the value to write. It works
1339
- the same for a database default (`serial`, `defaultRandom()`), a drizzle-side one
1340
- (`$defaultFn`), the plain `create*` mutations, and a batch where only some rows carry a key.
1341
-
1342
- The key has to have *something* to fill it in: a primary key declared with no default at all
1343
- stays non-null on the create input, so a nullable variable in that position is a validation
1344
- error at query time rather than a NOT NULL violation from the database.
1345
-
1346
- Dialect differences:
1347
-
1348
- - **PostgreSQL** and **SQLite** — the full surface above. A table with no primary key and no
1349
- unique constraint has nothing to conflict on, so it gets no upsert mutations at all
1350
- - **MySQL** — `ON DUPLICATE KEY UPDATE` fires on whichever unique key was violated and takes
1351
- no predicate, so `<Type>OnConflict` there has only `action` and `update`. `action: NOTHING`
1352
- becomes `INSERT IGNORE`, and the mutations return `MutationReturn` like every other MySQL
1353
- mutation
1354
-
1355
- The build-wide `conflictDoNothing` option is deprecated in favour of this: it applies to every
1356
- `create*` mutation with no way for a request to opt out. `onConflict: { action: NOTHING }` is
1357
- the per-request replacement.
1358
-
1359
- ## Nested writes
1360
-
1361
- `features.nestedWrites` adds a field per writable relation to every create and update input,
1362
- so a row and the rows it is related to can be written in one mutation:
1363
-
1364
- ```graphql
1365
- mutation {
1366
- createPostsSingle(
1367
- values: {
1368
- title: "Hello"
1369
- author: { connect: { email: { eq: "dan@example.com" } } }
1370
- comments: { create: [{ body: "First" }, { body: "Second" }] }
1371
- }
1372
- ) {
1373
- id
1374
- author {
1375
- name
1376
- }
1377
- }
1378
- }
1379
- ```
1380
-
1381
- Every relation field offers `create` and `connect`; on the update input, a relation whose
1382
- foreign key is nullable also offers `disconnect` and — for a to-many relation — `set`:
1383
-
1384
- ```graphql
1385
- mutation {
1386
- updateUsersSingle(
1387
- where: { id: { eq: 1 } }
1388
- set: { posts: { set: [{ isPublished: { eq: true } }], create: [{ title: "New" }] } }
1389
- ) {
1390
- id
1391
- }
1392
- }
1393
- ```
1394
-
1395
- - `connect`, `disconnect` and `set` take the target's own `Filters` input, so they select
1396
- rows the same way a query does. A filter that matches everything is rejected rather than
1397
- attaching the whole table
1398
- - `set` replaces the relation wholesale — everything attached is detached first, then the
1399
- filters attach. It runs before `create`, so a row created in the same write survives it
1400
- - A to-one relation takes one operation at a time (`connect` **or** `disconnect` **or**
1401
- `create`), and attaching to it detaches whatever was there
1402
- - `disconnect` and `set` only exist where the foreign key is nullable; there is no way to
1403
- detach a row whose key is `NOT NULL`
1404
- - A column the relation fills in becomes nullable on the create input — with
1405
- `author: { create: … }` available, `authorId` is one of two ways to supply it
1406
- - Nesting is one level deep: the row a nested `create` inserts takes columns only, and the
1407
- join column is left off, since the write it is part of sets it
1408
- - The generated input types are `<Type><Relation>NestedCreateInput` and
1409
- `<Type><Relation>NestedUpdateInput` for the operations, and
1410
- `<Type><Relation>NestedCreatePayloadInput` for the row a `create` inserts — all named
1411
- outside the root input namespace, so a relation whose name spells a sibling table's
1412
- (`item.type` beside a table named `itemType`) is not a collision
1413
- - Many-to-many (`.through()`) relations are written through their junction table — see
1414
- below
1415
- - The whole tree runs in one transaction — a savepoint when the request already carries one
1416
- — so a failure anywhere leaves nothing behind
1417
- - A `set` that carries only relation fields is a valid update: it changes what the row is
1418
- attached to without rewriting the row
1419
-
1420
- ### Many-to-many relations
1421
-
1422
- A relation declared with `.through()` is written by inserting and deleting rows of its
1423
- junction table, so nothing about the two rows being linked changes:
1424
-
1425
- ```graphql
1426
- mutation {
1427
- updatePostsSingle(
1428
- where: { id: { eq: 1 } }
1429
- set: { tags: { set: [{ name: { inArray: ["news", "release"] } }] } }
1430
- ) {
1431
- id
1432
- }
1433
- }
1434
- ```
1435
-
1436
- - It offers `connect` on the create input, and `connect`, `disconnect` and `set` on the
1437
- update input. All three take the target's `Filters`, exactly as on a foreign-key relation
1438
- - `disconnect` and `set` are offered unconditionally here: unlinking deletes a junction row
1439
- rather than writing `NULL` into a column that might be `NOT NULL`
1440
- - `connect` links every row its filters match and leaves already-linked rows alone, so
1441
- re-connecting is a no-op rather than a unique-constraint error
1442
- - `disconnect` deletes only the links, never the rows they pointed at
1443
- - `set: []` unlinks everything and links nothing back, which is how a many-to-many relation
1444
- is cleared
1445
- - There is no `create`: inserting a row *and* linking it is a write to two tables, and the
1446
- link is what the relation field is for. Create the row with its own mutation, then
1447
- `connect` it
1448
- - A relation is left out when its junction carries a further `NOT NULL` column with no
1449
- default — a filter alone could not supply a value for it — and when it is declared as a
1450
- to-one, whose "exactly one" guarantee lives in the junction's constraints rather than in
1451
- the relation
1452
-
1453
- Dialect support: **PostgreSQL**, and **SQLite** on an asynchronous driver (libsql, D1). A
1454
- nested write reads back the key of the row it just wrote, which MySQL has no `RETURNING` for,
1455
- and interleaves awaited statements inside a transaction, which a synchronous SQLite driver
1456
- cannot do — `buildSchema` rejects both combinations rather than generating something that
1457
- half-works.
1458
-
1459
- ## Atomic column updates
1460
-
1461
- `Update<Table>Input` sets columns to values, so "increment the view count" is a read followed
1462
- by a write of the number the read returned — two round-trips, and a lost update whenever two
1463
- clients read the same number. `features.fieldUpdateOperations` replaces qualifying columns'
1464
- update fields with an operations input, so the arithmetic happens in the database:
1465
-
1466
- ```graphql
1467
- mutation {
1468
- updatePostsSingle(where: { id: { eq: 1 } }, set: { views: { increment: 1 } }) {
1469
- views
1470
- }
1471
- }
1472
- ```
1473
-
1474
- compiles to `SET views = views + 1`. The flag changes the update inputs only; insert inputs
1475
- are untouched.
1476
-
1477
- - **Numeric columns** (`Int`, `Float`, `BigInt`, `Decimal`) take
1478
- `set` / `increment` / `decrement` / `multiply` / `divide`
1479
- - **Array columns** take `set` / `push`, where `push` appends to the existing array
1480
- - Exactly one operation per column. Passing two, or `{}`, is a `GraphQLError` naming the
1481
- column — graphql-js has no `@oneOf` on the version range this library supports, so it is
1482
- enforced at resolve time rather than by the type
1483
- - `set` is the explicit spelling of the plain assignment the field had before the flag, so
1484
- every existing update has a one-key rewrite: `{ views: 5 }` → `{ views: { set: 5 } }`
1485
- - The input types are named for the **scalar**, not the column — every `Int` column in the
1486
- schema shares one `IntFieldUpdate` — and a column claimed by a `scalarOverrides` entry
1487
- keeps its plain field, since the override decides what that column accepts
1488
-
1489
- Two things follow the database's own semantics rather than being guarded:
1490
-
1491
- - `increment`/`multiply` on an integer column can overflow, and `divide` on one **rounds**
1492
- the way the dialect rounds (PostgreSQL truncates toward zero; use a `Float` or `Decimal`
1493
- column if you need the fraction)
1494
- - `divide: 0` raises the driver's division-by-zero error, surfaced as a `GraphQLError`
1495
-
1496
- ## Row counts instead of rows
1497
-
1498
- The plural `update<Table>` / `delete<Table>` mutations always `RETURNING *`, which is wasted
1499
- work — and wasted bandwidth — when a client only wants to know how many rows were touched.
1500
- `features.countMutations` adds a count-returning variant of each:
1501
-
1502
- ```graphql
1503
- mutation {
1504
- deletePostsCount(where: { status: { eq: "spam" } })
1505
- updatePostsCount(where: { draft: { eq: true } }, set: { draft: false })
1506
- }
1507
- ```
1508
-
1509
- - Both return `Int!` — the number of rows the write affected — and add no new output type
1510
- to the schema
1511
- - Arguments are exactly the plural mutation's, so a client swaps one field name for the
1512
- other; `features.requireWhere` applies to them the same way
1513
- - They follow the feature switch of the write they mirror: `updateCount` needs
1514
- `features.update`, `deleteCount` needs `features.delete`
1515
- - **MySQL** gets them too, and they are arguably most useful there: MySQL's other mutations
1516
- return only `MutationReturn { isSuccess }`, so a row count is strictly more information
1517
- - `update<Table>Count` **rejects** nested writes with a `GraphQLError` — a nested write
1518
- needs the parent rows this mutation deliberately does not read back. Use the plural
1519
- mutation for those
1520
- - The count is read from whichever field the driver reports it in (`rowCount`,
1521
- `affectedRows`, `rowsAffected`, `changes`, `count`); a driver that reports none is a
1522
- `GraphQLError` rather than a silent `0`
1523
-
1524
- ## Single-row update & delete
1525
-
1526
- Alongside the plural `update<Table>` / `delete<Table>` mutations, every table gets an
1527
- `update<Table>Single` / `delete<Table>Single` variant that targets exactly one row:
1528
-
1529
- ```graphql
1530
- mutation {
1531
- updateUsersSingle(where: { id: { eq: 1 } }, set: { name: "Dan" }) {
1532
- id
1533
- name
1534
- }
1535
- }
1536
- ```
1537
-
1538
- - `where` is **non-null** and must contain at least one filter — `where: {}` is rejected at
1539
- resolve time, so a Single write can never become an unbounded one
1540
- - The return type is the single (nullable) row type, not a list: the affected row comes back
1541
- directly, and **no match returns `null`** instead of an empty list
1542
- - If `where` matches **more than one row**, the mutation throws a `GraphQLError` and writes
1543
- nothing — the match is checked (`LIMIT 2`) before the write runs
1544
- - **MySQL** cannot return the affected row (no `RETURNING`), so its Single variants keep the
1545
- single-match and non-empty-`where` guarantees but return `MutationReturn` like every other
1546
- MySQL mutation
1547
- - The variants are generated under `features.update` / `features.delete`, share the plural
1548
- mutations' input types, and appear in `entities.mutations` for custom schemas
1549
-
1550
- ### Mutation return shapes
1551
-
1552
- Every mutation that returns rows returns them non-null, with two exceptions that are
1553
- deliberate rather than accidental:
1554
-
1555
- | Mutation | Returns | Why |
1556
- | --- | --- | --- |
1557
- | `create<Table>Single` | `Table!`, or `Table` under `conflictDoNothing` | An insert of one row returns that row. It can only fail to produce one when the `conflictDoNothing` build option swallows a conflict, so it is nullable exactly when that option is set |
1558
- | `update<Table>Many` | `[Table]!` | Each entry's updated rows **in entry order**. An entry whose `where` matched nothing contributes `null` in its slot, so the response lines up with the request positionally — the nullable element is the whole point |
1559
- | everything else | `[Table!]!` / `Table!` / `Table` | — |
1560
-
1561
- The plural mutations still accept a missing `where` (a full-table write) by default. To rule
1562
- that out at the type level, `features: { requireWhere: true }` makes `where` non-null on
1563
- `update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
1564
- Single variants. It defaults to `false` for backwards compatibility.
1565
-
1566
- ## Bounding page size
1567
-
1568
- Nothing stops a client asking a list query for every row it has. `limits` sets a page size for
1569
- a request that names none, a ceiling for one that does, or both:
1570
-
1571
- ```ts
1572
- buildSchema(db, {
1573
- limits: {
1574
- defaultLimit: 25,
1575
- maxLimit: 100,
1576
- tables: {
1577
- Posts: { defaultLimit: 50 },
1578
- AuditLog: { maxLimit: 10, clampToMax: true },
1579
- },
1580
- },
1581
- });
1582
- ```
1583
-
1584
- | Key | Effect |
1585
- | --- | --- |
1586
- | `defaultLimit` | The `limit` applied when the request passes none |
1587
- | `maxLimit` | The largest `limit` a request may ask for |
1588
- | `clampToMax` | `false` (default) rejects a `limit` above `maxLimit`; `true` reduces it |
1589
- | `tables` | Per-table overrides, keyed by the table's key in your Drizzle schema |
1590
-
1591
- A per-table policy overrides the global one key by key, so `AuditLog` above still inherits
1592
- `defaultLimit: 25`. Everything is optional; a build with no `limits` behaves exactly as before.
1593
-
1594
- Two details worth stating plainly:
1595
-
1596
- - **`maxLimit` alone also bounds a request that passes no `limit`.** No limit means every
1597
- row, which is above any maximum, so such a request runs at `maxLimit`.
1598
- - **A rejection is the default because truncation lies.** Silently cutting a page down to
1599
- `maxLimit` tells a paginating client it has reached the end when it has not. `clampToMax:
1600
- true` opts into that when you would rather never fail a query.
1601
-
1602
- The policy applies to root list queries and to to-many relation fields, on both the eager and
1603
- the lazy relation paths. A relation takes the policy of the table it *reads*, not its parent's:
1604
- with `tables: { Posts: { maxLimit: 10 } }`, `users { posts(limit: 50) }` is rejected. Cursor
1605
- pagination inherits it — a page defaults to `defaultLimit` and the `cursor` values stay usable.
1606
-
1607
- What it does not touch: `<tableName>Single` queries, which are already one row; aggregate,
1608
- group-by and `<relation>Aggregate` fields, which count over the whole table rather than
1609
- returning rows; and to-one relation fields, which take no `limit` at all.
1610
-
1611
- ## Query cost
1612
-
1613
- Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
1614
- [`graphql-query-complexity`](https://github.com/slicknode/graphql-query-complexity)'s
1615
- `fieldExtensionsEstimator`. The generator knows which fields are paginated and which are
1616
- aggregates, so the cost tracks the rows a query can actually pull rather than the number of
1617
- fields it mentions:
1618
-
1619
- | Field | Cost |
1620
- | --- | --- |
1621
- | List query, to-many relation | `(limit ?? defaultListSize) * childComplexity` |
1622
- | Aggregate query, group-by query, `<relation>Aggregate` | `aggregateCost + childComplexity` |
1623
- | Everything else | no hint — your estimator's default applies |
1624
-
1625
- So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
1626
- same query without limits costs `10 * (1 + 10 * 1)` = 110.
1627
-
1628
- A [limit policy](#bounding-page-size) feeds into the hint: a field with no `limit` is priced at
1629
- its `defaultLimit` rather than `defaultListSize`, and nothing is priced above `maxLimit`, so the
1630
- cost matches the rows the query can actually pull.
1631
-
1632
- The hints do nothing until you install a complexity rule, so they are generated by default:
1633
-
1634
- ```ts
1635
- import { createComplexityRule, fieldExtensionsEstimator, simpleEstimator } from 'graphql-query-complexity';
1636
-
1637
- const rule = createComplexityRule({
1638
- maximumComplexity: 1000,
1639
- estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
1640
- });
1641
- ```
1642
-
1643
- Tune the two assumptions, or turn the hints off entirely:
1644
-
1645
- ```ts
1646
- buildSchema(db, {
1647
- complexity: { defaultListSize: 25, aggregateCost: 50 },
1648
- });
1649
-
1650
- buildSchema(db, { complexity: false });
1651
- ```
1652
-
1653
- ### Depth
1654
-
1655
- Cost is not a depth bound. A cyclic relation graph (`user -> posts -> author -> posts -> …`)
1656
- lets a client nest as deep as it likes, and a deep query over cheap fields can stay under any
1657
- complexity ceiling. Set `relationsDepthLimit: 0` to generate no relation fields at all, or —
1658
- more usefully — bound depth at the edge, along with the rest of the document-shape defences
1659
- below.
1660
-
1661
- ### Hardening a public endpoint
1662
-
1663
- A generated schema is wide by construction: every table is reachable, every relation is
1664
- traversable, and the relation graph is usually cyclic. The `complexity` hints price that
1665
- surface, but only for the shapes the generator knows about, and nothing in this library
1666
- inspects the incoming document. For a schema exposed to untrusted clients, put
1667
- [GraphQL Armor](https://github.com/Escape-Technologies/graphql-armor) in front of it — it
1668
- bundles the document-level defences this library deliberately does not generate:
1669
-
1670
- | Protection | What it bounds |
1671
- | --- | --- |
1672
- | `maxDepth` | nesting through a cyclic relation graph |
1673
- | `maxAliases` | the same expensive list requested many times under many aliases |
1674
- | `maxDirectives` | directive-count amplification |
1675
- | `maxTokens` | parser blow-up, rejected before validation runs |
1676
- | `costLimit` | a static per-document cost ceiling |
1677
- | `blockFieldSuggestion` | `Did you mean "keyHash"?` leaking field names on a typo |
1678
-
1679
- ```ts
1680
- import { EnvelopArmor } from '@escape.tech/graphql-armor';
1681
- import { createYoga } from 'graphql-yoga';
1682
-
1683
- const { schema } = buildSchema(db);
1684
- const armor = new EnvelopArmor({
1685
- maxDepth: { n: 8 }, // roughly three relation hops into the generated graph
1686
- maxAliases: { n: 15 },
1687
- costLimit: { maxCost: 5000 },
1688
- });
1689
-
1690
- const yoga = createYoga({ schema, plugins: armor.protect().plugins });
1691
- ```
1692
-
1693
- `ApolloArmor` is the Apollo Server equivalent — its `protect()` returns `plugins` plus
1694
- `validationRules`.
1695
-
1696
- Armor's `costLimit` is its own document-shape estimate and does not read the `complexity`
1697
- extensions above; the two compose, if you want a cheap static ceiling alongside row-count
1698
- aware pricing. Neither one bounds an individual `limit` argument, so a single unbounded list
1699
- query is still a full table scan — cap `limit` yourself if that matters.
1700
-
1701
- ## Authorization
1702
-
1703
- `buildSchema` is handed a Drizzle instance, not a request. It has no notion of who is asking,
1704
- so it generates no authorization: every table it knows about is queryable and writable by
1705
- anyone who can reach the endpoint. Auth belongs to a layer above the schema, where the
1706
- request context actually lives.
1707
-
1708
- - **[graphql-shield](https://github.com/maticzav/graphql-shield)** — permission rules
1709
- mapped onto fields and applied with
1710
- [`graphql-middleware`](https://github.com/maticzav/graphql-middleware). Rules attach to
1711
- root fields *and* to fields of a generated object type, which is what makes it an answer
1712
- to "this column must never leave the server" and not just "this query is blocked":
1713
-
1714
- ```ts
1715
- import { applyMiddleware } from 'graphql-middleware';
1716
- import { allow, deny, rule, shield } from 'graphql-shield';
1717
-
1718
- const isAuthenticated = rule()((parent, args, ctx) => Boolean(ctx.userId));
1719
-
1720
- const { schema } = buildSchema(db);
1721
- const guarded = applyMiddleware(
1722
- schema,
1723
- shield(
1724
- {
1725
- Query: { users: isAuthenticated, usersSingle: isAuthenticated },
1726
- Mutation: { '*': isAuthenticated },
1727
- ApiKeys: { keyHash: deny }, // field stays in the schema, never resolves
1728
- },
1729
- { fallbackRule: allow },
1730
- ),
1731
- );
1732
- ```
1733
-
1734
- - **[CASL](https://casl.js.org)** (`@casl/ability`) — one ability definition, conditions and
1735
- field lists included (`can('read', 'Posts', ['title', 'body'])`), shared between the
1736
- GraphQL layer and the rest of the app instead of a permission model that exists only in
1737
- the API. CASL is not GraphQL middleware itself: wire it in through the shield rules above
1738
- or through your own resolver wrapper, and let the ability answer the questions.
1739
-
1740
- What neither can do is **narrow** a query. They permit or deny a field; they do not append
1741
- `WHERE user_id = …` to the SQL a generated resolver runs, and a permitted `users` query still
1742
- returns every tenant's rows. Until row scoping is a first-class option here, the ways to get
1743
- it are, in increasing order of effort:
1744
-
1745
- - supply the tenant predicate as an ordinary `where` argument from a trusted gateway, and
1746
- deny the raw generated fields to everyone else;
1747
- - build the schema by hand from `entities`, exposing only the generated resolvers you have
1748
- wrapped;
1749
- - put the scoped tables behind database row-level security and give the driver the
1750
- request's identity, so the narrowing happens below Drizzle entirely.
1751
-
1752
- None of this removes anything from introspection either — `deny` hides the data, not the
1753
- shape. If the schema must not so much as mention a table, keep that table out of the Drizzle
1754
- instance you pass to `buildSchema` (a second instance over a narrowed schema object is
1755
- cheap).
1756
-
1757
- Nor is it a row bound: a complexity ceiling prices the `limit` a client asks for, it does not
1758
- cap it. Use [`limits`](#bounding-page-size) for that.
1759
-
1760
- ## Error handling
1761
-
1762
- Database drivers put a lot into an error message. Drizzle rethrows them with the full SQL
1763
- statement and its bound parameters attached, and Postgres itself names the table, the column
1764
- and the constraint that was violated. None of that belongs in a GraphQL response, so by
1765
- default every error a generated resolver throws is passed through a sanitizer:
1766
-
1767
- - errors drizzle-graphql raises itself (`Unable to update with no values specified!`,
1768
- `Field 'x' is not a valid date!`, filter misuse, …) are written for the client and pass
1769
- through unchanged
1770
- - anything else becomes `Internal server error` with `extensions.code:
1771
- "INTERNAL_SERVER_ERROR"`, and the original is kept on the error's `originalError` so a
1772
- server-side logger can still see it
1773
-
1774
- `onError` overrides this. Return an error to surface that one, or return nothing to let the
1775
- default apply — which makes it a pure logging hook:
1776
-
1777
- ```Typescript
1778
- const { schema } = buildSchema(db, {
1779
- onError: (error) => {
1780
- logger.error({ err: error }, 'drizzle-graphql resolver failed')
1781
- // no return value — the default sanitizing still applies
1782
- },
1783
- })
1784
- ```
1785
-
1786
- ```Typescript
1787
- // Surface raw database errors, e.g. in development
1788
- buildSchema(db, { onError: (error) => error as Error })
1789
-
1790
- // Or map them yourself
1791
- buildSchema(db, {
1792
- onError: (error) =>
1793
- isUniqueViolation(error)
1794
- ? new GraphQLError('That record already exists', { extensions: { code: 'CONFLICT' } })
1795
- : undefined,
1796
- })
1797
- ```
1798
-
1799
- The hook covers root queries and mutations, relation and aggregate fields, and the
1800
- standalone `entities.fieldResolvers`. The default is exported as `defaultErrorMapper` if you
1801
- want to fall back to it explicitly.
1802
-
1803
- ### What an error carries
1804
-
1805
- Every error the library raises itself carries its context as data, so nothing downstream has
1806
- to match on prose:
1807
-
1808
- ```json
1809
- {
1810
- "message": "'where' matched more than one row — nothing was written!",
1811
- "path": ["updateUser"],
1812
- "extensions": {
1813
- "code": "DRIZZLE_MULTI_ROW_MATCH",
1814
- "drizzle": { "table": "Users", "operation": "update", "field": "updateUsersSingle" }
1815
- }
1816
- }
1817
- ```
1818
-
1819
- - `code` classifies the failure — `DRIZZLE_MULTI_ROW_MATCH`, `DRIZZLE_WHERE_REQUIRED`,
1820
- `DRIZZLE_LIMIT_EXCEEDED`, `DRIZZLE_NO_VALUES`, `DRIZZLE_INVALID_FILTER`,
1821
- `DRIZZLE_INVALID_CURSOR`, `DRIZZLE_INVALID_INPUT_VALUE`, and so on. The full union is
1822
- exported as `DrizzleErrorCode`.
1823
- - `drizzle` says what it was about: the Drizzle schema key of the `table`, the `operation`,
1824
- the generated `field`, and the `relation` for a relation field. The type is exported as
1825
- `DrizzleErrorContext`.
1826
-
1827
- A value the request could not supply is `DRIZZLE_INVALID_INPUT_VALUE` — a date that does not
1828
- parse, a `BigInt` argument that is not an integer, a malformed JSON column value — and a write
1829
- naming something that is not a column of its table is `DRIZZLE_UNKNOWN_COLUMN`. The mirror
1830
- image, a stored value the scalar transporting it cannot represent, is
1831
- `DRIZZLE_UNREPRESENTABLE_VALUE`; it says the data is out of range, not that the request was
1832
- wrong.
1833
-
1834
- The `drizzle` block is only on errors raised from inside a field. An argument rejected while
1835
- GraphQL coerces the request — a `BigInt` variable that is not an integer, say — is rejected
1836
- before any resolver runs, so it carries its `code` but no `drizzle` and no `path`.
1837
-
1838
- The **generated** field name is in `extensions.drizzle.field` rather than in the message on
1839
- purpose. A schema that republishes these fields under other names — `RenameRootFields`, a
1840
- stitched gateway, a hand-written façade — renames the schema, not the inside of a message, so
1841
- a message naming `updateUsersSingle` would be telling a client about a field it has never
1842
- heard of. The name the client asked for is in `path`, already correct under any rename.
1843
-
1844
- Driver errors are not classified: they are sanitized as above and keep
1845
- `code: "INTERNAL_SERVER_ERROR"` with no `drizzle` block, which is also how `onError` can tell
1846
- the two apart without inspecting messages.
1847
-
1848
- ## Transactions
1849
-
1850
- Each resolver runs its statements on the database the schema was built from, so by default a
1851
- request that fires several mutations commits each one separately and a failure halfway leaves
1852
- the earlier ones in place. There are two ways to make a request atomic.
1853
-
1854
- ### Letting the library open one
1855
-
1856
- `transactions: 'auto'` opens `db.transaction()` once per request whose document selects more
1857
- than one root mutation field, and runs every mutation field — plus the reads nested under
1858
- them — inside it:
1859
-
1860
- ```Typescript
1861
- const { schema } = buildSchema(db, { transactions: 'auto' })
1862
-
1863
- // or, to change the safety timeout:
1864
- const { schema } = buildSchema(db, { transactions: { mode: 'auto', timeoutMs: 10_000 } })
1865
- ```
1866
-
1867
- The transaction commits when the last mutation field completes and rolls back if any of them
1868
- fails; mutation fields after a failure fail fast instead of running against a rolled-back
1869
- transaction. A document with one mutation field or none opens nothing, so ordinary requests
1870
- are untouched.
1871
-
1872
- - The driver must support `db.transaction()` — `neon-http` does not. For SQLite only an
1873
- asynchronous driver can hold a transaction open across resolvers, and `buildSchema`
1874
- throws for a synchronous one rather than failing at request time
1875
- - The GraphQL context value must be a fresh object per request (every mainstream server
1876
- does this) — it keys the per-request transaction state
1877
- - `timeoutMs` (default `30000`) rolls back an abandoned transaction, e.g. when the host
1878
- server stopped calling resolvers because of a non-null completion error, instead of
1879
- leaking the connection
1880
- - A document that mixes in mutation fields this build did not generate is left alone: their
1881
- completion cannot be tracked, so no transaction is opened
1882
- - It never nests — an executor supplied on the context wins, as below
1883
-
1884
- ### Supplying your own executor
1885
-
1886
- To control the boundary yourself, open a transaction and put it on the GraphQL context under
1887
- the exported `drizzleExecutorKey`:
1888
-
1889
- ```Typescript
1890
- import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
1891
-
1892
- const { schema } = buildSchema(db)
1893
-
1894
- await db.transaction(async (tx) => {
1895
- const result = await graphql({
1896
- schema,
1897
- source: request.query,
1898
- variableValues: request.variables,
1899
- contextValue: { [drizzleExecutorKey]: tx },
1900
- })
1901
-
1902
- if (result.errors?.length) throw new Error('rolling back')
1903
- return result
1904
- })
1905
- ```
1906
-
1907
- Every generated resolver reads the key at resolve time, so queries, mutations, aggregates
1908
- and relation field resolvers all run on the executor you supply and see its uncommitted
1909
- rows. With no key on the context, everything falls back to the build-time database, which
1910
- is what an ordinary request does.
1911
-
1912
- The value does not have to be a transaction — any object with the same interface works, for
1913
- example a pooled connection bound to a tenant, or a logging proxy around `db`. Because the
1914
- key is created with `Symbol.for`, the ESM and CJS builds of this package agree on it when
1915
- both end up loaded in one process.
1916
-
1917
- Or let the library open one per request: `transactions: 'auto'` wraps a document that fires
1918
- more than one generated mutation in a single transaction, committed when the last one
1919
- completes and rolled back if any of them fails.
1920
-
1921
- ```Typescript
1922
- buildSchema(db, { transactions: 'auto' })
1923
- ```
1924
-
1925
- A document with one mutation, or one that selects a mutation field this build did not
1926
- generate, still runs unwrapped — there is nothing to keep atomic.
1927
-
1928
- ## Write hooks
1929
-
1930
- `onWrite` runs a hook **inside the mutation's transaction**, so its writes commit or roll back
1931
- with the mutation itself. That is the position an audit row, an outbox row or a denormalized
1932
- counter needs, and the one a `graphql-middleware` wrapper cannot occupy — a wrapper sits
1933
- outside the resolver, so its after-position is after the commit.
1934
-
1935
- ```Typescript
1936
- const { schema } = buildSchema(db, {
1937
- onWrite: {
1938
- posts: async ({ table, operation, rows, tx }) => {
1939
- await tx.insert(auditLog).values(rows.map((row) => ({ table, operation, rowId: row.id })))
1940
- },
1941
- },
1942
- })
1943
- ```
1944
-
1945
- A bare function is the **`after`** hook — the position that has rows. `{ before, after }` names
1946
- them explicitly:
1947
-
1948
- ```Typescript
1949
- onWrite: {
1950
- posts: {
1951
- // Reads inside the same snapshot the write will use.
1952
- before: async ({ args, tx }) => {
1953
- const [parent] = await tx.select().from(orgs).where(eq(orgs.id, args.values[0].orgId))
1954
- if (!parent) throw new Error('org went away')
1955
- },
1956
- after: async ({ rows, tx }) => { /* … */ },
1957
- },
1958
- }
1959
- ```
1960
-
1961
- Registering a bare function (or a positions object) at the top level applies it to every table;
1962
- anything else is read as a table map keyed by the **Drizzle schema key**, and a name that
1963
- matches nothing throws at build time.
1964
-
1965
- The payload:
1966
-
1967
- | Field | |
1968
- | --- | --- |
1969
- | `table` | Drizzle schema key of the table being written |
1970
- | `operation` | `'insert' \| 'update' \| 'updateMany' \| 'upsert' \| 'delete' \| 'restore'` |
1971
- | `position` | `'before'` or `'after'` |
1972
- | `single` | whether the field writes one row or a list |
1973
- | `args` | the field's GraphQL arguments, as the resolver received them |
1974
- | `rows` | the post-write rows **as the database returned them**, before the output mapper |
1975
- | `context`, `info` | the GraphQL context value and resolve info |
1976
- | `tx` | the executor the mutation ran on |
1977
-
1978
- - **`tx` is the executor the mutation itself used** — one you supplied under
1979
- `drizzleExecutorKey`, the request's auto-transaction, or, for a field that would otherwise
1980
- have run unwrapped, a transaction opened for it *because* the hook is there. The hook does
1981
- not have to know which.
1982
- - **Throwing rolls the mutation back.** A hook that can only log is what a logging plugin
1983
- already does.
1984
- - **`rows` is empty** at the `before` position, and on MySQL, whose mutations return no rows.
1985
- The `before` position and `args` still work there.
1986
- - The hook fires for the **mutation field's own table**. Rows a nested `create` writes into
1987
- another table do not fire that table's hook.
1988
-
1989
- A build with no `onWrite` opens exactly the transactions it did before.
1990
-
1991
-
1992
- ## Relations & N+1 handling
1993
-
1994
- Generated schemas resolve nested relations without N+1 query explosions:
1995
-
1996
- - **Queries** — root queries (`entities.queries.*`) eagerly load every requested
1997
- relation in a single round-trip using Drizzle's relational query builder
1998
- (`with:`), including nested relations and per-relation `where` / `orderBy` /
1999
- `limit` / `offset` arguments.
2000
- - **Mutations (PostgreSQL & SQLite)** — after an insert or update, if the selection set
2001
- includes relation fields, the affected rows are re-fetched by primary key through one
2002
- relational query so their relations are eagerly loaded (single- and composite-column
2003
- primary keys are supported). `delete` mutations keep using the request-scoped batch
2004
- loader, since the rows no longer exist to re-fetch. MySQL mutations return only a
2005
- success flag (no row payload), so this step does not apply there.
2006
- - **Custom schemas** — each relation field also has a standalone resolver, exported as
2007
- `entities.fieldResolvers[TableName][relationName]`. When you wire generated types
2008
- into your own schema and your root resolver does **not** pre-fetch relations, these
2009
- resolvers batch all sibling loads in a request into a single `IN (…)` query
2010
- (request-scoped, keyed on the GraphQL `context`), preventing N+1. Per-parent
2011
- `limit` / `offset` on a to-many relation is applied across the whole batch using a
2012
- `ROW_NUMBER() OVER (PARTITION BY …)` window function — still one query, not one per
2013
- parent.
2014
-
2015
- ```Typescript
2016
- // entities.fieldResolvers is keyed by table name, then relation name
2017
- const usersPostsResolver = entities.fieldResolvers.Users.posts
2018
- ```
2019
-
2020
- > [!IMPORTANT]
2021
- > Per-parent paginated relations (a to-many relation field with `limit` or `offset`)
2022
- > rely on SQL window functions. These require **PostgreSQL**, **MySQL 8.0+**, or
2023
- > **SQLite 3.25+**. Relations without pagination, and all other query/mutation paths,
2024
- > have no such requirement.
2025
-
2026
- ### Relation nullability
2027
-
2028
- A **to-many** relation field is always `[Target!]!`. A **to-one** relation field is
2029
- nullable by default, but honors the relation's declared optionality: declaring
2030
- `optional: false` on a Drizzle `one` relation — the assertion that the related row always
2031
- exists, i.e. a `NOT NULL` foreign key — emits the field as `Target!`:
2032
-
2033
- ```Typescript
2034
- const relations = buildRelations({ Users, Posts }, {
2035
- Posts: {
2036
- // posts.author_id is NOT NULL → author: Users!
2037
- author: r.one.Users({ from: r.Posts.authorId, to: r.Users.id, optional: false }),
2038
- },
2039
- })
2040
- ```
2041
-
2042
- Only the explicit `optional: false` declaration is honored — required-ness is never
2043
- inferred from column nullability, since a `NOT NULL` `from` column does not guarantee a
2044
- related row exists when the constraint lives on the other side of the join. Note that on a
2045
- required relation, a `where` argument that filters out the related row (or a dangling
2046
- foreign key) resolves to `null` and therefore surfaces as a GraphQL non-null error.
2047
-
2048
- ### Overriding a relation's resolver without overfetching
2049
-
2050
- By default the eager `with:` pre-fetch is driven purely by the GraphQL selection set:
2051
- any selected relation is fetched from the database, even if you intend to resolve it
2052
- yourself (from a cache, another service, a dataloader, …). To override a relation's
2053
- resolver, first opt it out of eager loading with `eagerLoadRelations` so the parent
2054
- query stops fetching it, then supply your resolver with the standard
2055
- [`@graphql-tools/schema`](https://the-guild.dev/graphql/tools) utilities:
2056
-
2057
- ```Typescript
2058
- import { addResolversToSchema } from '@graphql-tools/schema'
2059
-
2060
- const { schema } = buildSchema(db, {
2061
- // Exclude Users.posts from the parent query's `with:` clause.
2062
- // Other relations keep eager-loading as usual.
2063
- eagerLoadRelations: (table, relation) => !(table === 'Users' && relation === 'posts'),
2064
- })
2065
-
2066
- const finalSchema = addResolversToSchema({
2067
- schema,
2068
- resolvers: {
2069
- Users: {
2070
- posts: (parent, args, context) => context.postsLoader.load(parent.id),
2071
- },
2072
- },
2073
- })
2074
- ```
2075
-
2076
- `eagerLoadRelations` accepts:
2077
-
2078
- - `true` (default) — eager-load every relation.
2079
- - `false` — never eager-load; every relation resolves lazily through its
2080
- (request-batched) field resolver.
2081
- - `(tableName, relationName) => boolean` — decide per relation. Returning `false`
2082
- excludes that relation from `with:` (and from the mutation eager re-fetch).
2083
-
2084
- Opting a relation out does **not** remove its field — it keeps resolving lazily via the
2085
- request-scoped batch loader, so the field still works even before you override it. Table
2086
- and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
2087
- of `entities.fieldResolvers`.
2088
-
2089
- ## Writing your own resolver over a generated type
2090
-
2091
- When you replace or wrap a generated resolver — to add a permission check that needs the
2092
- row, to run a write inside your own transaction, to enforce a rule the schema cannot
2093
- express — you become responsible for returning a row shaped like the type you replaced.
2094
- That means translating the GraphQL selection set into the relational query builder's
2095
- `with:` tree: relation field resolvers only fill in relations the parent did not already
2096
- provide.
2097
-
2098
- Two exports do that for you, alongside the already-exported `extractFilters` and
2099
- `extractOrderBy`:
2100
-
2101
- ```Typescript
2102
- import { resolveSelection, selectionToWith } from '@vantreeseba/drizzle-graphql'
2103
-
2104
- // The whole read: selected columns, relation arguments, eager loading, cursor
2105
- // pagination, and the transport remapping of the result — against any executor.
2106
- const rows = await resolveSelection(info, {
2107
- db,
2108
- table: 'Users',
2109
- where: { id: { eq: userId } },
2110
- executor: tx, // defaults to `db`; pass a transaction to see its own writes
2111
- })
2112
-
2113
- // Or just the `with:` tree, if you are building the query yourself.
2114
- const withTree = selectionToWith(info, { db, table: 'Users' })
2115
- const row = await tx.query.Users.findFirst({ where: { id: userId }, with: withTree })
2116
- ```
2117
-
2118
- `selectionToWith` resolves fragments and inline fragments against `info.fragments`,
2119
- intersects the selection against the relations config (so scalars and any fields you added
2120
- yourself drop out instead of becoming invalid `with:` keys), follows aliases back to the
2121
- relation they select, compiles each relation's `where` / `orderBy` / `limit` / `offset`
2122
- into its nested entry, and leaves `<relation>Aggregate` selections out — they are not
2123
- `with:` entries. It returns `undefined` when the selection asks for no relations.
2124
-
2125
- `resolveSelection` additionally accepts `single`, `orderBy`, `limit`, `offset` and `after`,
2126
- and returns the rows already remapped to their GraphQL transport forms.
2127
-
2128
- Both take a `typeNameMapper` option. If the schema was built with one, pass the same
2129
- function here — the translation matches selections against GraphQL type names, so a build
2130
- that renames its types has to say so.
2131
-
2132
- ## Identifying generated fields
2133
-
2134
- Every generated field and object type publishes what it is under `extensions.drizzle`, so
2135
- wrappers — `graphql-middleware`, envelop, `graphql-shield`, an audit log, a cache
2136
- invalidator — can ask the schema instead of parsing field names. Names are configurable
2137
- (`prefixes`, `suffixes`, `typeNameMapper`), which makes name parsing ambiguous by
2138
- construction; this is not.
2139
-
2140
- ```Typescript
2141
- import { drizzleExtension, identifyRows } from '@vantreeseba/drizzle-graphql'
2142
-
2143
- const field = info.parentType.getFields()[info.fieldName]
2144
- const meta = drizzleExtension(field)
2145
- // {
2146
- // table: 'Users', // the Drizzle schema key
2147
- // kind: 'mutation', // 'query' | 'mutation' | 'relation' | 'aggregate' | 'type'
2148
- // operation: 'update', // 'select' | 'insert' | 'update' | 'updateMany' | 'upsert'
2149
- // // | 'delete' | 'restore' | 'aggregate' | 'groupBy'
2150
- // // | 'relation'
2151
- // // | 'relationAggregate'
2152
- // single: true, // one row, or a list
2153
- // targetArg: 'where', // 'where' | 'values' | 'updates' — where the rows live
2154
- // primaryKey: ['id'], // key columns of `table`, in schema order
2155
- // }
2156
-
2157
- if (meta?.kind === 'mutation') await audit(meta.table, meta.operation, args[meta.targetArg])
2158
- ```
2159
-
2160
- `drizzleExtension` returns `undefined` for anything this library did not generate, which is
2161
- the check to make before treating a field as one of ours. Relation fields (and
2162
- `<relation>Aggregate` fields) additionally carry `relation` and `parentTable`, with `table`
2163
- naming the relation's **target** — so `Users.posts` reads as
2164
- `{ table: 'Posts', parentTable: 'Users', relation: 'posts' }`. Object types carry
2165
- `{ table, kind: 'type', primaryKey }`, which is what a stitching layer needs to map a type
2166
- back to its source.
2167
-
2168
- Because the row identity of a mutation lives in a different argument for every operation,
2169
- `identifyRows` reads whichever one applies and hands back primary-key values:
2170
-
2171
- ```Typescript
2172
- const target = identifyRows(field, args)
2173
- // { table: 'Users', primaryKey: ['id'], rows: [{ id: 3 }], complete: true }
2174
-
2175
- if (target?.complete) await invalidate(target.table, target.rows)
2176
- ```
2177
-
2178
- It understands `values` (one object or a list), `where` (`eq` for one row, `inArray` for
2179
- many, an equality on every column of a composite key), and the per-entry `where` inside a
2180
- batch update's `updates`. Check `complete` before treating `rows` as the affected set: it
2181
- is `false` when the arguments do not pin the rows down — an insert whose key the database
2182
- generates, a filter on something other than the primary key, an unbounded update, or a
2183
- table with no primary key at all.
2184
-
2185
- > [!NOTE]
2186
- > `extensions.drizzle` is stable API. The point of publishing it is that consumers stop
2187
- > parsing names, which only holds if the shape does not move: fields are added to it, never
2188
- > removed or repurposed.
2189
- ## Configuration reference
2190
-
2191
- Every key of the second argument to `buildSchema(db, config?)`. Each links to the section
2192
- that explains it, where there is one.
2193
-
2194
- | Key | Default | What it does |
2195
- | --- | --- | --- |
2196
- | `mutations` | `true` | `false` omits the `Mutation` type entirely |
2197
- | `features` | see [Choosing what gets generated](#choosing-what-gets-generated) | which operations are generated, per operation |
2198
- | `exclude` | none | [tables and columns left out](#excluding-tables-and-columns) of the generated schema entirely |
2199
- | `relationsDepthLimit` | unlimited | how deep relation fields are generated; `0` gives a flat, columns-only schema |
2200
- | `prefixes` / `suffixes` / `typeNameMapper` | see [Naming](#naming) | operation and type names |
2201
- | `typeNamePrefix` / `typeNameSuffix` / `derivedTypeNameMapper` | none | [names of the derived types](#renaming-the-derived-types) — filters, inputs, aggregates, shared inputs |
2202
- | `scalars` / `mapColumnType` | built-in detection | [override a column's GraphQL type](#overriding-a-columns-scalar), by name or by rule |
2203
- | `enumNameMapper` | shared `<EnumName>Enum` per database enum | [names the enum a column maps to](#enum-types) |
2204
- | `describeColumn` / `describeTable` / `describeRelation` | none | [GraphQL descriptions](#documenting-the-schema) |
2205
- | `deprecateColumn` | none | `@deprecated` on a column's output and optional input fields |
2206
- | `conflictDoNothing` | `false` | PostgreSQL only — `ON CONFLICT DO NOTHING` on inserts, which makes `create<Table>Single` nullable |
2207
- | `complexity` | on | [cost hints](#query-cost) in field extensions; `false` turns them off |
2208
- | `transactions` | `'none'` | `'auto'` wraps a multi-mutation request in one [transaction](#transactions) |
2209
- | `limits` | unbounded | [default and maximum page size](#bounding-page-size), globally or per table |
2210
- | `defaults` | none | [per-table default `orderBy`](#default-ordering) for requests that omit one |
2211
- | `scope` | none | [row-level predicate](#row-scoping-and-multi-tenancy) every read and write of a table is confined to |
2212
- | `contextValues` | none | [columns the server stamps from context](#columns-the-server-owns) instead of accepting from the client |
2213
- | `softDelete` | none | [tables that mark rows deleted](#soft-delete) instead of removing them |
2214
- | `onWrite` | none | [hook that runs inside the mutation's own transaction](#write-hooks) |
2215
- | `onError` | replaces driver errors | [error handling](#error-handling) hook — log, or surface a different error |
2216
- | `eagerLoadRelations` | `true` | [which relations are pre-fetched](#overriding-a-relations-resolver-without-overfetching) with the parent query |