@vantreeseba/drizzle-graphql 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +390 -0
- package/dist/README.md +390 -0
- package/dist/index.cjs +1760 -463
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -5
- package/dist/index.d.ts +216 -5
- package/dist/index.js +1712 -401
- package/dist/index.js.map +1 -1
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,6 +85,396 @@ Automatically create GraphQL schema or customizable schema config fields from Dr
|
|
|
85
85
|
})
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
## Choosing what gets generated
|
|
89
|
+
|
|
90
|
+
Every generated operation is on by default. `features` turns individual ones off, which
|
|
91
|
+
keeps them out of the schema, out of `entities`, and out of the type map — a schema for a
|
|
92
|
+
read-only API, or one that never exposes aggregates, does not pay for types nobody can
|
|
93
|
+
reach:
|
|
94
|
+
|
|
95
|
+
```Typescript
|
|
96
|
+
const { schema } = buildSchema(db, {
|
|
97
|
+
features: {
|
|
98
|
+
aggregates: false, // <plural>Aggregate root queries
|
|
99
|
+
relationAggregates: false, // <relation>Aggregate fields on object types
|
|
100
|
+
distinct: false, // the `distinct` argument on list queries
|
|
101
|
+
insert: false, // create<Table> / create<Table>Single mutations
|
|
102
|
+
update: false, // update<Table> mutations
|
|
103
|
+
delete: false, // delete<Table> mutations
|
|
104
|
+
upsert: true, // upsert<Table> / upsert<Table>Single mutations (off by default)
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- Any flag left out keeps its default of `true`, so `{ features: { delete: false } }`
|
|
110
|
+
changes nothing else
|
|
111
|
+
- `upsert` is the exception: it defaults to `false`, so the upsert mutations and their
|
|
112
|
+
conflict input only exist if you ask for them
|
|
113
|
+
- Turning off `insert` or `update` also drops the input type that only that mutation
|
|
114
|
+
used (`Create<Type>Input` / `Update<Type>Input`)
|
|
115
|
+
- Turning off all three mutation features omits the `Mutation` type entirely, the same as
|
|
116
|
+
`mutations: false`
|
|
117
|
+
- List and single queries are always generated, so `Query` is never empty
|
|
118
|
+
|
|
119
|
+
## Scalars
|
|
120
|
+
|
|
121
|
+
Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
|
|
122
|
+
generated SDL says what a field actually holds instead of falling back to `String`:
|
|
123
|
+
|
|
124
|
+
| Drizzle column | GraphQL type | Transported as |
|
|
125
|
+
| ------------------------------------------ | ------------ | ------------------------------------ |
|
|
126
|
+
| `json` / `jsonb` (and `mode: 'json'`) | `JSON` | the parsed value |
|
|
127
|
+
| `bigint` (and `mode: 'bigint'`) | `BigInt` | a decimal string |
|
|
128
|
+
| `uuid` | `UUID` | a validated UUID string |
|
|
129
|
+
| `timestamp` / `datetime` | `DateTime` | an ISO-8601 string |
|
|
130
|
+
| `date` | `Date` | a `YYYY-MM-DD` string |
|
|
131
|
+
|
|
132
|
+
```graphql
|
|
133
|
+
mutation {
|
|
134
|
+
createDocumentSingle(values: { id: "11111111-1111-4111-8111-111111111111", payload: { tags: ["a"], views: 3 }, counter: "9007199254740993" }) {
|
|
135
|
+
payload
|
|
136
|
+
counter
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- **`JSON`** carries the value itself — objects, arrays, numbers, strings, `true`/`false`.
|
|
142
|
+
Reads return the parsed value, not a stringified one, and writes take a literal or a
|
|
143
|
+
variable rather than a string of JSON
|
|
144
|
+
- **`BigInt`** is always a decimal string in both directions, so values past
|
|
145
|
+
`Number.MAX_SAFE_INTEGER` survive the round-trip. Integer literals are accepted on input;
|
|
146
|
+
floats and non-numeric strings are rejected
|
|
147
|
+
- **`UUID`** validates the format on the way in, including inside `where` filters
|
|
148
|
+
|
|
149
|
+
The scalars are exported if you need them in a hand-written schema:
|
|
150
|
+
|
|
151
|
+
```Typescript
|
|
152
|
+
import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQLUUID } from 'drizzle-graphql'
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
|
|
156
|
+
called in SDL, to avoid clashing with the language's own `BigInt`.
|
|
157
|
+
|
|
158
|
+
## Relation filters
|
|
159
|
+
|
|
160
|
+
A table's `where` input also exposes its relations, so you can filter rows by what they're
|
|
161
|
+
related to instead of pulling everything and filtering client-side.
|
|
162
|
+
|
|
163
|
+
A **to-one** relation takes the target table's filter input directly:
|
|
164
|
+
|
|
165
|
+
```graphql
|
|
166
|
+
{
|
|
167
|
+
posts(where: { author: { name: { eq: "FifthUser" } } }) {
|
|
168
|
+
id
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
A **to-many** relation takes a `some` / `none` / `every` wrapper
|
|
174
|
+
(`<Target>ListRelationFilter`):
|
|
175
|
+
|
|
176
|
+
```graphql
|
|
177
|
+
{
|
|
178
|
+
# users who wrote at least one post containing "drizzle"
|
|
179
|
+
users(where: { posts: { some: { content: { like: "%drizzle%" } } } }) {
|
|
180
|
+
id
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# users with no posts at all
|
|
184
|
+
users(where: { posts: { none: {} } }) {
|
|
185
|
+
id
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# users all of whose posts are published (users with no posts match vacuously)
|
|
189
|
+
users(where: { posts: { every: { isPublished: { eq: true } } } }) {
|
|
190
|
+
id
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- Relation filters compile to correlated `EXISTS` subqueries — the related rows are never
|
|
196
|
+
fetched, and no join duplicates the parent rows
|
|
197
|
+
- They nest arbitrarily (`users(where: { posts: { some: { author: { … } } } })`) and combine
|
|
198
|
+
freely with column filters (implicit `AND`) and with `OR`
|
|
199
|
+
- They're accepted anywhere a filter is — list and single queries, aggregate queries,
|
|
200
|
+
`update`/`delete` mutations, and the `where` argument on a relation field
|
|
201
|
+
- `some: {}` means "at least one related row exists"; `none: {}` means "none exist"
|
|
202
|
+
- Several modes may be given at once and are `AND`ed together
|
|
203
|
+
- A relation whose name collides with a column name is skipped — the column keeps the field
|
|
204
|
+
- Many-to-many relations declared with `.through()` are not filterable yet and are left out
|
|
205
|
+
of the filter input
|
|
206
|
+
|
|
207
|
+
## Aggregate queries
|
|
208
|
+
|
|
209
|
+
Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
|
|
210
|
+
following the same naming rules as the other generated queries:
|
|
211
|
+
|
|
212
|
+
```graphql
|
|
213
|
+
{
|
|
214
|
+
postsAggregate(where: { authorId: { eq: 1 } }) {
|
|
215
|
+
count
|
|
216
|
+
avg {
|
|
217
|
+
views
|
|
218
|
+
}
|
|
219
|
+
sum {
|
|
220
|
+
views
|
|
221
|
+
}
|
|
222
|
+
min {
|
|
223
|
+
createdAt
|
|
224
|
+
}
|
|
225
|
+
max {
|
|
226
|
+
createdAt
|
|
227
|
+
title
|
|
228
|
+
}
|
|
229
|
+
countNonNull {
|
|
230
|
+
publishedAt
|
|
231
|
+
}
|
|
232
|
+
countDistinct {
|
|
233
|
+
authorId
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
- `count` — number of matching rows (`Int!`)
|
|
240
|
+
- `avg` / `sum` — one nullable `Float` field per numeric column
|
|
241
|
+
- `min` / `max` — one field per orderable column (numbers, strings, enums, dates, bigints),
|
|
242
|
+
typed exactly like that column is in the table's own type
|
|
243
|
+
- `countNonNull` — `Int!` per column: how many matching rows have a non-null value there.
|
|
244
|
+
Every column qualifies, since `count(col)` is valid whatever the type
|
|
245
|
+
- `countDistinct` — `Int!` per column: how many distinct non-null values there are. Limited to
|
|
246
|
+
the same columns as `min` / `max`, because counting distinct values needs an equality operator
|
|
247
|
+
|
|
248
|
+
Columns that have no meaningful ordering — booleans, arrays, JSON, buffers, and geometry —
|
|
249
|
+
are left out of these types, and the `avg` / `sum` / `min` / `max` fields themselves are
|
|
250
|
+
omitted when no column qualifies.
|
|
251
|
+
|
|
252
|
+
The optional `where` argument takes the same filter input as the table's list query, and is
|
|
253
|
+
applied to every aggregate in the selection. All requested aggregates are computed in a
|
|
254
|
+
single `SELECT`, and on an empty result set `count` is `0` while the other values are `null`.
|
|
255
|
+
|
|
256
|
+
Grouping (`groupBy`) is not supported yet.
|
|
257
|
+
|
|
258
|
+
## Relation aggregates
|
|
259
|
+
|
|
260
|
+
Every to-many relation also gets an `<relationName>Aggregate` field on the parent type, so you
|
|
261
|
+
can count or summarise related rows without fetching them:
|
|
262
|
+
|
|
263
|
+
```graphql
|
|
264
|
+
{
|
|
265
|
+
users {
|
|
266
|
+
id
|
|
267
|
+
postsAggregate {
|
|
268
|
+
count
|
|
269
|
+
}
|
|
270
|
+
publishedPosts: postsAggregate(where: { published: { eq: true } }) {
|
|
271
|
+
count
|
|
272
|
+
max {
|
|
273
|
+
createdAt
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- The field returns the target table's own `<Type>Aggregate` type — the same one the root
|
|
281
|
+
`<tableName>Aggregate` query returns, so `count` / `avg` / `sum` / `min` / `max` behave
|
|
282
|
+
identically
|
|
283
|
+
- `where` takes the target table's filter input, including [relation filters](#relation-filters),
|
|
284
|
+
and applies only to the related rows
|
|
285
|
+
- A parent with no related rows gets `count: 0` and `null` for every other aggregate
|
|
286
|
+
- To-one relations get no aggregate field — there is nothing to aggregate over
|
|
287
|
+
- The field is skipped if its name would collide with a column or another relation
|
|
288
|
+
|
|
289
|
+
All parents in a selection are aggregated with a single
|
|
290
|
+
`SELECT <fk>, … WHERE <fk> IN (…) GROUP BY <fk>` per request, so `postsAggregate` on a list of
|
|
291
|
+
users is one extra query, not one per user. Differently-aliased selections with different
|
|
292
|
+
`where` arguments are batched separately.
|
|
293
|
+
|
|
294
|
+
## Distinct
|
|
295
|
+
|
|
296
|
+
List queries take a `distinct` argument — a list of columns from the `<Type>DistinctColumn`
|
|
297
|
+
enum. Rows sharing the same combination of those columns collapse to one:
|
|
298
|
+
|
|
299
|
+
```graphql
|
|
300
|
+
{
|
|
301
|
+
# the first post of each author
|
|
302
|
+
posts(distinct: [authorId], orderBy: { createdAt: { direction: asc, priority: 1 } }) {
|
|
303
|
+
id
|
|
304
|
+
authorId
|
|
305
|
+
createdAt
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
- Which row survives each group is decided by `orderBy` — the first one wins. With no
|
|
311
|
+
`orderBy`, that's the lowest primary key
|
|
312
|
+
- `where` is applied **before** rows are collapsed; `limit` and `offset` are applied
|
|
313
|
+
**after**, so `limit: 10` returns ten distinct rows
|
|
314
|
+
- Several columns are treated as one combined key, not as independent ones
|
|
315
|
+
- `distinct` is available on list queries only — a single query returns one row either way
|
|
316
|
+
|
|
317
|
+
It runs as an extra `row_number() over (partition by …)` query that picks the surviving
|
|
318
|
+
rows' primary keys, after which the main query is narrowed to them — so it needs the same
|
|
319
|
+
window-function support as per-parent paginated relations (**PostgreSQL**, **MySQL 8.0+**,
|
|
320
|
+
or **SQLite 3.25+**), and a table with no primary key cannot use it.
|
|
321
|
+
|
|
322
|
+
## Pagination ordering
|
|
323
|
+
|
|
324
|
+
SQL gives no ordering guarantee for a query that has no `ORDER BY`, so paging through an
|
|
325
|
+
unordered result can return the same row twice or skip one entirely. To keep pages stable,
|
|
326
|
+
a query that returns only part of a table is ordered by its primary key when the request
|
|
327
|
+
supplies no `orderBy`:
|
|
328
|
+
|
|
329
|
+
- list queries with `limit` and/or `offset`
|
|
330
|
+
- `<tableName>Single` queries, which are an implicit `limit 1`
|
|
331
|
+
- to-many relation fields with `limit` and/or `offset` (as a tiebreak appended to any
|
|
332
|
+
`orderBy` you do supply, so per-parent slices are deterministic)
|
|
333
|
+
|
|
334
|
+
An explicit `orderBy` always takes precedence, composite primary keys are ordered by every
|
|
335
|
+
key column, and an unpaginated list query is left unordered so no sort is paid for.
|
|
336
|
+
|
|
337
|
+
## Upsert
|
|
338
|
+
|
|
339
|
+
`features.upsert` adds a pair of mutations per table that insert rows, or update the ones
|
|
340
|
+
that already exist:
|
|
341
|
+
|
|
342
|
+
```graphql
|
|
343
|
+
mutation {
|
|
344
|
+
upsertUsersSingle(values: { id: 1, name: "Dan", email: "dan@example.com" }) {
|
|
345
|
+
id
|
|
346
|
+
name
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
With no `onConflict`, a conflict on the **primary key** overwrites every column the request
|
|
352
|
+
supplied. `onConflict` changes that:
|
|
353
|
+
|
|
354
|
+
```graphql
|
|
355
|
+
mutation {
|
|
356
|
+
upsertUsers(
|
|
357
|
+
values: [
|
|
358
|
+
{ email: "dan@example.com", name: "Dan", visits: 1 }
|
|
359
|
+
{ email: "sam@example.com", name: "Sam", visits: 1 }
|
|
360
|
+
]
|
|
361
|
+
onConflict: {
|
|
362
|
+
target: [email] # must be a unique constraint; defaults to the primary key
|
|
363
|
+
action: UPDATE # or NOTHING, to keep the existing row
|
|
364
|
+
update: [name] # columns to overwrite; defaults to every supplied column
|
|
365
|
+
where: { isConfirmed: { eq: true } } # only overwrite rows that match
|
|
366
|
+
}
|
|
367
|
+
) {
|
|
368
|
+
id
|
|
369
|
+
name
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
- A batch upsert updates each row with **its own** values (`excluded.<column>`), not with
|
|
375
|
+
the last row's
|
|
376
|
+
- Columns the request did not supply are never overwritten — a partial upsert does not null
|
|
377
|
+
out the rest of the row. Listing an unsupplied column in `update` is an error rather than
|
|
378
|
+
a silent no-op
|
|
379
|
+
- `target` must match one of the table's unique constraints exactly; anything else is
|
|
380
|
+
rejected with the list of valid targets, instead of the database's opaque "no unique or
|
|
381
|
+
exclusion constraint matching" error
|
|
382
|
+
- `action: NOTHING` inserts nothing and returns nothing for the conflicting row. An `UPDATE`
|
|
383
|
+
with no columns left to write degrades to the same thing
|
|
384
|
+
- `values` is the same `Create<Type>Input` the insert mutations take, so turning `insert`
|
|
385
|
+
off does not remove it
|
|
386
|
+
|
|
387
|
+
Dialect differences:
|
|
388
|
+
|
|
389
|
+
- **PostgreSQL** and **SQLite** — the full surface above. A table with no primary key and no
|
|
390
|
+
unique constraint has nothing to conflict on, so it gets no upsert mutations at all
|
|
391
|
+
- **MySQL** — `ON DUPLICATE KEY UPDATE` fires on whichever unique key was violated and takes
|
|
392
|
+
no predicate, so `<Type>OnConflict` there has only `action` and `update`. `action: NOTHING`
|
|
393
|
+
becomes `INSERT IGNORE`, and the mutations return `MutationReturn` like every other MySQL
|
|
394
|
+
mutation
|
|
395
|
+
|
|
396
|
+
The build-wide `conflictDoNothing` option is deprecated in favour of this: it applies to every
|
|
397
|
+
`create*` mutation with no way for a request to opt out. `onConflict: { action: NOTHING }` is
|
|
398
|
+
the per-request replacement.
|
|
399
|
+
|
|
400
|
+
## Error handling
|
|
401
|
+
|
|
402
|
+
Database drivers put a lot into an error message. Drizzle rethrows them with the full SQL
|
|
403
|
+
statement and its bound parameters attached, and Postgres itself names the table, the column
|
|
404
|
+
and the constraint that was violated. None of that belongs in a GraphQL response, so by
|
|
405
|
+
default every error a generated resolver throws is passed through a sanitizer:
|
|
406
|
+
|
|
407
|
+
- errors drizzle-graphql raises itself (`Unable to update with no values specified!`,
|
|
408
|
+
`Field 'x' is not a valid date!`, filter misuse, …) are written for the client and pass
|
|
409
|
+
through unchanged
|
|
410
|
+
- anything else becomes `Internal server error` with `extensions.code:
|
|
411
|
+
"INTERNAL_SERVER_ERROR"`, and the original is kept on the error's `originalError` so a
|
|
412
|
+
server-side logger can still see it
|
|
413
|
+
|
|
414
|
+
`onError` overrides this. Return an error to surface that one, or return nothing to let the
|
|
415
|
+
default apply — which makes it a pure logging hook:
|
|
416
|
+
|
|
417
|
+
```Typescript
|
|
418
|
+
const { schema } = buildSchema(db, {
|
|
419
|
+
onError: (error) => {
|
|
420
|
+
logger.error({ err: error }, 'drizzle-graphql resolver failed')
|
|
421
|
+
// no return value — the default sanitizing still applies
|
|
422
|
+
},
|
|
423
|
+
})
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
```Typescript
|
|
427
|
+
// Surface raw database errors, e.g. in development
|
|
428
|
+
buildSchema(db, { onError: (error) => error as Error })
|
|
429
|
+
|
|
430
|
+
// Or map them yourself
|
|
431
|
+
buildSchema(db, {
|
|
432
|
+
onError: (error) =>
|
|
433
|
+
isUniqueViolation(error)
|
|
434
|
+
? new GraphQLError('That record already exists', { extensions: { code: 'CONFLICT' } })
|
|
435
|
+
: undefined,
|
|
436
|
+
})
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
The hook covers root queries and mutations, relation and aggregate fields, and the
|
|
440
|
+
standalone `entities.fieldResolvers`. The default is exported as `defaultErrorMapper` if you
|
|
441
|
+
want to fall back to it explicitly.
|
|
442
|
+
|
|
443
|
+
## Transactions
|
|
444
|
+
|
|
445
|
+
Each resolver runs its statements on the database the schema was built from. A request that
|
|
446
|
+
fires several mutations therefore commits each one separately, and a failure halfway leaves
|
|
447
|
+
the earlier ones in place. To run a whole request as one unit, open a transaction yourself
|
|
448
|
+
and put it on the GraphQL context under the exported `drizzleExecutorKey`:
|
|
449
|
+
|
|
450
|
+
```Typescript
|
|
451
|
+
import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
|
|
452
|
+
|
|
453
|
+
const { schema } = buildSchema(db)
|
|
454
|
+
|
|
455
|
+
await db.transaction(async (tx) => {
|
|
456
|
+
const result = await graphql({
|
|
457
|
+
schema,
|
|
458
|
+
source: request.query,
|
|
459
|
+
variableValues: request.variables,
|
|
460
|
+
contextValue: { [drizzleExecutorKey]: tx },
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
if (result.errors?.length) throw new Error('rolling back')
|
|
464
|
+
return result
|
|
465
|
+
})
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Every generated resolver reads the key at resolve time, so queries, mutations, aggregates
|
|
469
|
+
and relation field resolvers all run on the executor you supply and see its uncommitted
|
|
470
|
+
rows. With no key on the context, everything falls back to the build-time database, which
|
|
471
|
+
is what an ordinary request does.
|
|
472
|
+
|
|
473
|
+
The value does not have to be a transaction — any object with the same interface works, for
|
|
474
|
+
example a pooled connection bound to a tenant, or a logging proxy around `db`. Because the
|
|
475
|
+
key is created with `Symbol.for`, the ESM and CJS builds of this package agree on it when
|
|
476
|
+
both end up loaded in one process.
|
|
477
|
+
|
|
88
478
|
## Relations & N+1 handling
|
|
89
479
|
|
|
90
480
|
Generated schemas resolve nested relations without N+1 query explosions:
|