@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 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: