@vantreeseba/drizzle-graphql 7.2.0 → 7.4.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,39 @@ Automatically create GraphQL schema or customizable schema config fields from Dr
85
85
  })
86
86
  ```
87
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
+
88
121
  ## Choosing what gets generated
89
122
 
90
123
  Every generated operation is on by default. `features` turns individual ones off, which
@@ -124,6 +157,35 @@ const { schema } = buildSchema(db, {
124
157
  `mutations: false`
125
158
  - List and single queries are always generated, so `Query` is never empty
126
159
 
160
+ ### Per-table operations
161
+
162
+ The choice is rarely build-wide: a schema usually has a handful of tables that must not be
163
+ deleted through the API, a handful whose writes belong to a hand-written resolver that
164
+ enforces something, and a majority that want everything. Any flag can be a predicate
165
+ instead of a boolean, asked once per table with the table's key in the Drizzle schema:
166
+
167
+ ```Typescript
168
+ const { schema } = buildSchema(db, {
169
+ features: {
170
+ delete: (table) => table !== 'auditLog',
171
+ update: (table) => !ownedByCustomResolver.has(table),
172
+ upsert: (table) => table === 'settings',
173
+ },
174
+ })
175
+ ```
176
+
177
+ - The table is still fully readable — this decides which operations it offers, not whether
178
+ it is in the schema at all. For that, use `exclude` below
179
+ - `nestedWrites` is the one flag that stays build-wide: its plans are computed once over
180
+ the whole relation graph, and a nested write reaches a second table by definition
181
+ - `Mutation` is omitted when *no* table generates a mutation, and kept when one still does
182
+ - Some operations are built out of others, and the generator knows which. A table that
183
+ asks for `upsert` while its `insert` or `update` is off — an upsert is a second write
184
+ path past the operation you turned off — warns on the console at build time, naming the
185
+ tables, rather than leaving that to be discovered later. So does asking for `updateMany`
186
+ without `update`, `groupBy` without `aggregates`, `countMutations` with neither plural
187
+ write, or `nestedWrites` alongside a table whose own writes are off
188
+
127
189
  ## Excluding tables and columns
128
190
 
129
191
  `features` decides which *kinds* of operation exist. `exclude` decides which tables and
@@ -402,6 +464,124 @@ A **to-many** relation takes a `some` / `none` / `every` wrapper
402
464
  `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
403
465
  to-many relation
404
466
 
467
+ ## Matching lists of values
468
+
469
+ `inArray` / `notInArray` take a list of candidate values and compile to SQL `IN` / `NOT IN`.
470
+ Two things about them are worth stating explicitly.
471
+
472
+ **An empty list is answered, not rejected.** A caller building a filter from a variable often
473
+ ends up with an empty list, and that list has a well-defined meaning: nothing is `IN ()` and
474
+ everything is `NOT IN ()`.
475
+
476
+ ```graphql
477
+ {
478
+ # no rows
479
+ posts(where: { id: { inArray: [] } }) {
480
+ id
481
+ }
482
+
483
+ # every row
484
+ posts(where: { id: { notInArray: [] } }) {
485
+ id
486
+ }
487
+ }
488
+ ```
489
+
490
+ The same rule applies to the Postgres array-column operators: `hasSome: []` matches nothing
491
+ (overlapping with no elements is never true) and `hasEvery: []` matches everything (every
492
+ array contains all zero of them).
493
+
494
+ **The list's own order can be the sort order.** SQL returns `IN` matches in whatever order it
495
+ likes, so a caller that fetches rows by a ranked list of ids normally has to re-sort them
496
+ client-side. Setting `matchFilterOrder` on an `orderBy` entry sorts by the row's position in
497
+ that column's `inArray` list instead of by the column's value:
498
+
499
+ ```graphql
500
+ {
501
+ posts(
502
+ where: { id: { inArray: [7, 3, 12] } }
503
+ orderBy: { id: { direction: asc, priority: 1, matchFilterOrder: true } }
504
+ ) {
505
+ id # → 7, 3, 12
506
+ }
507
+ }
508
+ ```
509
+
510
+ - `direction: asc` keeps the list's order; `desc` reverses it
511
+ - It needs an `inArray` filter on the same column, at the top level of `where` — an
512
+ `inArray` buried inside an `OR` branch raises an error rather than being guessed at
513
+ - It interleaves with the other `orderBy` entries by `priority` like any ordering key, and
514
+ works on relation fields as well as root lists
515
+ - It compiles to a `CASE` ladder over the list values, so it behaves identically on all
516
+ three dialects and every value stays a bound parameter
517
+ - It cannot be combined with `after` or `distinct`: a row's position in the request's own
518
+ filter list is not a value of the row, so it cannot be encoded into a cursor or rebuilt
519
+ against a subquery. Selecting `cursor` under it yields `null` rather than an error
520
+
521
+ ## Filtering inside JSON documents
522
+
523
+ `json` / `jsonb` columns get a `path` filter that compares the value at one path inside the
524
+ document, alongside the whole-document `eq` / `ne` / `contains` operators:
525
+
526
+ ```graphql
527
+ {
528
+ users(where: { meta: { path: { path: ["profile", "level"], gt: 2 } } }) {
529
+ id
530
+ }
531
+ }
532
+ ```
533
+
534
+ `path` walks the document key by key from the root; an all-digits key indexes an array
535
+ (`["tags", "0"]`). Several predicates may be given at once and are `AND`ed:
536
+
537
+ ```graphql
538
+ {
539
+ users(
540
+ where: {
541
+ meta: {
542
+ path: [
543
+ { path: ["profile", "role"], eq: "admin" }
544
+ { path: ["profile", "level"], gte: 3 }
545
+ ]
546
+ }
547
+ }
548
+ ) {
549
+ id
550
+ }
551
+ }
552
+ ```
553
+
554
+ Available operators are `eq` / `ne` / `lt` / `lte` / `gt` / `gte`, the safe string matchers
555
+ `startsWith` / `endsWith` / `contains` (plus their case-insensitive `i`-prefixed forms), and
556
+ `isNull` / `isNotNull`. A missing key and a JSON `null` both read as null.
557
+
558
+ **How the value is compared** is decided by the operand: a GraphQL number compares
559
+ numerically, a boolean as a boolean, and anything else as text. Pass `as: TEXT | NUMBER |
560
+ BOOLEAN` when the operand's type doesn't match the document's — comparing a numeric field
561
+ against a `String` variable, say:
562
+
563
+ ```graphql
564
+ {
565
+ users(where: { meta: { path: { path: ["profile", "level"], as: NUMBER, eq: "3" } } }) {
566
+ id
567
+ }
568
+ }
569
+ ```
570
+
571
+ A value of the wrong shape never matches rather than failing the query: `gt: 0` against a
572
+ document whose path holds `"admin"` returns no rows on every dialect. That takes a guard on
573
+ each one — Postgres errors outright on a bad `::numeric`, MySQL quietly casts a non-numeric
574
+ string to `0`, and SQLite's cross-type ordering would otherwise sort every string above every
575
+ number.
576
+
577
+ Note that `contains` means different things at the two levels, because a path names a scalar:
578
+ on the column it is structural JSON containment (Postgres `@>` / MySQL `JSON_CONTAINS`),
579
+ inside a `path` filter it is substring matching on the extracted value.
580
+
581
+ Path filters combine with `AND` / `OR` / `NOT` like any other operator, and are accepted
582
+ everywhere a filter is — list, single and aggregate queries, `update` / `delete`, and the
583
+ `where` argument on a relation field.
584
+
405
585
  ## Aggregate queries
406
586
 
407
587
  Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
@@ -642,8 +822,8 @@ field, and passing it back as `after` resumes strictly after that row.
642
822
  - Dates and bigints round-trip losslessly through the cursor
643
823
  - A cursor is only valid under the ordering it was issued for — reusing it with a
644
824
  different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
645
- - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
646
- cursor pagination (its `cursor` field resolves to `null`)
825
+ - `after` cannot be combined with `distinct` or with `matchFilterOrder`, and a table with
826
+ no primary key cannot use cursor pagination (its `cursor` field resolves to `null`)
647
827
  - If a table has a real column named `cursor`, the column wins and the meta field is
648
828
  skipped
649
829
 
package/dist/README.md CHANGED
@@ -85,6 +85,39 @@ Automatically create GraphQL schema or customizable schema config fields from Dr
85
85
  })
86
86
  ```
87
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
+
88
121
  ## Choosing what gets generated
89
122
 
90
123
  Every generated operation is on by default. `features` turns individual ones off, which
@@ -124,6 +157,35 @@ const { schema } = buildSchema(db, {
124
157
  `mutations: false`
125
158
  - List and single queries are always generated, so `Query` is never empty
126
159
 
160
+ ### Per-table operations
161
+
162
+ The choice is rarely build-wide: a schema usually has a handful of tables that must not be
163
+ deleted through the API, a handful whose writes belong to a hand-written resolver that
164
+ enforces something, and a majority that want everything. Any flag can be a predicate
165
+ instead of a boolean, asked once per table with the table's key in the Drizzle schema:
166
+
167
+ ```Typescript
168
+ const { schema } = buildSchema(db, {
169
+ features: {
170
+ delete: (table) => table !== 'auditLog',
171
+ update: (table) => !ownedByCustomResolver.has(table),
172
+ upsert: (table) => table === 'settings',
173
+ },
174
+ })
175
+ ```
176
+
177
+ - The table is still fully readable — this decides which operations it offers, not whether
178
+ it is in the schema at all. For that, use `exclude` below
179
+ - `nestedWrites` is the one flag that stays build-wide: its plans are computed once over
180
+ the whole relation graph, and a nested write reaches a second table by definition
181
+ - `Mutation` is omitted when *no* table generates a mutation, and kept when one still does
182
+ - Some operations are built out of others, and the generator knows which. A table that
183
+ asks for `upsert` while its `insert` or `update` is off — an upsert is a second write
184
+ path past the operation you turned off — warns on the console at build time, naming the
185
+ tables, rather than leaving that to be discovered later. So does asking for `updateMany`
186
+ without `update`, `groupBy` without `aggregates`, `countMutations` with neither plural
187
+ write, or `nestedWrites` alongside a table whose own writes are off
188
+
127
189
  ## Excluding tables and columns
128
190
 
129
191
  `features` decides which *kinds* of operation exist. `exclude` decides which tables and
@@ -402,6 +464,124 @@ A **to-many** relation takes a `some` / `none` / `every` wrapper
402
464
  `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
403
465
  to-many relation
404
466
 
