@vantreeseba/drizzle-graphql 4.1.0 → 5.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
@@ -96,12 +96,14 @@ reach:
96
96
  const { schema } = buildSchema(db, {
97
97
  features: {
98
98
  aggregates: false, // <plural>Aggregate root queries
99
+ groupBy: false, // <plural>GroupBy root queries
99
100
  relationAggregates: false, // <relation>Aggregate fields on object types
100
101
  distinct: false, // the `distinct` argument on list queries
101
102
  insert: false, // create<Table> / create<Table>Single mutations
102
- update: false, // update<Table> mutations
103
- delete: false, // delete<Table> mutations
103
+ update: false, // update<Table> / update<Table>Single mutations
104
+ delete: false, // delete<Table> / delete<Table>Single mutations
104
105
  upsert: true, // upsert<Table> / upsert<Table>Single mutations (off by default)
106
+ requireWhere: true, // make `where` non-null on plural update/delete (off by default)
105
107
  },
106
108
  })
107
109
  ```
@@ -110,6 +112,8 @@ const { schema } = buildSchema(db, {
110
112
  changes nothing else
111
113
  - `upsert` is the exception: it defaults to `false`, so the upsert mutations and their
112
114
  conflict input only exist if you ask for them
115
+ - `groupBy` needs `aggregates`: it reuses those output types, so turning `aggregates` off
116
+ turns the group-by queries off with it
113
117
  - Turning off `insert` or `update` also drops the input type that only that mutation
114
118
  used (`Create<Type>Input` / `Update<Type>Input`)
115
119
  - Turning off all three mutation features omits the `Mutation` type entirely, the same as
@@ -201,8 +205,10 @@ A **to-many** relation takes a `some` / `none` / `every` wrapper
201
205
  - `some: {}` means "at least one related row exists"; `none: {}` means "none exist"
202
206
  - Several modes may be given at once and are `AND`ed together
203
207
  - 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
208
+ - Many-to-many relations declared with `.through()` are filterable too: the `EXISTS`
209
+ subquery joins the junction table to the target, so
210
+ `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
211
+ to-many relation
206
212
 
207
213
  ## Aggregate queries
208
214
 
@@ -253,7 +259,72 @@ The optional `where` argument takes the same filter input as the table's list qu
253
259
  applied to every aggregate in the selection. All requested aggregates are computed in a
254
260
  single `SELECT`, and on an empty result set `count` is `0` while the other values are `null`.
255
261
 
