@vantreeseba/drizzle-graphql 5.6.0 → 7.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 CHANGED
@@ -1,768 +1,1020 @@
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
- ## Choosing what gets generated
89
-
90
- Every generated operation is on by default. `features` turns individual ones off, which
91
- keeps them out of the schema, out of `entities`, and out of the type map — a schema for a
92
- read-only API, or one that never exposes aggregates, does not pay for types nobody can
93
- reach:
94
-
95
- ```Typescript
96
- const { schema } = buildSchema(db, {
97
- features: {
98
- aggregates: false, // <plural>Aggregate root queries
99
- groupBy: false, // <plural>GroupBy root queries
100
- relationAggregates: false, // <relation>Aggregate fields on object types
101
- distinct: false, // the `distinct` argument on list queries
102
- insert: false, // create<Table> / create<Table>Single mutations
103
- update: false, // update<Table> / update<Table>Single mutations
104
- delete: false, // delete<Table> / delete<Table>Single mutations
105
- upsert: true, // upsert<Table> / upsert<Table>Single mutations (off by default)
106
- requireWhere: true, // make `where` non-null on plural update/delete (off by default)
107
- },
108
- })
109
- ```
110
-
111
- - Any flag left out keeps its default of `true`, so `{ features: { delete: false } }`
112
- changes nothing else
113
- - `upsert` is the exception: it defaults to `false`, so the upsert mutations and their
114
- conflict input only exist if you ask for them
115
- - `groupBy` needs `aggregates`: it reuses those output types, so turning `aggregates` off
116
- turns the group-by queries off with it
117
- - Turning off `insert` or `update` also drops the input type that only that mutation
118
- used (`Create<Type>Input` / `Update<Type>Input`)
119
- - Turning off all three mutation features omits the `Mutation` type entirely, the same as
120
- `mutations: false`
121
- - List and single queries are always generated, so `Query` is never empty
122
-
123
- ## Scalars
124
-
125
- Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
126
- generated SDL says what a field actually holds instead of falling back to `String`:
127
-
128
- | Drizzle column | GraphQL type | Transported as |
129
- | ------------------------------------------ | ------------ | ------------------------------------ |
130
- | `json` / `jsonb` (and `mode: 'json'`) | `JSON` | the parsed value |
131
- | `bigint` (and `mode: 'bigint'`) | `BigInt` | a decimal string |
132
- | `uuid` | `UUID` | a validated UUID string |
133
- | `timestamp` / `datetime` | `DateTime` | an ISO-8601 string |
134
- | `date` | `Date` | a `YYYY-MM-DD` string |
135
-
136
- ```graphql
137
- mutation {
138
- createDocumentSingle(values: { id: "11111111-1111-4111-8111-111111111111", payload: { tags: ["a"], views: 3 }, counter: "9007199254740993" }) {
139
- payload
140
- counter
141
- }
142
- }
143
- ```
144
-
145
- - **`JSON`** carries the value itself — objects, arrays, numbers, strings, `true`/`false`.
146
- Reads return the parsed value, not a stringified one, and writes take a literal or a
147
- variable rather than a string of JSON
148
- - **`BigInt`** is always a decimal string in both directions, so values past
149
- `Number.MAX_SAFE_INTEGER` survive the round-trip. Integer literals are accepted on input;
150
- floats and non-numeric strings are rejected
151
- - **`UUID`** validates the format on the way in, including inside `where` filters
152
-
153
- The scalars are exported if you need them in a hand-written schema:
154
-
155
- ```Typescript
156
- import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQLUUID } from 'drizzle-graphql'
157
- ```
158
-
159
- `GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
160
- called in SDL, to avoid clashing with the language's own `BigInt`.
161
-
162
- ## Relation filters
163
-
164
- A table's `where` input also exposes its relations, so you can filter rows by what they're
165
- related to instead of pulling everything and filtering client-side.
166
-
167
- A **to-one** relation takes the target table's filter input directly:
168
-
169
- ```graphql
170
- {
171
- posts(where: { author: { name: { eq: "FifthUser" } } }) {
172
- id
173
- }
174
- }
175
- ```
176
-
177
- A **to-many** relation takes a `some` / `none` / `every` wrapper
178
- (`<Target>ListRelationFilter`):
179
-
180
- ```graphql
181
- {
182
- # users who wrote at least one post containing "drizzle"
183
- users(where: { posts: { some: { content: { like: "%drizzle%" } } } }) {
184
- id
185
- }
186
-
187
- # users with no posts at all
188
- users(where: { posts: { none: {} } }) {
189
- id
190
- }
191
-
192
- # users all of whose posts are published (users with no posts match vacuously)
193
- users(where: { posts: { every: { isPublished: { eq: true } } } }) {
194
- id
195
- }
196
- }
197
- ```
198
-
199
- - Relation filters compile to correlated `EXISTS` subqueries — the related rows are never
200
- fetched, and no join duplicates the parent rows
201
- - They nest arbitrarily (`users(where: { posts: { some: { author: { … } } } })`) and combine
202
- freely with column filters (implicit `AND`) and with `OR`
203
- - They're accepted anywhere a filter is — list and single queries, aggregate queries,
204
- `update`/`delete` mutations, and the `where` argument on a relation field
205
- - `some: {}` means "at least one related row exists"; `none: {}` means "none exist"
206
- - Several modes may be given at once and are `AND`ed together
207
- - A relation whose name collides with a column name is skipped — the column keeps the field
208
- - Many-to-many relations declared with `.through()` are filterable too: the `EXISTS`
209
- subquery joins the junction table to the target, so
210
- `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
211
- to-many relation
212
-
213
- ## Aggregate queries
214
-
215
- Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
216
- following the same naming rules as the other generated queries:
217
-
218
- ```graphql
219
- {
220
- postsAggregate(where: { authorId: { eq: 1 } }) {
221
- count
222
- avg {
223
- views
224
- }
225
- sum {
226
- views
227
- }
228
- min {
229
- createdAt
230
- }
231
- max {
232
- createdAt
233
- title
234
- }
235
- countNonNull {
236
- publishedAt
237
- }
238
- countDistinct {
239
- authorId
240
- }
241
- }
242
- }
243
- ```
244
-
245
- - `count` — number of matching rows (`Int!`)
246
- - `avg` / `sum` — one nullable `Float` field per numeric column
247
- - `min` / `max` — one field per orderable column (numbers, strings, enums, dates, bigints),
248
- typed exactly like that column is in the table's own type
249
- - `countNonNull` — `Int!` per column: how many matching rows have a non-null value there.
250
- Every column qualifies, since `count(col)` is valid whatever the type
251
- - `countDistinct` — `Int!` per column: how many distinct non-null values there are. Limited to
252
- the same columns as `min` / `max`, because counting distinct values needs an equality operator
253
-
254
- Columns that have no meaningful ordering — booleans, arrays, JSON, buffers, and geometry —
255
- are left out of these types, and the `avg` / `sum` / `min` / `max` fields themselves are
256
- omitted when no column qualifies.
257
-
258
- The optional `where` argument takes the same filter input as the table's list query, and is
259
- applied to every aggregate in the selection. All requested aggregates are computed in a
260
- single `SELECT`, and on an empty result set `count` is `0` while the other values are `null`.
261
-
262
- Add `groupBy` to get the same numbers one row per group — see [Group by](#group-by).
263
-
264
- ## Group by
265
-
266
- Every table with aggregates also gets a `<tableName>GroupBy` query (e.g. `postsGroupBy`): the
267
- same aggregates as `<tableName>Aggregate`, computed once per distinct combination of the
268
- columns you group by.
269
-
270
- ```graphql
271
- {
272
- postsGroupBy(
273
- groupBy: [authorId, published]
274
- where: { createdAt: { gte: "2024-01-01T00:00:00Z" } }
275
- having: { count: { gte: 5 } }
276
- ) {
277
- group {
278
- authorId
279
- published
280
- }
281
- count
282
- avg {
283
- views
284
- }
285
- max {
286
- createdAt
287
- }
288
- }
289
- }
290
- ```
291
-
292
- - `groupBy` is required and takes one or more values of the table's `<Type>GroupByColumn`
293
- enum. It holds the orderable columns plus booleans — anything the database can group on.
294
- An empty list is an error, and repeated columns are ignored
295
- - `group` is a `<Type>GroupKeys!` object with one nullable field per groupable column, typed
296
- like the table's own column. Columns the query did not group by come back as `null`, which
297
- a column whose grouped value really is `NULL` is indistinguishable from
298
- - Every other field is the same one `<tableName>Aggregate` returns — `count`, `avg`, `sum`,
299
- `min`, `max`, `countNonNull`, `countDistinct` — with the identical types and rules
300
- - `where` filters rows before grouping; `having` filters groups after aggregating
301
- - The result is `[<Type>GroupBy!]!`, one row per group, in whatever order the database
302
- returns them. Add your own ordering client-side if you need it
303
-
304
- `having` mirrors the aggregate selection, with an `AggregateNumberFilter` (`eq`, `ne`, `gt`,
305
- `gte`, `lt`, `lte`, all `Float`) in place of each value:
306
-
307
- ```graphql
308
- {
309
- postsGroupBy(groupBy: [authorId], having: { count: { gt: 3 }, avg: { views: { gte: 100 } } }) {
310
- group {
311
- authorId
312
- }
313
- count
314
- }
315
- }
316
- ```
317
-
318
- - `having: { count: … }` filters on the row count; `avg` / `sum` / `min` / `max` /
319
- `countNonNull` / `countDistinct` take one filter per column, over the same columns the
320
- matching aggregate type exposes
321
- - Every entry in a `having` is ANDed together
322
- - A group filter does not need to be in the selection — `having: { count: { gt: 3 } }` works
323
- whether or not you asked for `count`
324
-
325
- The whole thing is one `SELECT … GROUP BY … HAVING …`. Set `features: { groupBy: false }` to
326
- leave these queries (and their `<Type>GroupBy`, `<Type>GroupKeys`, `<Type>GroupByColumn` and
327
- `<Type>Having` types) out of the schema; turning off `aggregates` removes them too.
328
-
329
- ## Relation aggregates
330
-
331
- Every to-many relation also gets an `<relationName>Aggregate` field on the parent type, so you
332
- can count or summarise related rows without fetching them:
333
-
334
- ```graphql
335
- {
336
- users {
337
- id
338
- postsAggregate {
339
- count
340
- }
341
- publishedPosts: postsAggregate(where: { published: { eq: true } }) {
342
- count
343
- max {
344
- createdAt
345
- }
346
- }
347
- }
348
- }
349
- ```
350
-
351
- - The field returns the target table's own `<Type>Aggregate` type — the same one the root
352
- `<tableName>Aggregate` query returns, so `count` / `avg` / `sum` / `min` / `max` behave
353
- identically
354
- - `where` takes the target table's filter input, including [relation filters](#relation-filters),
355
- and applies only to the related rows
356
- - A parent with no related rows gets `count: 0` and `null` for every other aggregate
357
- - To-one relations get no aggregate field — there is nothing to aggregate over
358
- - The field is skipped if its name would collide with a column or another relation
359
-
360
- All parents in a selection are aggregated with a single
361
- `SELECT <fk>, … WHERE <fk> IN (…) GROUP BY <fk>` per request, so `postsAggregate` on a list of
362
- users is one extra query, not one per user. Differently-aliased selections with different
363
- `where` arguments are batched separately.
364
-
365
- ## Distinct
366
-
367
- List queries take a `distinct` argument — a list of columns from the `<Type>DistinctColumn`
368
- enum. Rows sharing the same combination of those columns collapse to one:
369
-
370
- ```graphql
371
- {
372
- # the first post of each author
373
- posts(distinct: [authorId], orderBy: { createdAt: { direction: asc, priority: 1 } }) {
374
- id
375
- authorId
376
- createdAt
377
- }
378
- }
379
- ```
380
-
381
- - Which row survives each group is decided by `orderBy` — the first one wins. With no
382
- `orderBy`, that's the lowest primary key
383
- - `where` is applied **before** rows are collapsed; `limit` and `offset` are applied
384
- **after**, so `limit: 10` returns ten distinct rows
385
- - Several columns are treated as one combined key, not as independent ones
386
- - `distinct` is available on list queries only — a single query returns one row either way
387
-
388
- It runs as an extra `row_number() over (partition by …)` query that picks the surviving
389
- rows' primary keys, after which the main query is narrowed to them — so it needs the same
390
- window-function support as per-parent paginated relations (**PostgreSQL**, **MySQL 8.0+**,
391
- or **SQLite 3.25+**), and a table with no primary key cannot use it.
392
-
393
- ## Pagination ordering
394
-
395
- SQL gives no ordering guarantee for a query that has no `ORDER BY`, so paging through an
396
- unordered result can return the same row twice or skip one entirely. To keep pages stable,
397
- a query that returns only part of a table is ordered by its primary key when the request
398
- supplies no `orderBy`:
399
-
400
- - list queries with `limit` and/or `offset`
401
- - `<tableName>Single` queries, which are an implicit `limit 1`
402
- - to-many relation fields with `limit` and/or `offset` (as a tiebreak appended to any
403
- `orderBy` you do supply, so per-parent slices are deterministic)
404
-
405
- An explicit `orderBy` always takes precedence, composite primary keys are ordered by every
406
- key column, and an unpaginated list query is left unordered so no sort is paid for.
407
-
408
- ## Cursor pagination
409
-
410
- `OFFSET` pagination degrades linearly and shifts under concurrent writes. List queries also
411
- support keyset pagination: every row returned by a list query exposes an opaque `cursor`
412
- field, and passing it back as `after` resumes strictly after that row.
413
-
414
- ```graphql
415
- {
416
- posts(orderBy: { createdAt: { direction: desc, priority: 1 } }, limit: 10) {
417
- id
418
- createdAt
419
- cursor
420
- }
421
- }
422
-
423
- # next page — same orderBy, plus the last row's cursor
424
- {
425
- posts(
426
- orderBy: { createdAt: { direction: desc, priority: 1 } }
427
- limit: 10
428
- after: "eyJvIjogW1siY3JlYXRlZEF0IiwgImRlc2MiXSwgWyJpZCIsICJhc2MiXV0sIC4uLn0"
429
- ) {
430
- id
431
- createdAt
432
- cursor
433
- }
434
- }
435
- ```
436
-
437
- - The cursor encodes the row's position in the query's **total order** — your `orderBy`
438
- columns plus the primary key as an ascending tiebreak — so pages are stable even when
439
- the ordered columns aren't unique, and inserts or deletes mid-scroll don't shift the
440
- window
441
- - `after` compiles to a keyset predicate built from drizzle's `and`/`or`/`gt`/`lt`
442
- comparisons (not SQL row-value syntax), so mixed-direction `orderBy` works
443
- - `NULL`s in ordered columns follow each dialect's native `ORDER BY` placement —
444
- PostgreSQL sorts them largest (last in `asc`), MySQL and SQLite smallest (first in
445
- `asc`) — and the predicate matches, so rows with `NULL` in an ordered column are paged
446
- through, not skipped
447
- - Dates and bigints round-trip losslessly through the cursor
448
- - A cursor is only valid under the ordering it was issued for — reusing it with a
449
- different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
450
- - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
451
- cursor pagination (its `cursor` field resolves to `null`)
452
- - If a table has a real column named `cursor`, the column wins and the meta field is
453
- skipped
454
-
455
- ## Upsert
456
-
457
- `features.upsert` adds a pair of mutations per table that insert rows, or update the ones
458
- that already exist:
459
-
460
- ```graphql
461
- mutation {
462
- upsertUsersSingle(values: { id: 1, name: "Dan", email: "dan@example.com" }) {
463
- id
464
- name
465
- }
466
- }
467
- ```
468
-
469
- With no `onConflict`, a conflict on the **primary key** overwrites every column the request
470
- supplied. `onConflict` changes that:
471
-
472
- ```graphql
473
- mutation {
474
- upsertUsers(
475
- values: [
476
- { email: "dan@example.com", name: "Dan", visits: 1 }
477
- { email: "sam@example.com", name: "Sam", visits: 1 }
478
- ]
479
- onConflict: {
480
- target: [email] # must be a unique constraint; defaults to the primary key
481
- action: UPDATE # or NOTHING, to keep the existing row
482
- update: [name] # columns to overwrite; defaults to every supplied column
483
- where: { isConfirmed: { eq: true } } # only overwrite rows that match
484
- }
485
- ) {
486
- id
487
- name
488
- }
489
- }
490
- ```
491
-
492
- - A batch upsert updates each row with **its own** values (`excluded.<column>`), not with
493
- the last row's
494
- - Columns the request did not supply are never overwritten — a partial upsert does not null
495
- out the rest of the row. Listing an unsupplied column in `update` is an error rather than
496
- a silent no-op
497
- - `target` must match one of the table's unique constraints exactly; anything else is
498
- rejected with the list of valid targets, instead of the database's opaque "no unique or
499
- exclusion constraint matching" error
500
- - `action: NOTHING` inserts nothing and returns nothing for the conflicting row. An `UPDATE`
501
- with no columns left to write degrades to the same thing
502
- - `values` is the same `Create<Type>Input` the insert mutations take, so turning `insert`
503
- off does not remove it
504
-
505
- Dialect differences:
506
-
507
- - **PostgreSQL** and **SQLite** — the full surface above. A table with no primary key and no
508
- unique constraint has nothing to conflict on, so it gets no upsert mutations at all
509
- - **MySQL** — `ON DUPLICATE KEY UPDATE` fires on whichever unique key was violated and takes
510
- no predicate, so `<Type>OnConflict` there has only `action` and `update`. `action: NOTHING`
511
- becomes `INSERT IGNORE`, and the mutations return `MutationReturn` like every other MySQL
512
- mutation
513
-
514
- The build-wide `conflictDoNothing` option is deprecated in favour of this: it applies to every
515
- `create*` mutation with no way for a request to opt out. `onConflict: { action: NOTHING }` is
516
- the per-request replacement.
517
-
518
- ## Single-row update & delete
519
-
520
- Alongside the plural `update<Table>` / `delete<Table>` mutations, every table gets an
521
- `update<Table>Single` / `delete<Table>Single` variant that targets exactly one row:
522
-
523
- ```graphql
524
- mutation {
525
- updateUsersSingle(where: { id: { eq: 1 } }, set: { name: "Dan" }) {
526
- id
527
- name
528
- }
529
- }
530
- ```
531
-
532
- - `where` is **non-null** and must contain at least one filter — `where: {}` is rejected at
533
- resolve time, so a Single write can never become an unbounded one
534
- - The return type is the single (nullable) row type, not a list: the affected row comes back
535
- directly, and **no match returns `null`** instead of an empty list
536
- - If `where` matches **more than one row**, the mutation throws a `GraphQLError` and writes
537
- nothing — the match is checked (`LIMIT 2`) before the write runs
538
- - **MySQL** cannot return the affected row (no `RETURNING`), so its Single variants keep the
539
- single-match and non-empty-`where` guarantees but return `MutationReturn` like every other
540
- MySQL mutation
541
- - The variants are generated under `features.update` / `features.delete`, share the plural
542
- mutations' input types, and appear in `entities.mutations` for custom schemas
543
-
544
- The plural mutations still accept a missing `where` (a full-table write) by default. To rule
545
- that out at the type level, `features: { requireWhere: true }` makes `where` non-null on
546
- `update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
547
- Single variants. It defaults to `false` for backwards compatibility.
548
-
549
- ## Query cost
550
-
551
- Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
552
- [`graphql-query-complexity`](https://github.com/slicknode/graphql-query-complexity)'s
553
- `fieldExtensionsEstimator`. The generator knows which fields are paginated and which are
554
- aggregates, so the cost tracks the rows a query can actually pull rather than the number of
555
- fields it mentions:
556
-
557
- | Field | Cost |
558
- | --- | --- |
559
- | List query, to-many relation | `(limit ?? defaultListSize) * childComplexity` |
560
- | Aggregate query, group-by query, `<relation>Aggregate` | `aggregateCost + childComplexity` |
561
- | Everything else | no hint — your estimator's default applies |
562
-
563
- So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
564
- same query without limits costs `10 * (1 + 10 * 1)` = 110.
565
-
566
- The hints do nothing until you install a complexity rule, so they are generated by default:
567
-
568
- ```ts
569
- import { createComplexityRule, fieldExtensionsEstimator, simpleEstimator } from 'graphql-query-complexity';
570
-
571
- const rule = createComplexityRule({
572
- maximumComplexity: 1000,
573
- estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
574
- });
575
- ```
576
-
577
- Tune the two assumptions, or turn the hints off entirely:
578
-
579
- ```ts
580
- buildSchema(db, {
581
- complexity: { defaultListSize: 25, aggregateCost: 50 },
582
- });
583
-
584
- buildSchema(db, { complexity: false });
585
- ```
586
-
587
- ### Depth
588
-
589
- Cost is not a depth bound. A cyclic relation graph (`user -> posts -> author -> posts -> …`)
590
- lets a client nest as deep as it likes, and a deep query over cheap fields can stay under any
591
- complexity ceiling. Put a depth limit in front of a publicly exposed schema as well — e.g.
592
- [`graphql-depth-limit`](https://github.com/stems/graphql-depth-limit) — or set
593
- `relationsDepthLimit: 0` to generate no relation fields at all.
594
-
595
- ## Error handling
596
-
597
- Database drivers put a lot into an error message. Drizzle rethrows them with the full SQL
598
- statement and its bound parameters attached, and Postgres itself names the table, the column
599
- and the constraint that was violated. None of that belongs in a GraphQL response, so by
600
- default every error a generated resolver throws is passed through a sanitizer:
601
-
602
- - errors drizzle-graphql raises itself (`Unable to update with no values specified!`,
603
- `Field 'x' is not a valid date!`, filter misuse, …) are written for the client and pass
604
- through unchanged
605
- - anything else becomes `Internal server error` with `extensions.code:
606
- "INTERNAL_SERVER_ERROR"`, and the original is kept on the error's `originalError` so a
607
- server-side logger can still see it
608
-
609
- `onError` overrides this. Return an error to surface that one, or return nothing to let the
610
- default apply — which makes it a pure logging hook:
611
-
612
- ```Typescript
613
- const { schema } = buildSchema(db, {
614
- onError: (error) => {
615
- logger.error({ err: error }, 'drizzle-graphql resolver failed')
616
- // no return value — the default sanitizing still applies
617
- },
618
- })
619
- ```
620
-
621
- ```Typescript
622
- // Surface raw database errors, e.g. in development
623
- buildSchema(db, { onError: (error) => error as Error })
624
-
625
- // Or map them yourself
626
- buildSchema(db, {
627
- onError: (error) =>
628
- isUniqueViolation(error)
629
- ? new GraphQLError('That record already exists', { extensions: { code: 'CONFLICT' } })
630
- : undefined,
631
- })
632
- ```
633
-
634
- The hook covers root queries and mutations, relation and aggregate fields, and the
635
- standalone `entities.fieldResolvers`. The default is exported as `defaultErrorMapper` if you
636
- want to fall back to it explicitly.
637
-
638
- ## Transactions
639
-
640
- Each resolver runs its statements on the database the schema was built from. A request that
641
- fires several mutations therefore commits each one separately, and a failure halfway leaves
642
- the earlier ones in place. To run a whole request as one unit, open a transaction yourself
643
- and put it on the GraphQL context under the exported `drizzleExecutorKey`:
644
-
645
- ```Typescript
646
- import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
647
-
648
- const { schema } = buildSchema(db)
649
-
650
- await db.transaction(async (tx) => {
651
- const result = await graphql({
652
- schema,
653
- source: request.query,
654
- variableValues: request.variables,
655
- contextValue: { [drizzleExecutorKey]: tx },
656
- })
657
-
658
- if (result.errors?.length) throw new Error('rolling back')
659
- return result
660
- })
661
- ```
662
-
663
- Every generated resolver reads the key at resolve time, so queries, mutations, aggregates
664
- and relation field resolvers all run on the executor you supply and see its uncommitted
665
- rows. With no key on the context, everything falls back to the build-time database, which
666
- is what an ordinary request does.
667
-
668
- The value does not have to be a transaction — any object with the same interface works, for
669
- example a pooled connection bound to a tenant, or a logging proxy around `db`. Because the
670
- key is created with `Symbol.for`, the ESM and CJS builds of this package agree on it when
671
- both end up loaded in one process.
672
-
673
- ## Relations & N+1 handling
674
-
675
- Generated schemas resolve nested relations without N+1 query explosions:
676
-
677
- - **Queries** — root queries (`entities.queries.*`) eagerly load every requested
678
- relation in a single round-trip using Drizzle's relational query builder
679
- (`with:`), including nested relations and per-relation `where` / `orderBy` /
680
- `limit` / `offset` arguments.
681
- - **Mutations (PostgreSQL & SQLite)** — after an insert or update, if the selection set
682
- includes relation fields, the affected rows are re-fetched by primary key through one
683
- relational query so their relations are eagerly loaded (single- and composite-column
684
- primary keys are supported). `delete` mutations keep using the request-scoped batch
685
- loader, since the rows no longer exist to re-fetch. MySQL mutations return only a
686
- success flag (no row payload), so this step does not apply there.
687
- - **Custom schemas** — each relation field also has a standalone resolver, exported as
688
- `entities.fieldResolvers[TableName][relationName]`. When you wire generated types
689
- into your own schema and your root resolver does **not** pre-fetch relations, these
690
- resolvers batch all sibling loads in a request into a single `IN (…)` query
691
- (request-scoped, keyed on the GraphQL `context`), preventing N+1. Per-parent
692
- `limit` / `offset` on a to-many relation is applied across the whole batch using a
693
- `ROW_NUMBER() OVER (PARTITION BY …)` window function — still one query, not one per
694
- parent.
695
-
696
- ```Typescript
697
- // entities.fieldResolvers is keyed by table name, then relation name
698
- const usersPostsResolver = entities.fieldResolvers.Users.posts
699
- ```
700
-
701
- > [!IMPORTANT]
702
- > Per-parent paginated relations (a to-many relation field with `limit` or `offset`)
703
- > rely on SQL window functions. These require **PostgreSQL**, **MySQL 8.0+**, or
704
- > **SQLite 3.25+**. Relations without pagination, and all other query/mutation paths,
705
- > have no such requirement.
706
-
707
- ### Relation nullability
708
-
709
- A **to-many** relation field is always `[Target!]!`. A **to-one** relation field is
710
- nullable by default, but honors the relation's declared optionality: declaring
711
- `optional: false` on a Drizzle `one` relation — the assertion that the related row always
712
- exists, i.e. a `NOT NULL` foreign key — emits the field as `Target!`:
713
-
714
- ```Typescript
715
- const relations = buildRelations({ Users, Posts }, {
716
- Posts: {
717
- // posts.author_id is NOT NULL → author: Users!
718
- author: r.one.Users({ from: r.Posts.authorId, to: r.Users.id, optional: false }),
719
- },
720
- })
721
- ```
722
-
723
- Only the explicit `optional: false` declaration is honored — required-ness is never
724
- inferred from column nullability, since a `NOT NULL` `from` column does not guarantee a
725
- related row exists when the constraint lives on the other side of the join. Note that on a
726
- required relation, a `where` argument that filters out the related row (or a dangling
727
- foreign key) resolves to `null` and therefore surfaces as a GraphQL non-null error.
728
-
729
- ### Overriding a relation's resolver without overfetching
730
-
731
- By default the eager `with:` pre-fetch is driven purely by the GraphQL selection set:
732
- any selected relation is fetched from the database, even if you intend to resolve it
733
- yourself (from a cache, another service, a dataloader, …). To override a relation's
734
- resolver, first opt it out of eager loading with `eagerLoadRelations` so the parent
735
- query stops fetching it, then supply your resolver with the standard
736
- [`@graphql-tools/schema`](https://the-guild.dev/graphql/tools) utilities:
737
-
738
- ```Typescript
739
- import { addResolversToSchema } from '@graphql-tools/schema'
740
-
741
- const { schema } = buildSchema(db, {
742
- // Exclude Users.posts from the parent query's `with:` clause.
743
- // Other relations keep eager-loading as usual.
744
- eagerLoadRelations: (table, relation) => !(table === 'Users' && relation === 'posts'),
745
- })
746
-
747
- const finalSchema = addResolversToSchema({
748
- schema,
749
- resolvers: {
750
- Users: {
751
- posts: (parent, args, context) => context.postsLoader.load(parent.id),
752
- },
753
- },
754
- })
755
- ```
756
-
757
- `eagerLoadRelations` accepts:
758
-
759
- - `true` (default) — eager-load every relation.
760
- - `false` — never eager-load; every relation resolves lazily through its
761
- (request-batched) field resolver.
762
- - `(tableName, relationName) => boolean` — decide per relation. Returning `false`
763
- excludes that relation from `with:` (and from the mutation eager re-fetch).
764
-
765
- Opting a relation out does **not** remove its field — it keeps resolving lazily via the
766
- request-scoped batch loader, so the field still works even before you override it. Table
767
- and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
768
- of `entities.fieldResolvers`.
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
+ ## Choosing what gets generated
89
+
90
+ Every generated operation is on by default. `features` turns individual ones off, which
91
+ keeps them out of the schema, out of `entities`, and out of the type map — a schema for a
92
+ read-only API, or one that never exposes aggregates, does not pay for types nobody can
93
+ reach:
94
+
95
+ ```Typescript
96
+ const { schema } = buildSchema(db, {
97
+ features: {
98
+ aggregates: false, // <plural>Aggregate root queries
99
+ groupBy: false, // <plural>GroupBy root queries
100
+ relationAggregates: false, // <relation>Aggregate fields on object types
101
+ distinct: false, // the `distinct` argument on list queries
102
+ insert: false, // create<Table> / create<Table>Single mutations
103
+ update: false, // update<Table> / update<Table>Single mutations
104
+ delete: false, // delete<Table> / delete<Table>Single mutations
105
+ upsert: true, // upsert<Table> / upsert<Table>Single mutations (off by default)
106
+ nestedWrites: true, // relation fields on the create/update inputs (off by default)
107
+ fieldUpdateOperations: true, // increment/push operations on the update input (off by default)
108
+ countMutations: true, // update<Table>Count / delete<Table>Count mutations (off by default)
109
+ requireWhere: true, // make `where` non-null on plural update/delete (off by default)
110
+ },
111
+ })
112
+ ```
113
+
114
+ - Any flag left out keeps its default of `true`, so `{ features: { delete: false } }`
115
+ changes nothing else
116
+ - `upsert`, `nestedWrites`, `fieldUpdateOperations` and `countMutations` are the
117
+ exceptions: all four default to `false`, so they only exist if you ask for them
118
+ - `groupBy` needs `aggregates`: it reuses those output types, so turning `aggregates` off
119
+ turns the group-by queries off with it
120
+ - Turning off `insert` or `update` also drops the input type that only that mutation
121
+ used (`Create<Type>Input` / `Update<Type>Input`)
122
+ - Turning off all three mutation features omits the `Mutation` type entirely, the same as
123
+ `mutations: false`
124
+ - List and single queries are always generated, so `Query` is never empty
125
+
126
+ ## Scalars
127
+
128
+ Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
129
+ generated SDL says what a field actually holds instead of falling back to `String`:
130
+
131
+ | Drizzle column | GraphQL type | Transported as |
132
+ | ------------------------------------------ | ------------ | ------------------------------------ |
133
+ | `json` / `jsonb` (and `mode: 'json'`) | `JSON` | the parsed value |
134
+ | `bigint` (and `mode: 'bigint'`) | `BigInt` | a decimal string |
135
+ | `uuid` | `UUID` | a validated UUID string |
136
+ | `timestamp` / `datetime` | `DateTime` | an ISO-8601 string |
137
+ | `date` | `Date` | a `YYYY-MM-DD` string |
138
+
139
+ ```graphql
140
+ mutation {
141
+ createDocumentSingle(values: { id: "11111111-1111-4111-8111-111111111111", payload: { tags: ["a"], views: 3 }, counter: "9007199254740993" }) {
142
+ payload
143
+ counter
144
+ }
145
+ }
146
+ ```
147
+
148
+ - **`JSON`** carries the value itself — objects, arrays, numbers, strings, `true`/`false`.
149
+ Reads return the parsed value, not a stringified one, and writes take a literal or a
150
+ variable rather than a string of JSON
151
+ - **`BigInt`** is always a decimal string in both directions, so values past
152
+ `Number.MAX_SAFE_INTEGER` survive the round-trip. Integer literals are accepted on input;
153
+ floats and non-numeric strings are rejected
154
+ - **`UUID`** validates the format on the way in, including inside `where` filters
155
+
156
+ The scalars are exported if you need them in a hand-written schema:
157
+
158
+ ```Typescript
159
+ import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQLUUID } from 'drizzle-graphql'
160
+ ```
161
+
162
+ `GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
163
+ called in SDL, to avoid clashing with the language's own `BigInt`.
164
+
165
+ ## Enum types
166
+
167
+ A column declared with a database enum becomes a GraphQL enum named after **the enum**, not
168
+ after the column that happens to use it:
169
+
170
+ ```Typescript
171
+ export const status = pgEnum('status', ['active', 'archived'])
172
+
173
+ export const Users = pgTable('users', { status: status('status') })
174
+ export const Posts = pgTable('posts', { status: status('status') })
175
+ ```
176
+
177
+ ```graphql
178
+ enum StatusEnum {
179
+ active
180
+ archived
181
+ }
182
+
183
+ type Users {
184
+ status: StatusEnum!
185
+ }
186
+ type Posts {
187
+ status: StatusEnum!
188
+ }
189
+ ```
190
+
191
+ - One type, one filter input (`StatusEnumFilter`), so a client variable typed `StatusEnum!`
192
+ is passable to either table
193
+ - The declared name is PascalCased and suffixed: `user_status` → `UserStatusEnum`
194
+ - A column that lists its values inline (`text('tier', { enum: ['free', 'paid'] })`) has no
195
+ declared name to share, so it keeps a per-column type — `UsersTierEnum`
196
+ - Two different enums that would land on the same GraphQL name (`status` in two Postgres
197
+ schemas) fail the build rather than silently sharing a type
198
+
199
+ `enumNameMapper` overrides the name. Returning `undefined` keeps the default, so a mapper
200
+ only has to handle the cases it cares about:
201
+
202
+ ```Typescript
203
+ const { schema } = buildSchema(db, {
204
+ // `schema` is the Postgres schema the enum was declared in, if any; `values` is its
205
+ // value list. Everything but `enumName` is always present.
206
+ enumNameMapper: ({ enumName, schema }) =>
207
+ schema === 'reporting' ? `Reporting${enumName}Enum` : undefined,
208
+ })
209
+ ```
210
+
211
+ Two columns the mapper sends to the same name share one type, as long as their values match —
212
+ which is also how inline enums can be unified. Sending the same name to two different value
213
+ lists is the build-time error above.
214
+
215
+ > **Upgrading:** before this, every enum column got its own `<Table><Column>Enum`. A column
216
+ > backed by a declared enum now reports the shared name instead (`UsersStatusEnum` →
217
+ > `StatusEnum`). To keep the old names, return them from the mapper:
218
+ >
219
+ > ```Typescript
220
+ > enumNameMapper: ({ tableName, columnName }) =>
221
+ > `${tableName}${columnName[0]!.toUpperCase()}${columnName.slice(1)}Enum`
222
+ > ```
223
+
224
+ ## Documenting the schema
225
+
226
+ Four hooks put descriptions and deprecations on the generated types. Each is called per
227
+ column (or per table / per relation) and returns `undefined` to say nothing:
228
+
229
+ ```Typescript
230
+ const { schema } = buildSchema(db, {
231
+ describeColumn: (column, { tableName, columnName }) => docs[tableName]?.[columnName],
232
+ describeTable: (tableName) => docs[tableName]?._table,
233
+ describeRelation: (tableName, relationName) => `The ${relationName} of this ${tableName}`,
234
+ deprecateColumn: (column, { columnName }) => (columnName === 'legacyId' ? 'Use `id`' : undefined),
235
+ })
236
+ ```
237
+
238
+ - `describeColumn` reaches every place the column appears: the object type, the create and
239
+ update inputs, the filter input's field, `Min`/`Max` aggregates and `GroupKeys`
240
+ - The hook receives the drizzle column itself, so a project that keeps its documentation in
241
+ the schema (a `.$type()` brand, a custom column property, a comment table) can read it
242
+ straight off
243
+ - `deprecateColumn` marks output fields and *optional* input fields. A required input field
244
+ is skipped: GraphQL forbids deprecating one, and emitting it would make the schema invalid
245
+ - Filter and `orderBy` fields are never deprecated — filtering on a deprecated column is how
246
+ a caller finds the rows still using it
247
+
248
+ ## Relation filters
249
+
250
+ A table's `where` input also exposes its relations, so you can filter rows by what they're
251
+ related to instead of pulling everything and filtering client-side.
252
+
253
+ A **to-one** relation takes the target table's filter input directly:
254
+
255
+ ```graphql
256
+ {
257
+ posts(where: { author: { name: { eq: "FifthUser" } } }) {
258
+ id
259
+ }
260
+ }
261
+ ```
262
+
263
+ A **to-many** relation takes a `some` / `none` / `every` wrapper
264
+ (`<Target>ListRelationFilter`):
265
+
266
+ ```graphql
267
+ {
268
+ # users who wrote at least one post containing "drizzle"
269
+ users(where: { posts: { some: { content: { like: "%drizzle%" } } } }) {
270
+ id
271
+ }
272
+
273
+ # users with no posts at all
274
+ users(where: { posts: { none: {} } }) {
275
+ id
276
+ }
277
+
278
+ # users all of whose posts are published (users with no posts match vacuously)
279
+ users(where: { posts: { every: { isPublished: { eq: true } } } }) {
280
+ id
281
+ }
282
+ }
283
+ ```
284
+
285
+ - Relation filters compile to correlated `EXISTS` subqueries — the related rows are never
286
+ fetched, and no join duplicates the parent rows
287
+ - They nest arbitrarily (`users(where: { posts: { some: { author: { … } } } })`) and combine
288
+ freely with column filters (implicit `AND`) and with `OR`
289
+ - They're accepted anywhere a filter is — list and single queries, aggregate queries,
290
+ `update`/`delete` mutations, and the `where` argument on a relation field
291
+ - `some: {}` means "at least one related row exists"; `none: {}` means "none exist"
292
+ - Several modes may be given at once and are `AND`ed together
293
+ - A relation whose name collides with a column name is skipped — the column keeps the field
294
+ - Many-to-many relations declared with `.through()` are filterable too: the `EXISTS`
295
+ subquery joins the junction table to the target, so
296
+ `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
297
+ to-many relation
298
+
299
+ ## Aggregate queries
300
+
301
+ Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
302
+ following the same naming rules as the other generated queries:
303
+
304
+ ```graphql
305
+ {
306
+ postsAggregate(where: { authorId: { eq: 1 } }) {
307
+ count
308
+ avg {
309
+ views
310
+ }
311
+ sum {
312
+ views
313
+ }
314
+ min {
315
+ createdAt
316
+ }
317
+ max {
318
+ createdAt
319
+ title
320
+ }
321
+ countNonNull {
322
+ publishedAt
323
+ }
324
+ countDistinct {
325
+ authorId
326
+ }
327
+ }
328
+ }
329
+ ```
330
+
331
+ - `count` — number of matching rows (`Int!`)
332
+ - `avg` / `sum` — one nullable `Float` field per numeric column
333
+ - `min` / `max` — one field per orderable column (numbers, strings, enums, dates, bigints),
334
+ typed exactly like that column is in the table's own type
335
+ - `countNonNull` — `Int!` per column: how many matching rows have a non-null value there.
336
+ Every column qualifies, since `count(col)` is valid whatever the type
337
+ - `countDistinct` — `Int!` per column: how many distinct non-null values there are. Limited to
338
+ the same columns as `min` / `max`, because counting distinct values needs an equality operator
339
+
340
+ Columns that have no meaningful ordering — booleans, arrays, JSON, buffers, and geometry —
341
+ are left out of these types, and the `avg` / `sum` / `min` / `max` fields themselves are
342
+ omitted when no column qualifies.
343
+
344
+ The optional `where` argument takes the same filter input as the table's list query, and is
345
+ applied to every aggregate in the selection. All requested aggregates are computed in a
346
+ single `SELECT`, and on an empty result set `count` is `0` while the other values are `null`.
347
+
348
+ Add `groupBy` to get the same numbers one row per group — see [Group by](#group-by).
349
+
350
+ ## Group by
351
+
352
+ Every table with aggregates also gets a `<tableName>GroupBy` query (e.g. `postsGroupBy`): the
353
+ same aggregates as `<tableName>Aggregate`, computed once per distinct combination of the
354
+ columns you group by.
355
+
356
+ ```graphql
357
+ {
358
+ postsGroupBy(
359
+ groupBy: [authorId, published]
360
+ where: { createdAt: { gte: "2024-01-01T00:00:00Z" } }
361
+ having: { count: { gte: 5 } }
362
+ ) {
363
+ group {
364
+ authorId
365
+ published
366
+ }
367
+ count
368
+ avg {
369
+ views
370
+ }
371
+ max {
372
+ createdAt
373
+ }
374
+ }
375
+ }
376
+ ```
377
+
378
+ - `groupBy` is required and takes one or more values of the table's `<Type>GroupByColumn`
379
+ enum. It holds the orderable columns plus booleans — anything the database can group on.
380
+ An empty list is an error, and repeated columns are ignored
381
+ - `group` is a `<Type>GroupKeys!` object with one nullable field per groupable column, typed
382
+ like the table's own column. Columns the query did not group by come back as `null`, which
383
+ a column whose grouped value really is `NULL` is indistinguishable from
384
+ - Every other field is the same one `<tableName>Aggregate` returns — `count`, `avg`, `sum`,
385
+ `min`, `max`, `countNonNull`, `countDistinct` — with the identical types and rules
386
+ - `where` filters rows before grouping; `having` filters groups after aggregating
387
+ - The result is `[<Type>GroupBy!]!`, one row per group, in whatever order the database
388
+ returns them. Add your own ordering client-side if you need it
389
+
390
+ `having` mirrors the aggregate selection, with an `AggregateNumberFilter` (`eq`, `ne`, `gt`,
391
+ `gte`, `lt`, `lte`, all `Float`) in place of each value:
392
+
393
+ ```graphql
394
+ {
395
+ postsGroupBy(groupBy: [authorId], having: { count: { gt: 3 }, avg: { views: { gte: 100 } } }) {
396
+ group {
397
+ authorId
398
+ }
399
+ count
400
+ }
401
+ }
402
+ ```
403
+
404
+ - `having: { count: … }` filters on the row count; `avg` / `sum` / `min` / `max` /
405
+ `countNonNull` / `countDistinct` take one filter per column, over the same columns the
406
+ matching aggregate type exposes
407
+ - Every entry in a `having` is ANDed together
408
+ - A group filter does not need to be in the selection — `having: { count: { gt: 3 } }` works
409
+ whether or not you asked for `count`
410
+
411
+ The whole thing is one `SELECT … GROUP BY … HAVING …`. Set `features: { groupBy: false }` to
412
+ leave these queries (and their `<Type>GroupBy`, `<Type>GroupKeys`, `<Type>GroupByColumn` and
413
+ `<Type>Having` types) out of the schema; turning off `aggregates` removes them too.
414
+
415
+ ## Relation aggregates
416
+
417
+ Every to-many relation also gets an `<relationName>Aggregate` field on the parent type, so you
418
+ can count or summarise related rows without fetching them:
419
+
420
+ ```graphql
421
+ {
422
+ users {
423
+ id
424
+ postsAggregate {
425
+ count
426
+ }
427
+ publishedPosts: postsAggregate(where: { published: { eq: true } }) {
428
+ count
429
+ max {
430
+ createdAt
431
+ }
432
+ }
433
+ }
434
+ }
435
+ ```
436
+
437
+ - The field returns the target table's own `<Type>Aggregate` type — the same one the root
438
+ `<tableName>Aggregate` query returns, so `count` / `avg` / `sum` / `min` / `max` behave
439
+ identically
440
+ - `where` takes the target table's filter input, including [relation filters](#relation-filters),
441
+ and applies only to the related rows
442
+ - A parent with no related rows gets `count: 0` and `null` for every other aggregate
443
+ - To-one relations get no aggregate field — there is nothing to aggregate over
444
+ - The field is skipped if its name would collide with a column or another relation
445
+
446
+ All parents in a selection are aggregated with a single
447
+ `SELECT <fk>, … WHERE <fk> IN (…) GROUP BY <fk>` per request, so `postsAggregate` on a list of
448
+ users is one extra query, not one per user. Differently-aliased selections with different
449
+ `where` arguments are batched separately.
450
+
451
+ ## Distinct
452
+
453
+ List queries take a `distinct` argument — a list of columns from the `<Type>DistinctColumn`
454
+ enum. Rows sharing the same combination of those columns collapse to one:
455
+
456
+ ```graphql
457
+ {
458
+ # the first post of each author
459
+ posts(distinct: [authorId], orderBy: { createdAt: { direction: asc, priority: 1 } }) {
460
+ id
461
+ authorId
462
+ createdAt
463
+ }
464
+ }
465
+ ```
466
+
467
+ - Which row survives each group is decided by `orderBy` — the first one wins. With no
468
+ `orderBy`, that's the lowest primary key
469
+ - `where` is applied **before** rows are collapsed; `limit` and `offset` are applied
470
+ **after**, so `limit: 10` returns ten distinct rows
471
+ - Several columns are treated as one combined key, not as independent ones
472
+ - `distinct` is also available on to-many relation fields, where it means one row per
473
+ distinct value **per parent** — the window is partitioned by the foreign key as well as
474
+ the requested columns
475
+ - `distinct` is not on single queries — those return one row either way
476
+
477
+ It runs as an extra `row_number() over (partition by …)` query that picks the surviving
478
+ rows' primary keys, after which the main query is narrowed to them — so it needs the same
479
+ window-function support as per-parent paginated relations (**PostgreSQL**, **MySQL 8.0+**,
480
+ or **SQLite 3.25+**), and a table with no primary key cannot use it.
481
+
482
+ ## Pagination ordering
483
+
484
+ SQL gives no ordering guarantee for a query that has no `ORDER BY`, so paging through an
485
+ unordered result can return the same row twice or skip one entirely. To keep pages stable,
486
+ a query that returns only part of a table is ordered by its primary key when the request
487
+ supplies no `orderBy`:
488
+
489
+ - list queries with `limit` and/or `offset`
490
+ - `<tableName>Single` queries, which are an implicit `limit 1`
491
+ - to-many relation fields with `limit` and/or `offset` (as a tiebreak appended to any
492
+ `orderBy` you do supply, so per-parent slices are deterministic)
493
+
494
+ An explicit `orderBy` always takes precedence, composite primary keys are ordered by every
495
+ key column, and an unpaginated list query is left unordered so no sort is paid for.
496
+
497
+ ## Cursor pagination
498
+
499
+ `OFFSET` pagination degrades linearly and shifts under concurrent writes. List queries also
500
+ support keyset pagination: every row returned by a list query exposes an opaque `cursor`
501
+ field, and passing it back as `after` resumes strictly after that row.
502
+
503
+ ```graphql
504
+ {
505
+ posts(orderBy: { createdAt: { direction: desc, priority: 1 } }, limit: 10) {
506
+ id
507
+ createdAt
508
+ cursor
509
+ }
510
+ }
511
+
512
+ # next page — same orderBy, plus the last row's cursor
513
+ {
514
+ posts(
515
+ orderBy: { createdAt: { direction: desc, priority: 1 } }
516
+ limit: 10
517
+ after: "eyJvIjogW1siY3JlYXRlZEF0IiwgImRlc2MiXSwgWyJpZCIsICJhc2MiXV0sIC4uLn0"
518
+ ) {
519
+ id
520
+ createdAt
521
+ cursor
522
+ }
523
+ }
524
+ ```
525
+
526
+ - The cursor encodes the row's position in the query's **total order** — your `orderBy`
527
+ columns plus the primary key as an ascending tiebreak — so pages are stable even when
528
+ the ordered columns aren't unique, and inserts or deletes mid-scroll don't shift the
529
+ window
530
+ - `after` compiles to a keyset predicate built from drizzle's `and`/`or`/`gt`/`lt`
531
+ comparisons (not SQL row-value syntax), so mixed-direction `orderBy` works
532
+ - `NULL`s in ordered columns follow each dialect's native `ORDER BY` placement —
533
+ PostgreSQL sorts them largest (last in `asc`), MySQL and SQLite smallest (first in
534
+ `asc`) — and the predicate matches, so rows with `NULL` in an ordered column are paged
535
+ through, not skipped
536
+ - Dates and bigints round-trip losslessly through the cursor
537
+ - A cursor is only valid under the ordering it was issued for — reusing it with a
538
+ different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
539
+ - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
540
+ cursor pagination (its `cursor` field resolves to `null`)
541
+ - If a table has a real column named `cursor`, the column wins and the meta field is
542
+ skipped
543
+
544
+ To-many relation fields take `after` and `distinct` too, with the same meaning they have on
545
+ a root list of the same table — a relation field is a list of the target table, so it gets
546
+ that table's whole pagination surface:
547
+
548
+ ```graphql
549
+ {
550
+ users {
551
+ id
552
+ posts(orderBy: { createdAt: { direction: desc, priority: 1 } }, limit: 10, after: "…") {
553
+ id
554
+ cursor
555
+ }
556
+ }
557
+ }
558
+ ```
559
+
560
+ Drizzle's `with:` clause cannot express either one — keyset needs a predicate over the
561
+ request's own total order, `distinct` needs a window pass, and a `cursor` has to be computed
562
+ from the raw row. So a relation field that is passed `after` or `distinct`, or that selects
563
+ `cursor`, drops out of the eager fetch and resolves through the request-scoped batch loader
564
+ instead, which implements all three. That is still **one query per relation** across every
565
+ parent in the batch (`distinct` adds its key-selection pass, as on a root list) — never one
566
+ per parent. Nothing about the arguments changes; only how many round-trips the request
567
+ costs.
568
+
569
+
570
+ ## Upsert
571
+
572
+ `features.upsert` adds a pair of mutations per table that insert rows, or update the ones
573
+ that already exist:
574
+
575
+ ```graphql
576
+ mutation {
577
+ upsertUsersSingle(values: { id: 1, name: "Dan", email: "dan@example.com" }) {
578
+ id
579
+ name
580
+ }
581
+ }
582
+ ```
583
+
584
+ With no `onConflict`, a conflict on the **primary key** overwrites every column the request
585
+ supplied. `onConflict` changes that:
586
+
587
+ ```graphql
588
+ mutation {
589
+ upsertUsers(
590
+ values: [
591
+ { email: "dan@example.com", name: "Dan", visits: 1 }
592
+ { email: "sam@example.com", name: "Sam", visits: 1 }
593
+ ]
594
+ onConflict: {
595
+ target: [email] # must be a unique constraint; defaults to the primary key
596
+ action: UPDATE # or NOTHING, to keep the existing row
597
+ update: [name] # columns to overwrite; defaults to every supplied column
598
+ where: { isConfirmed: { eq: true } } # only overwrite rows that match
599
+ }
600
+ ) {
601
+ id
602
+ name
603
+ }
604
+ }
605
+ ```
606
+
607
+ - A batch upsert updates each row with **its own** values (`excluded.<column>`), not with
608
+ the last row's
609
+ - Columns the request did not supply are never overwritten — a partial upsert does not null
610
+ out the rest of the row. Listing an unsupplied column in `update` is an error rather than
611
+ a silent no-op
612
+ - `target` must match one of the table's unique constraints exactly; anything else is
613
+ rejected with the list of valid targets, instead of the database's opaque "no unique or
614
+ exclusion constraint matching" error
615
+ - `action: NOTHING` inserts nothing and returns nothing for the conflicting row. An `UPDATE`
616
+ with no columns left to write degrades to the same thing
617
+ - `values` is the same `Create<Type>Input` the insert mutations take, so turning `insert`
618
+ off does not remove it
619
+
620
+ Dialect differences:
621
+
622
+ - **PostgreSQL** and **SQLite** — the full surface above. A table with no primary key and no
623
+ unique constraint has nothing to conflict on, so it gets no upsert mutations at all
624
+ - **MySQL** — `ON DUPLICATE KEY UPDATE` fires on whichever unique key was violated and takes
625
+ no predicate, so `<Type>OnConflict` there has only `action` and `update`. `action: NOTHING`
626
+ becomes `INSERT IGNORE`, and the mutations return `MutationReturn` like every other MySQL
627
+ mutation
628
+
629
+ The build-wide `conflictDoNothing` option is deprecated in favour of this: it applies to every
630
+ `create*` mutation with no way for a request to opt out. `onConflict: { action: NOTHING }` is
631
+ the per-request replacement.
632
+
633
+ ## Nested writes
634
+
635
+ `features.nestedWrites` adds a field per writable relation to every create and update input,
636
+ so a row and the rows it is related to can be written in one mutation:
637
+
638
+ ```graphql
639
+ mutation {
640
+ createPostsSingle(
641
+ values: {
642
+ title: "Hello"
643
+ author: { connect: { email: { eq: "dan@example.com" } } }
644
+ comments: { create: [{ body: "First" }, { body: "Second" }] }
645
+ }
646
+ ) {
647
+ id
648
+ author {
649
+ name
650
+ }
651
+ }
652
+ }
653
+ ```
654
+
655
+ Every relation field offers `create` and `connect`; on the update input, a relation whose
656
+ foreign key is nullable also offers `disconnect` and — for a to-many relation — `set`:
657
+
658
+ ```graphql
659
+ mutation {
660
+ updateUsersSingle(
661
+ where: { id: { eq: 1 } }
662
+ set: { posts: { set: [{ isPublished: { eq: true } }], create: [{ title: "New" }] } }
663
+ ) {
664
+ id
665
+ }
666
+ }
667
+ ```
668
+
669
+ - `connect`, `disconnect` and `set` take the target's own `Filters` input, so they select
670
+ rows the same way a query does. A filter that matches everything is rejected rather than
671
+ attaching the whole table
672
+ - `set` replaces the relation wholesale — everything attached is detached first, then the
673
+ filters attach. It runs before `create`, so a row created in the same write survives it
674
+ - A to-one relation takes one operation at a time (`connect` **or** `disconnect` **or**
675
+ `create`), and attaching to it detaches whatever was there
676
+ - `disconnect` and `set` only exist where the foreign key is nullable; there is no way to
677
+ detach a row whose key is `NOT NULL`
678
+ - A column the relation fills in becomes nullable on the create input — with
679
+ `author: { create: … }` available, `authorId` is one of two ways to supply it
680
+ - Nesting is one level deep: the row a nested `create` inserts takes columns only, and the
681
+ join column is left off, since the write it is part of sets it
682
+ - Many-to-many (`.through()`) relations are not written through
683
+ - The whole tree runs in one transaction — a savepoint when the request already carries one
684
+ — so a failure anywhere leaves nothing behind
685
+ - A `set` that carries only relation fields is a valid update: it changes what the row is
686
+ attached to without rewriting the row
687
+
688
+ Dialect support: **PostgreSQL**, and **SQLite** on an asynchronous driver (libsql, D1). A
689
+ nested write reads back the key of the row it just wrote, which MySQL has no `RETURNING` for,
690
+ and interleaves awaited statements inside a transaction, which a synchronous SQLite driver
691
+ cannot do — `buildSchema` rejects both combinations rather than generating something that
692
+ half-works.
693
+
694
+ ## Atomic column updates
695
+
696
+ `Update<Table>Input` sets columns to values, so "increment the view count" is a read followed
697
+ by a write of the number the read returned — two round-trips, and a lost update whenever two
698
+ clients read the same number. `features.fieldUpdateOperations` replaces qualifying columns'
699
+ update fields with an operations input, so the arithmetic happens in the database:
700
+
701
+ ```graphql
702
+ mutation {
703
+ updatePostsSingle(where: { id: { eq: 1 } }, set: { views: { increment: 1 } }) {
704
+ views
705
+ }
706
+ }
707
+ ```
708
+
709
+ compiles to `SET views = views + 1`. The flag changes the update inputs only; insert inputs
710
+ are untouched.
711
+
712
+ - **Numeric columns** (`Int`, `Float`, `BigInt`, `Decimal`) take
713
+ `set` / `increment` / `decrement` / `multiply` / `divide`
714
+ - **Array columns** take `set` / `push`, where `push` appends to the existing array
715
+ - Exactly one operation per column. Passing two, or `{}`, is a `GraphQLError` naming the
716
+ column — graphql-js has no `@oneOf` on the version range this library supports, so it is
717
+ enforced at resolve time rather than by the type
718
+ - `set` is the explicit spelling of the plain assignment the field had before the flag, so
719
+ every existing update has a one-key rewrite: `{ views: 5 }` → `{ views: { set: 5 } }`
720
+ - The input types are named for the **scalar**, not the column — every `Int` column in the
721
+ schema shares one `IntFieldUpdate` — and a column claimed by a `scalarOverrides` entry
722
+ keeps its plain field, since the override decides what that column accepts
723
+
724
+ Two things follow the database's own semantics rather than being guarded:
725
+
726
+ - `increment`/`multiply` on an integer column can overflow, and `divide` on one **rounds**
727
+ the way the dialect rounds (PostgreSQL truncates toward zero; use a `Float` or `Decimal`
728
+ column if you need the fraction)
729
+ - `divide: 0` raises the driver's division-by-zero error, surfaced as a `GraphQLError`
730
+
731
+ ## Row counts instead of rows
732
+
733
+ The plural `update<Table>` / `delete<Table>` mutations always `RETURNING *`, which is wasted
734
+ work — and wasted bandwidth — when a client only wants to know how many rows were touched.
735
+ `features.countMutations` adds a count-returning variant of each:
736
+
737
+ ```graphql
738
+ mutation {
739
+ deletePostsCount(where: { status: { eq: "spam" } })
740
+ updatePostsCount(where: { draft: { eq: true } }, set: { draft: false })
741
+ }
742
+ ```
743
+
744
+ - Both return `Int!` — the number of rows the write affected — and add no new output type
745
+ to the schema
746
+ - Arguments are exactly the plural mutation's, so a client swaps one field name for the
747
+ other; `features.requireWhere` applies to them the same way
748
+ - They follow the feature switch of the write they mirror: `updateCount` needs
749
+ `features.update`, `deleteCount` needs `features.delete`
750
+ - **MySQL** gets them too, and they are arguably most useful there: MySQL's other mutations
751
+ return only `MutationReturn { isSuccess }`, so a row count is strictly more information
752
+ - `update<Table>Count` **rejects** nested writes with a `GraphQLError` — a nested write
753
+ needs the parent rows this mutation deliberately does not read back. Use the plural
754
+ mutation for those
755
+ - The count is read from whichever field the driver reports it in (`rowCount`,
756
+ `affectedRows`, `rowsAffected`, `changes`, `count`); a driver that reports none is a
757
+ `GraphQLError` rather than a silent `0`
758
+
759
+ ## Single-row update & delete
760
+
761
+ Alongside the plural `update<Table>` / `delete<Table>` mutations, every table gets an
762
+ `update<Table>Single` / `delete<Table>Single` variant that targets exactly one row:
763
+
764
+ ```graphql
765
+ mutation {
766
+ updateUsersSingle(where: { id: { eq: 1 } }, set: { name: "Dan" }) {
767
+ id
768
+ name
769
+ }
770
+ }
771
+ ```
772
+
773
+ - `where` is **non-null** and must contain at least one filter — `where: {}` is rejected at
774
+ resolve time, so a Single write can never become an unbounded one
775
+ - The return type is the single (nullable) row type, not a list: the affected row comes back
776
+ directly, and **no match returns `null`** instead of an empty list
777
+ - If `where` matches **more than one row**, the mutation throws a `GraphQLError` and writes
778
+ nothing — the match is checked (`LIMIT 2`) before the write runs
779
+ - **MySQL** cannot return the affected row (no `RETURNING`), so its Single variants keep the
780
+ single-match and non-empty-`where` guarantees but return `MutationReturn` like every other
781
+ MySQL mutation
782
+ - The variants are generated under `features.update` / `features.delete`, share the plural
783
+ mutations' input types, and appear in `entities.mutations` for custom schemas
784
+
785
+ ### Mutation return shapes
786
+
787
+ Every mutation that returns rows returns them non-null, with two exceptions that are
788
+ deliberate rather than accidental:
789
+
790
+ | Mutation | Returns | Why |
791
+ | --- | --- | --- |
792
+ | `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 |
793
+ | `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 |
794
+ | everything else | `[Table!]!` / `Table!` / `Table` | — |
795
+
796
+ The plural mutations still accept a missing `where` (a full-table write) by default. To rule
797
+ that out at the type level, `features: { requireWhere: true }` makes `where` non-null on
798
+ `update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
799
+ Single variants. It defaults to `false` for backwards compatibility.
800
+
801
+ ## Query cost
802
+
803
+ Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
804
+ [`graphql-query-complexity`](https://github.com/slicknode/graphql-query-complexity)'s
805
+ `fieldExtensionsEstimator`. The generator knows which fields are paginated and which are
806
+ aggregates, so the cost tracks the rows a query can actually pull rather than the number of
807
+ fields it mentions:
808
+
809
+ | Field | Cost |
810
+ | --- | --- |
811
+ | List query, to-many relation | `(limit ?? defaultListSize) * childComplexity` |
812
+ | Aggregate query, group-by query, `<relation>Aggregate` | `aggregateCost + childComplexity` |
813
+ | Everything else | no hint — your estimator's default applies |
814
+
815
+ So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
816
+ same query without limits costs `10 * (1 + 10 * 1)` = 110.
817
+
818
+ The hints do nothing until you install a complexity rule, so they are generated by default:
819
+
820
+ ```ts
821
+ import { createComplexityRule, fieldExtensionsEstimator, simpleEstimator } from 'graphql-query-complexity';
822
+
823
+ const rule = createComplexityRule({
824
+ maximumComplexity: 1000,
825
+ estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
826
+ });
827
+ ```
828
+
829
+ Tune the two assumptions, or turn the hints off entirely:
830
+
831
+ ```ts
832
+ buildSchema(db, {
833
+ complexity: { defaultListSize: 25, aggregateCost: 50 },
834
+ });
835
+
836
+ buildSchema(db, { complexity: false });
837
+ ```
838
+
839
+ ### Depth
840
+
841
+ Cost is not a depth bound. A cyclic relation graph (`user -> posts -> author -> posts -> …`)
842
+ lets a client nest as deep as it likes, and a deep query over cheap fields can stay under any
843
+ complexity ceiling. Put a depth limit in front of a publicly exposed schema as well — e.g.
844
+ [`graphql-depth-limit`](https://github.com/stems/graphql-depth-limit) — or set
845
+ `relationsDepthLimit: 0` to generate no relation fields at all.
846
+
847
+ ## Error handling
848
+
849
+ Database drivers put a lot into an error message. Drizzle rethrows them with the full SQL
850
+ statement and its bound parameters attached, and Postgres itself names the table, the column
851
+ and the constraint that was violated. None of that belongs in a GraphQL response, so by
852
+ default every error a generated resolver throws is passed through a sanitizer:
853
+
854
+ - errors drizzle-graphql raises itself (`Unable to update with no values specified!`,
855
+ `Field 'x' is not a valid date!`, filter misuse, …) are written for the client and pass
856
+ through unchanged
857
+ - anything else becomes `Internal server error` with `extensions.code:
858
+ "INTERNAL_SERVER_ERROR"`, and the original is kept on the error's `originalError` so a
859
+ server-side logger can still see it
860
+
861
+ `onError` overrides this. Return an error to surface that one, or return nothing to let the
862
+ default apply — which makes it a pure logging hook:
863
+
864
+ ```Typescript
865
+ const { schema } = buildSchema(db, {
866
+ onError: (error) => {
867
+ logger.error({ err: error }, 'drizzle-graphql resolver failed')
868
+ // no return value — the default sanitizing still applies
869
+ },
870
+ })
871
+ ```
872
+
873
+ ```Typescript
874
+ // Surface raw database errors, e.g. in development
875
+ buildSchema(db, { onError: (error) => error as Error })
876
+
877
+ // Or map them yourself
878
+ buildSchema(db, {
879
+ onError: (error) =>
880
+ isUniqueViolation(error)
881
+ ? new GraphQLError('That record already exists', { extensions: { code: 'CONFLICT' } })
882
+ : undefined,
883
+ })
884
+ ```
885
+
886
+ The hook covers root queries and mutations, relation and aggregate fields, and the
887
+ standalone `entities.fieldResolvers`. The default is exported as `defaultErrorMapper` if you
888
+ want to fall back to it explicitly.
889
+
890
+ ## Transactions
891
+
892
+ Each resolver runs its statements on the database the schema was built from. A request that
893
+ fires several mutations therefore commits each one separately, and a failure halfway leaves
894
+ the earlier ones in place. To run a whole request as one unit, open a transaction yourself
895
+ and put it on the GraphQL context under the exported `drizzleExecutorKey`:
896
+
897
+ ```Typescript
898
+ import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
899
+
900
+ const { schema } = buildSchema(db)
901
+
902
+ await db.transaction(async (tx) => {
903
+ const result = await graphql({
904
+ schema,
905
+ source: request.query,
906
+ variableValues: request.variables,
907
+ contextValue: { [drizzleExecutorKey]: tx },
908
+ })
909
+
910
+ if (result.errors?.length) throw new Error('rolling back')
911
+ return result
912
+ })
913
+ ```
914
+
915
+ Every generated resolver reads the key at resolve time, so queries, mutations, aggregates
916
+ and relation field resolvers all run on the executor you supply and see its uncommitted
917
+ rows. With no key on the context, everything falls back to the build-time database, which
918
+ is what an ordinary request does.
919
+
920
+ The value does not have to be a transaction — any object with the same interface works, for
921
+ example a pooled connection bound to a tenant, or a logging proxy around `db`. Because the
922
+ key is created with `Symbol.for`, the ESM and CJS builds of this package agree on it when
923
+ both end up loaded in one process.
924
+
925
+ ## Relations & N+1 handling
926
+
927
+ Generated schemas resolve nested relations without N+1 query explosions:
928
+
929
+ - **Queries** — root queries (`entities.queries.*`) eagerly load every requested
930
+ relation in a single round-trip using Drizzle's relational query builder
931
+ (`with:`), including nested relations and per-relation `where` / `orderBy` /
932
+ `limit` / `offset` arguments.
933
+ - **Mutations (PostgreSQL & SQLite)** — after an insert or update, if the selection set
934
+ includes relation fields, the affected rows are re-fetched by primary key through one
935
+ relational query so their relations are eagerly loaded (single- and composite-column
936
+ primary keys are supported). `delete` mutations keep using the request-scoped batch
937
+ loader, since the rows no longer exist to re-fetch. MySQL mutations return only a
938
+ success flag (no row payload), so this step does not apply there.
939
+ - **Custom schemas** — each relation field also has a standalone resolver, exported as
940
+ `entities.fieldResolvers[TableName][relationName]`. When you wire generated types
941
+ into your own schema and your root resolver does **not** pre-fetch relations, these
942
+ resolvers batch all sibling loads in a request into a single `IN (…)` query
943
+ (request-scoped, keyed on the GraphQL `context`), preventing N+1. Per-parent
944
+ `limit` / `offset` on a to-many relation is applied across the whole batch using a
945
+ `ROW_NUMBER() OVER (PARTITION BY …)` window function — still one query, not one per
946
+ parent.
947
+
948
+ ```Typescript
949
+ // entities.fieldResolvers is keyed by table name, then relation name
950
+ const usersPostsResolver = entities.fieldResolvers.Users.posts
951
+ ```
952
+
953
+ > [!IMPORTANT]
954
+ > Per-parent paginated relations (a to-many relation field with `limit` or `offset`)
955
+ > rely on SQL window functions. These require **PostgreSQL**, **MySQL 8.0+**, or
956
+ > **SQLite 3.25+**. Relations without pagination, and all other query/mutation paths,
957
+ > have no such requirement.
958
+
959
+ ### Relation nullability
960
+
961
+ A **to-many** relation field is always `[Target!]!`. A **to-one** relation field is
962
+ nullable by default, but honors the relation's declared optionality: declaring
963
+ `optional: false` on a Drizzle `one` relation — the assertion that the related row always
964
+ exists, i.e. a `NOT NULL` foreign key — emits the field as `Target!`:
965
+
966
+ ```Typescript
967
+ const relations = buildRelations({ Users, Posts }, {
968
+ Posts: {
969
+ // posts.author_id is NOT NULL → author: Users!
970
+ author: r.one.Users({ from: r.Posts.authorId, to: r.Users.id, optional: false }),
971
+ },
972
+ })
973
+ ```
974
+
975
+ Only the explicit `optional: false` declaration is honored — required-ness is never
976
+ inferred from column nullability, since a `NOT NULL` `from` column does not guarantee a
977
+ related row exists when the constraint lives on the other side of the join. Note that on a
978
+ required relation, a `where` argument that filters out the related row (or a dangling
979
+ foreign key) resolves to `null` and therefore surfaces as a GraphQL non-null error.
980
+
981
+ ### Overriding a relation's resolver without overfetching
982
+
983
+ By default the eager `with:` pre-fetch is driven purely by the GraphQL selection set:
984
+ any selected relation is fetched from the database, even if you intend to resolve it
985
+ yourself (from a cache, another service, a dataloader, …). To override a relation's
986
+ resolver, first opt it out of eager loading with `eagerLoadRelations` so the parent
987
+ query stops fetching it, then supply your resolver with the standard
988
+ [`@graphql-tools/schema`](https://the-guild.dev/graphql/tools) utilities:
989
+
990
+ ```Typescript
991
+ import { addResolversToSchema } from '@graphql-tools/schema'
992
+
993
+ const { schema } = buildSchema(db, {
994
+ // Exclude Users.posts from the parent query's `with:` clause.
995
+ // Other relations keep eager-loading as usual.
996
+ eagerLoadRelations: (table, relation) => !(table === 'Users' && relation === 'posts'),
997
+ })
998
+
999
+ const finalSchema = addResolversToSchema({
1000
+ schema,
1001
+ resolvers: {
1002
+ Users: {
1003
+ posts: (parent, args, context) => context.postsLoader.load(parent.id),
1004
+ },
1005
+ },
1006
+ })
1007
+ ```
1008
+
1009
+ `eagerLoadRelations` accepts:
1010
+
1011
+ - `true` (default) — eager-load every relation.
1012
+ - `false` — never eager-load; every relation resolves lazily through its
1013
+ (request-batched) field resolver.
1014
+ - `(tableName, relationName) => boolean` — decide per relation. Returning `false`
1015
+ excludes that relation from `with:` (and from the mutation eager re-fetch).
1016
+
1017
+ Opting a relation out does **not** remove its field — it keeps resolving lazily via the
1018
+ request-scoped batch loader, so the field still works even before you override it. Table
1019
+ and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
1020
+ of `entities.fieldResolvers`.