467
+ ## Matching lists of values
468
+
469
+ `inArray` / `notInArray` take a list of candidate values and compile to SQL `IN` / `NOT IN`.
470
+ Two things about them are worth stating explicitly.
471
+
472
+ **An empty list is answered, not rejected.** A caller building a filter from a variable often
473
+ ends up with an empty list, and that list has a well-defined meaning: nothing is `IN ()` and
474
+ everything is `NOT IN ()`.
475
+
476
+ ```graphql
477
+ {
478
+ # no rows
479
+ posts(where: { id: { inArray: [] } }) {
480
+ id
481
+ }
482
+
483
+ # every row
484
+ posts(where: { id: { notInArray: [] } }) {
485
+ id
486
+ }
487
+ }
488
+ ```
489
+
490
+ The same rule applies to the Postgres array-column operators: `hasSome: []` matches nothing
491
+ (overlapping with no elements is never true) and `hasEvery: []` matches everything (every
492
+ array contains all zero of them).
493
+
494
+ **The list's own order can be the sort order.** SQL returns `IN` matches in whatever order it
495
+ likes, so a caller that fetches rows by a ranked list of ids normally has to re-sort them
496
+ client-side. Setting `matchFilterOrder` on an `orderBy` entry sorts by the row's position in
497
+ that column's `inArray` list instead of by the column's value:
498
+
499
+ ```graphql
500
+ {
501
+ posts(
502
+ where: { id: { inArray: [7, 3, 12] } }
503
+ orderBy: { id: { direction: asc, priority: 1, matchFilterOrder: true } }
504
+ ) {
505
+ id # → 7, 3, 12
506
+ }
507
+ }
508
+ ```
509
+
510
+ - `direction: asc` keeps the list's order; `desc` reverses it
511
+ - It needs an `inArray` filter on the same column, at the top level of `where` — an
512
+ `inArray` buried inside an `OR` branch raises an error rather than being guessed at
513
+ - It interleaves with the other `orderBy` entries by `priority` like any ordering key, and
514
+ works on relation fields as well as root lists
515
+ - It compiles to a `CASE` ladder over the list values, so it behaves identically on all
516
+ three dialects and every value stays a bound parameter
517
+ - It cannot be combined with `after` or `distinct`: a row's position in the request's own
518
+ filter list is not a value of the row, so it cannot be encoded into a cursor or rebuilt
519
+ against a subquery. Selecting `cursor` under it yields `null` rather than an error
520
+
521
+ ## Filtering inside JSON documents
522
+
523
+ `json` / `jsonb` columns get a `path` filter that compares the value at one path inside the
524
+ document, alongside the whole-document `eq` / `ne` / `contains` operators:
525
+
526
+ ```graphql
527
+ {
528
+ users(where: { meta: { path: { path: ["profile", "level"], gt: 2 } } }) {
529
+ id
530
+ }
531
+ }
532
+ ```
533
+
534
+ `path` walks the document key by key from the root; an all-digits key indexes an array
535
+ (`["tags", "0"]`). Several predicates may be given at once and are `AND`ed:
536
+
537
+ ```graphql
538
+ {
539
+ users(
540
+ where: {
541
+ meta: {
542
+ path: [
543
+ { path: ["profile", "role"], eq: "admin" }
544
+ { path: ["profile", "level"], gte: 3 }
545
+ ]
546
+ }
547
+ }
548
+ ) {
549
+ id
550
+ }
551
+ }
552
+ ```
553
+
554
+ Available operators are `eq` / `ne` / `lt` / `lte` / `gt` / `gte`, the safe string matchers
555
+ `startsWith` / `endsWith` / `contains` (plus their case-insensitive `i`-prefixed forms), and
556
+ `isNull` / `isNotNull`. A missing key and a JSON `null` both read as null.
557
+
558
+ **How the value is compared** is decided by the operand: a GraphQL number compares
559
+ numerically, a boolean as a boolean, and anything else as text. Pass `as: TEXT | NUMBER |
560
+ BOOLEAN` when the operand's type doesn't match the document's — comparing a numeric field
561
+ against a `String` variable, say:
562
+
563
+ ```graphql
564
+ {
565
+ users(where: { meta: { path: { path: ["profile", "level"], as: NUMBER, eq: "3" } } }) {
566
+ id
567
+ }
568
+ }
569
+ ```
570
+
571
+ A value of the wrong shape never matches rather than failing the query: `gt: 0` against a
572
+ document whose path holds `"admin"` returns no rows on every dialect. That takes a guard on
573
+ each one — Postgres errors outright on a bad `::numeric`, MySQL quietly casts a non-numeric
574
+ string to `0`, and SQLite's cross-type ordering would otherwise sort every string above every
575
+ number.
576
+
577
+ Note that `contains` means different things at the two levels, because a path names a scalar:
578
+ on the column it is structural JSON containment (Postgres `@>` / MySQL `JSON_CONTAINS`),
579
+ inside a `path` filter it is substring matching on the extracted value.
580
+
581
+ Path filters combine with `AND` / `OR` / `NOT` like any other operator, and are accepted
582
+ everywhere a filter is — list, single and aggregate queries, `update` / `delete`, and the
583
+ `where` argument on a relation field.
584
+
405
585
  ## Aggregate queries
406
586
 
407
587
  Every table also gets an aggregate query field — `<tableName>Aggregate` (e.g. `usersAggregate`),
@@ -642,8 +822,8 @@ field, and passing it back as `after` resumes strictly after that row.
642
822
  - Dates and bigints round-trip losslessly through the cursor
643
823
  - A cursor is only valid under the ordering it was issued for — reusing it with a
644
824
  different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
645
- - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
646
- cursor pagination (its `cursor` field resolves to `null`)
825
+ - `after` cannot be combined with `distinct` or with `matchFilterOrder`, and a table with
826
+ no primary key cannot use cursor pagination (its `cursor` field resolves to `null`)
647
827
  - If a table has a real column named `cursor`, the column wins and the meta field is
648
828
  skipped
649
829