256
- Grouping (`groupBy`) is not supported yet.
262
+ Add `groupBy` to get the same numbers one row per group — see [Group by](#group-by).
263
+
264
+ ## Group by
265
+
266
+ Every table with aggregates also gets a `<tableName>GroupBy` query (e.g. `postsGroupBy`): the
267
+ same aggregates as `<tableName>Aggregate`, computed once per distinct combination of the
268
+ columns you group by.
269
+
270
+ ```graphql
271
+ {
272
+ postsGroupBy(
273
+ groupBy: [authorId, published]
274
+ where: { createdAt: { gte: "2024-01-01T00:00:00Z" } }
275
+ having: { count: { gte: 5 } }
276
+ ) {
277
+ group {
278
+ authorId
279
+ published
280
+ }
281
+ count
282
+ avg {
283
+ views
284
+ }
285
+ max {
286
+ createdAt
287
+ }
288
+ }
289
+ }
290
+ ```
291
+
292
+ - `groupBy` is required and takes one or more values of the table's `<Type>GroupByColumn`
293
+ enum. It holds the orderable columns plus booleans — anything the database can group on.
294
+ An empty list is an error, and repeated columns are ignored
295
+ - `group` is a `<Type>GroupKeys!` object with one nullable field per groupable column, typed
296
+ like the table's own column. Columns the query did not group by come back as `null`, which
297
+ a column whose grouped value really is `NULL` is indistinguishable from
298
+ - Every other field is the same one `<tableName>Aggregate` returns — `count`, `avg`, `sum`,
299
+ `min`, `max`, `countNonNull`, `countDistinct` — with the identical types and rules
300
+ - `where` filters rows before grouping; `having` filters groups after aggregating
301
+ - The result is `[<Type>GroupBy!]!`, one row per group, in whatever order the database
302
+ returns them. Add your own ordering client-side if you need it
303
+
304
+ `having` mirrors the aggregate selection, with an `AggregateNumberFilter` (`eq`, `ne`, `gt`,
305
+ `gte`, `lt`, `lte`, all `Float`) in place of each value:
306
+
307
+ ```graphql
308
+ {
309
+ postsGroupBy(groupBy: [authorId], having: { count: { gt: 3 }, avg: { views: { gte: 100 } } }) {
310
+ group {
311
+ authorId
312
+ }
313
+ count
314
+ }
315
+ }
316
+ ```
317
+
318
+ - `having: { count: … }` filters on the row count; `avg` / `sum` / `min` / `max` /
319
+ `countNonNull` / `countDistinct` take one filter per column, over the same columns the
320
+ matching aggregate type exposes
321
+ - Every entry in a `having` is ANDed together
322
+ - A group filter does not need to be in the selection — `having: { count: { gt: 3 } }` works
323
+ whether or not you asked for `count`
324
+
325
+ The whole thing is one `SELECT … GROUP BY … HAVING …`. Set `features: { groupBy: false }` to
326
+ leave these queries (and their `<Type>GroupBy`, `<Type>GroupKeys`, `<Type>GroupByColumn` and
327
+ `<Type>Having` types) out of the schema; turning off `aggregates` removes them too.
257
328
 
258
329
  ## Relation aggregates
259
330
 
@@ -397,6 +468,37 @@ The build-wide `conflictDoNothing` option is deprecated in favour of this: it ap
397
468
  `create*` mutation with no way for a request to opt out. `onConflict: { action: NOTHING }` is
398
469
  the per-request replacement.
399
470
 
471
+ ## Single-row update & delete
472
+
473
+ Alongside the plural `update<Table>` / `delete<Table>` mutations, every table gets an
474
+ `update<Table>Single` / `delete<Table>Single` variant that targets exactly one row:
475
+
476
+ ```graphql
477
+ mutation {
478
+ updateUsersSingle(where: { id: { eq: 1 } }, set: { name: "Dan" }) {
479
+ id
480
+ name
481
+ }
482
+ }
483
+ ```
484
+
485
+ - `where` is **non-null** and must contain at least one filter — `where: {}` is rejected at
486
+ resolve time, so a Single write can never become an unbounded one
487
+ - The return type is the single (nullable) row type, not a list: the affected row comes back
488
+ directly, and **no match returns `null`** instead of an empty list
489
+ - If `where` matches **more than one row**, the mutation throws a `GraphQLError` and writes
490
+ nothing — the match is checked (`LIMIT 2`) before the write runs
491
+ - **MySQL** cannot return the affected row (no `RETURNING`), so its Single variants keep the
492
+ single-match and non-empty-`where` guarantees but return `MutationReturn` like every other
493
+ MySQL mutation
494
+ - The variants are generated under `features.update` / `features.delete`, share the plural
495
+ mutations' input types, and appear in `entities.mutations` for custom schemas
496
+
497
+ The plural mutations still accept a missing `where` (a full-table write) by default. To rule
498
+ that out at the type level, `features: { requireWhere: true }` makes `where` non-null on
499
+ `update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
500
+ Single variants. It defaults to `false` for backwards compatibility.
501
+
400
502
  ## Query cost
401
503
 
402
504
  Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
@@ -408,7 +510,7 @@ fields it mentions:
408
510
  | Field | Cost |
409
511
  | --- | --- |
410
512
  | List query, to-many relation | `(limit ?? defaultListSize) * childComplexity` |
411
- | Aggregate query, `<relation>Aggregate` | `aggregateCost + childComplexity` |
513
+ | Aggregate query, group-by query, `<relation>Aggregate` | `aggregateCost + childComplexity` |
412
514
  | Everything else | no hint — your estimator's default applies |
413
515
 
414
516
  So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
@@ -555,6 +657,28 @@ Generated schemas resolve nested relations without N+1 query explosions:
555
657
  > **SQLite 3.25+**. Relations without pagination, and all other query/mutation paths,
556
658
  > have no such requirement.
557
659
 
660
+ ### Relation nullability
661
+
662
+ A **to-many** relation field is always `[Target!]!`. A **to-one** relation field is
663
+ nullable by default, but honors the relation's declared optionality: declaring
664
+ `optional: false` on a Drizzle `one` relation — the assertion that the related row always
665
+ exists, i.e. a `NOT NULL` foreign key — emits the field as `Target!`:
666
+
667
+ ```Typescript
668
+ const relations = buildRelations({ Users, Posts }, {
669
+ Posts: {
670
+ // posts.author_id is NOT NULL → author: Users!
671
+ author: r.one.Users({ from: r.Posts.authorId, to: r.Users.id, optional: false }),
672
+ },
673
+ })
674
+ ```
675
+
676
+ Only the explicit `optional: false` declaration is honored — required-ness is never
677
+ inferred from column nullability, since a `NOT NULL` `from` column does not guarantee a
678
+ related row exists when the constraint lives on the other side of the join. Note that on a
679
+ required relation, a `where` argument that filters out the related row (or a dangling
680
+ foreign key) resolves to `null` and therefore surfaces as a GraphQL non-null error.
681
+
558
682
  ### Overriding a relation's resolver without overfetching
559
683
 
560
684
  By default the eager `with:` pre-fetch is driven purely by the GraphQL selection set:
package/dist/README.md CHANGED
@@ -96,12 +96,14 @@ reach:
96
96
  const { schema } = buildSchema(db, {
97
97
  features: {
98
98
  aggregates: false, // <plural>Aggregate root queries
99
+ groupBy: false, // <plural>GroupBy root queries
99
100
  relationAggregates: false, // <relation>Aggregate fields on object types
100
101
  distinct: false, // the `distinct` argument on list queries
101
102
  insert: false, // create<Table> / create<Table>Single mutations
102
- update: false, // update<Table> mutations
103
- delete: false, // delete<Table> mutations
103
+ update: false, // update<Table> / update<Table>Single mutations
104
+ delete: false, // delete<Table> / delete<Table>Single mutations
104
105
  upsert: true, // upsert<Table> / upsert<Table>Single mutations (off by default)
106
+ requireWhere: true, // make `where` non-null on plural update/delete (off by default)
105
107
  },
106
108
  })
107
109
  ```
@@ -110,6 +112,8 @@ const { schema } = buildSchema(db, {
110
112
  changes nothing else
111
113
  - `upsert` is the exception: it defaults to `false`, so the upsert mutations and their
112
114
  conflict input only exist if you ask for them
115
+ - `groupBy` needs `aggregates`: it reuses those output types, so turning `aggregates` off
116
+ turns the group-by queries off with it
113
117
  - Turning off `insert` or `update` also drops the input type that only that mutation
114
118
  used (`Create<Type>Input` / `Update<Type>Input`)
115
119
  - Turning off all three mutation features omits the `Mutation` type entirely, the same as
@@ -201,8 +205,10 @@ A **to-many** relation takes a `some` / `none` / `every` wrapper
201
205
  - `some: {}` means "at least one related row exists"; `none: {}` means "none exist"
202
206
  - Several modes may be given at once and are `AND`ed together
203
207
  - 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
208
+ - Many-to-many relations declared with `.through()` are filterable too: the `EXISTS`
209
+ subquery joins the junction table to the target, so
210
+ `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
211
+ to-many relation
206
212
 
207
213
  ## Aggregate queries
208
214
 
@@ -253,7 +259,72 @@ The optional `where` argument takes the same filter input as the table's list qu
253
259
  applied to every aggregate in the selection. All requested aggregates are computed in a
254
260
  single `SELECT`, and on an empty result set `count` is `0` while the other values are `null`.
255
261
 
256
- Grouping (`groupBy`) is not supported yet.
262
+ Add `groupBy` to get the same numbers one row per group — see [Group by](#group-by).
263
+
264
+ ## Group by
265
+
266
+ Every table with aggregates also gets a `<tableName>GroupBy` query (e.g. `postsGroupBy`): the
267
+ same aggregates as `<tableName>Aggregate`, computed once per distinct combination of the
268
+ columns you group by.
269
+
270
+ ```graphql
271
+ {
272
+ postsGroupBy(
273
+ groupBy: [authorId, published]
274
+ where: { createdAt: { gte: "2024-01-01T00:00:00Z" } }
275
+ having: { count: { gte: 5 } }
276
+ ) {
277
+ group {
278
+ authorId
279
+ published
280
+ }
281
+ count
282
+ avg {
283
+ views
284
+ }
285
+ max {
286
+ createdAt
287
+ }
288
+ }
289
+ }
290
+ ```
291
+
292
+ - `groupBy` is required and takes one or more values of the table's `<Type>GroupByColumn`
293
+ enum. It holds the orderable columns plus booleans — anything the database can group on.
294
+ An empty list is an error, and repeated columns are ignored
295
+ - `group` is a `<Type>GroupKeys!` object with one nullable field per groupable column, typed
296
+ like the table's own column. Columns the query did not group by come back as `null`, which
297
+ a column whose grouped value really is `NULL` is indistinguishable from
298
+ - Every other field is the same one `<tableName>Aggregate` returns — `count`, `avg`, `sum`,
299
+ `min`, `max`, `countNonNull`, `countDistinct` — with the identical types and rules
300
+ - `where` filters rows before grouping; `having` filters groups after aggregating
301
+ - The result is `[<Type>GroupBy!]!`, one row per group, in whatever order the database
302
+ returns them. Add your own ordering client-side if you need it
303
+
304
+ `having` mirrors the aggregate selection, with an `AggregateNumberFilter` (`eq`, `ne`, `gt`,
305
+ `gte`, `lt`, `lte`, all `Float`) in place of each value:
306
+
307
+ ```graphql
308
+ {
309
+ postsGroupBy(groupBy: [authorId], having: { count: { gt: 3 }, avg: { views: { gte: 100 } } }) {
310
+ group {
311
+ authorId
312
+ }
313
+ count
314
+ }
315
+ }
316
+ ```
317
+
318
+ - `having: { count: … }` filters on the row count; `avg` / `sum` / `min` / `max` /
319
+ `countNonNull` / `countDistinct` take one filter per column, over the same columns the
320
+ matching aggregate type exposes
321
+ - Every entry in a `having` is ANDed together
322
+ - A group filter does not need to be in the selection — `having: { count: { gt: 3 } }` works
323
+ whether or not you asked for `count`
324
+
325
+ The whole thing is one `SELECT … GROUP BY … HAVING …`. Set `features: { groupBy: false }` to
326
+ leave these queries (and their `<Type>GroupBy`, `<Type>GroupKeys`, `<Type>GroupByColumn` and
327
+ `<Type>Having` types) out of the schema; turning off `aggregates` removes them too.
257
328
 
258
329
  ## Relation aggregates
259
330
 
@@ -397,6 +468,37 @@ The build-wide `conflictDoNothing` option is deprecated in favour of this: it ap
397
468
  `create*` mutation with no way for a request to opt out. `onConflict: { action: NOTHING }` is
398
469
  the per-request replacement.
399
470
 
471
+ ## Single-row update & delete
472
+
473
+ Alongside the plural `update<Table>` / `delete<Table>` mutations, every table gets an
474
+ `update<Table>Single` / `delete<Table>Single` variant that targets exactly one row:
475
+
476
+ ```graphql
477
+ mutation {
478
+ updateUsersSingle(where: { id: { eq: 1 } }, set: { name: "Dan" }) {
479
+ id
480
+ name
481
+ }
482
+ }
483
+ ```
484
+
485
+ - `where` is **non-null** and must contain at least one filter — `where: {}` is rejected at
486
+ resolve time, so a Single write can never become an unbounded one
487
+ - The return type is the single (nullable) row type, not a list: the affected row comes back
488
+ directly, and **no match returns `null`** instead of an empty list
489
+ - If `where` matches **more than one row**, the mutation throws a `GraphQLError` and writes
490
+ nothing — the match is checked (`LIMIT 2`) before the write runs
491
+ - **MySQL** cannot return the affected row (no `RETURNING`), so its Single variants keep the
492
+ single-match and non-empty-`where` guarantees but return `MutationReturn` like every other
493
+ MySQL mutation
494
+ - The variants are generated under `features.update` / `features.delete`, share the plural
495
+ mutations' input types, and appear in `entities.mutations` for custom schemas
496
+
497
+ The plural mutations still accept a missing `where` (a full-table write) by default. To rule
498
+ that out at the type level, `features: { requireWhere: true }` makes `where` non-null on
499
+ `update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
500
+ Single variants. It defaults to `false` for backwards compatibility.
501
+
400
502
  ## Query cost
401
503
 
402
504
  Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
@@ -408,7 +510,7 @@ fields it mentions:
408
510
  | Field | Cost |
409
511
  | --- | --- |
410
512
  | List query, to-many relation | `(limit ?? defaultListSize) * childComplexity` |
411
- | Aggregate query, `<relation>Aggregate` | `aggregateCost + childComplexity` |
513
+ | Aggregate query, group-by query, `<relation>Aggregate` | `aggregateCost + childComplexity` |
412
514
  | Everything else | no hint — your estimator's default applies |
413
515
 
414
516
  So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
@@ -555,6 +657,28 @@ Generated schemas resolve nested relations without N+1 query explosions:
555
657
  > **SQLite 3.25+**. Relations without pagination, and all other query/mutation paths,
556
658
  > have no such requirement.
557
659
 
660
+ ### Relation nullability
661
+
662
+ A **to-many** relation field is always `[Target!]!`. A **to-one** relation field is
663
+ nullable by default, but honors the relation's declared optionality: declaring
664
+ `optional: false` on a Drizzle `one` relation — the assertion that the related row always
665
+ exists, i.e. a `NOT NULL` foreign key — emits the field as `Target!`:
666
+
667
+ ```Typescript
668
+ const relations = buildRelations({ Users, Posts }, {
669
+ Posts: {
670
+ // posts.author_id is NOT NULL → author: Users!
671
+ author: r.one.Users({ from: r.Posts.authorId, to: r.Users.id, optional: false }),
672
+ },
673
+ })
674
+ ```
675
+
676
+ Only the explicit `optional: false` declaration is honored — required-ness is never
677
+ inferred from column nullability, since a `NOT NULL` `from` column does not guarantee a
678
+ related row exists when the constraint lives on the other side of the join. Note that on a
679
+ required relation, a `where` argument that filters out the related row (or a dangling
680
+ foreign key) resolves to `null` and therefore surfaces as a GraphQL non-null error.
681
+
558
682
  ### Overriding a relation's resolver without overfetching
559
683
 
560
684
  By default the eager `with:` pre-fetch is driven purely by the GraphQL selection set: