@vantreeseba/drizzle-graphql 7.9.1 → 8.1.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/dist/README.md CHANGED
@@ -141,14 +141,16 @@ const { schema } = buildSchema(db, {
141
141
  fieldUpdateOperations: true, // increment/push operations on the update input (off by default)
142
142
  countMutations: true, // update<Table>Count / delete<Table>Count mutations (off by default)
143
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)
144
145
  },
145
146
  })
146
147
  ```
147
148
 
148
149
  - Any flag left out keeps its default of `true`, so `{ features: { delete: false } }`
149
150
  changes nothing else
150
- - `upsert`, `nestedWrites`, `fieldUpdateOperations` and `countMutations` are the
151
- exceptions: all four default to `false`, so they only exist if you ask for them
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
152
154
  - `groupBy` needs `aggregates`: it reuses those output types, so turning `aggregates` off
153
155
  turns the group-by queries off with it
154
156
  - Turning off `insert` or `update` also drops the input type that only that mutation
@@ -362,6 +364,56 @@ const { schema } = buildSchema(db, {
362
364
 
363
365
  Return `undefined` for any table that should keep its default naming.
364
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
+
365
417
  ## Soft delete
366
418
 
367
419
  `softDelete` turns a table's `delete` mutation into an UPDATE that marks the row, and hides
@@ -388,7 +440,7 @@ What a declared table gets:
388
440
 
389
441
  | Path | Effect |
390
442
  | --- | --- |
391
- | `deletePosts`, `deletePostsSingle` | UPDATE that writes the marker; returns the rows as they now stand |
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 |
392
444
  | `restorePosts`, `restorePostsSingle` | the inverse — matches **only** marked rows and clears the marker |
393
445
  | `posts`, `postsSingle`, cursor pages | marked rows are not returned |
394
446
  | `postsAggregate`, `postsGroupBy` | counted over unmarked rows only |
@@ -415,14 +467,94 @@ Defaults come from the column's type: `new Date()` for a timestamp, an ISO strin
415
467
  the predicate has to compare against a constant: any NOT NULL column other than a boolean must
416
468
  give `deletedValue` (a constant, not a function) and `restoredValue`, or the build throws.
417
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
+
418
548
  Three things it deliberately does not do:
419
549
 
420
550
  - **It does not cascade.** Marking a post does not mark its comments. A comment whose post is
421
- marked is still returned by `comments`; its `post` field resolves to `null`. Declare the
422
- child table too if that is not what you want.
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.
423
554
  - **It does not free the row's unique keys.** A marked row still occupies its primary key and
424
555
  every unique index, so inserting a new row with the same natural key fails the constraint.
425
- A partial unique index (`WHERE deleted_at IS NULL`) is the usual answer.
556
+ A partial unique index (`WHERE deleted_at IS NULL`) is the usual answer; `hardDelete`
557
+ (above) is the other one.
426
558
  - **It does not refuse.** Like `scope`, an unreachable row answers the same as a row that does
427
559
  not exist.
428
560
 
@@ -629,6 +761,75 @@ A **to-many** relation takes a `some` / `none` / `every` wrapper
629
761
  `users(where: { roles: { some: { name: { eq: "admin" } } } })` works the same as a direct
630
762
  to-many relation
631
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
+
632
833
  ## Matching lists of values
633
834
 
634
835
  `inArray` / `notInArray` take a list of candidate values and compile to SQL `IN` / `NOT IN`.
@@ -1118,6 +1319,30 @@ mutation {
1118
1319
  - `values` is the same `Create<Type>Input` the insert mutations take, so turning `insert`
1119
1320
  off does not remove it
1120
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
+
1121
1346
  Dialect differences:
1122
1347
 
1123
1348
  - **PostgreSQL** and **SQLite** — the full surface above. A table with no primary key and no
@@ -1180,6 +1405,11 @@ mutation {
1180
1405
  `author: { create: … }` available, `authorId` is one of two ways to supply it
1181
1406
  - Nesting is one level deep: the row a nested `create` inserts takes columns only, and the
1182
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
1183
1413
  - Many-to-many (`.through()`) relations are written through their junction table — see
1184
1414
  below
1185
1415
  - The whole tree runs in one transaction — a savepoint when the request already carries one
@@ -1570,6 +1800,51 @@ The hook covers root queries and mutations, relation and aggregate fields, and t
1570
1800
  standalone `entities.fieldResolvers`. The default is exported as `defaultErrorMapper` if you
1571
1801
  want to fall back to it explicitly.
1572
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
+
1573
1848
  ## Transactions
1574
1849
 
1575
1850
  Each resolver runs its statements on the database the schema was built from, so by default a
@@ -1923,6 +2198,7 @@ that explains it, where there is one.
1923
2198
  | `exclude` | none | [tables and columns left out](#excluding-tables-and-columns) of the generated schema entirely |
1924
2199
  | `relationsDepthLimit` | unlimited | how deep relation fields are generated; `0` gives a flat, columns-only schema |
1925
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 |
1926
2202
  | `scalars` / `mapColumnType` | built-in detection | [override a column's GraphQL type](#overriding-a-columns-scalar), by name or by rule |
1927
2203
  | `enumNameMapper` | shared `<EnumName>Enum` per database enum | [names the enum a column maps to](#enum-types) |
1928
2204
  | `describeColumn` / `describeTable` / `describeRelation` | none | [GraphQL descriptions](#documenting-the-schema) |