@vantreeseba/drizzle-graphql 9.1.0 → 9.1.1
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/index.cjs +7442 -7879
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1491 -1535
- package/dist/index.d.ts +1491 -1535
- package/dist/index.js +7405 -7946
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/dist/README.md +0 -2216
- package/dist/package.json +0 -108
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 |
|