@vantreeseba/drizzle-graphql 14.0.0 → 15.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/README.md CHANGED
@@ -2,17 +2,19 @@
2
2
 
3
3
  Automatically create GraphQL schema or customizable schema config fields from Drizzle ORM schema
4
4
 
5
+ Site: [cubicecho.github.io/drizzle-graphql](https://cubicecho.github.io/drizzle-graphql/)
6
+
5
7
  ## Usage
6
8
 
7
- - Pass your drizzle database instance and schema into builder to generate `{ schema, entities }` object
8
- - Use `schema` if pre-built schema already satisfies all your neeeds. It's compatible witn any server that consumes `GraphQLSchema` class instance
9
+ - Pass your drizzle database instance into the builder to generate a `{ schema, entities }` object. The tables are read from the `relations` you gave the drizzle constructor (`drizzle({ client, relations })`), so a table missing from your relations config is not part of the generated schema
10
+ - Use `schema` if pre-built schema already satisfies all your needs. It's compatible with any server that consumes `GraphQLSchema` class instance
9
11
 
10
12
  Example: hosting schema using [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
11
13
 
12
14
  ```Typescript
13
15
  import { createServer } from 'node:http'
14
16
  import { createYoga } from 'graphql-yoga'
15
- import { buildSchema } from 'drizzle-graphql'
17
+ import { buildSchema } from '@vantreeseba/drizzle-graphql'
16
18
 
17
19
  // db - your drizzle instance
18
20
  import { db } from './database'
@@ -20,6 +22,7 @@ Automatically create GraphQL schema or customizable schema config fields from Dr
20
22
  const { schema } = buildSchema(db)
21
23
 
22
24
  const yoga = createYoga({ schema })
25
+ const server = createServer(yoga)
23
26
 
24
27
  server.listen(4000, () => {
25
28
  console.info('Server is running on http://localhost:4000/graphql')
@@ -32,10 +35,11 @@ Automatically create GraphQL schema or customizable schema config fields from Dr
32
35
  import { createServer } from 'node:http'
33
36
  import { GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema } from 'graphql'
34
37
  import { createYoga } from 'graphql-yoga'
35
- import { buildSchema } from 'drizzle-graphql'
38
+ import { buildSchema } from '@vantreeseba/drizzle-graphql'
36
39
 
37
40
  // Schema contains 'Users' and 'Customers' tables
38
41
  import { db } from './database'
42
+ import { users } from './schema'
39
43
 
40
44
  const { entities } = buildSchema(db)
41
45
 
@@ -60,7 +64,7 @@ Automatically create GraphQL schema or customizable schema config fields from Dr
60
64
  },
61
65
  resolve: async (source, args, context, info) => {
62
66
  // Your custom logic goes here...
63
- const result = await db.select(schema.Users).where()...
67
+ const result = await db.select().from(users).where(/* … */)
64
68
 
65
69
  return result
66
70
  }
@@ -118,7 +122,7 @@ to leave that table with the default naming. The preset is exported as `singular
118
122
  so it can be wrapped:
119
123
 
120
124
  ```Typescript
121
- import { buildSchema, singularizeMapper } from 'drizzle-graphql'
125
+ import { buildSchema, singularizeMapper } from '@vantreeseba/drizzle-graphql'
122
126
 
123
127
  const { schema } = buildSchema(db, {
124
128
  typeNameMapper: (table) => (table === 'auditLog' ? undefined : singularizeMapper(table)),
@@ -165,8 +169,9 @@ const { schema } = buildSchema(db, {
165
169
  turns the group-by queries off with it
166
170
  - Turning off `insert` or `update` also drops the input type that only that mutation
167
171
  used (`Create<Type>Input` / `Update<Type>Input`)
168
- - Turning off all three mutation features omits the `Mutation` type entirely, the same as
169
- `mutations: false`
172
+ - Turning off every mutation feature omits the `Mutation` type entirely, the same as
173
+ `mutations: false` — unless a table has `softDelete`, whose `restore` mutations are
174
+ generated regardless
170
175
  - List and single queries are always generated, so `Query` is never empty
171
176
 
172
177
  ### Per-table operations
@@ -341,9 +346,7 @@ Together the two close the loop: `contextValues` decides which tenant a new row
341
346
 
342
347
  Names that match nothing throw at build time, the same as `exclude` — a renamed column fails the
343
348
  build instead of quietly ceasing to be scoped.
344
- > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
345
- > or an equivalent layer above the schema, which has the request context this library
346
- > does not.
349
+
347
350
  ## Naming
348
351
 
349
352
  Type and field names are derived from the Drizzle schema keys. Three options change them.
@@ -460,7 +463,7 @@ Keyed by the **Drizzle schema key**, like `scope` and `exclude`. A function form
460
463
  accepted, for a convention applied across the schema:
461
464
 
462
465
  ```Typescript
463
- softDelete: (table, tableName) => ('deletedAt' in getTableColumns(table) ? 'deletedAt' : undefined)
466
+ softDelete: (table, tableName) => ('deletedAt' in getColumns(table) ? 'deletedAt' : undefined)
464
467
  ```
465
468
 
466
469
  What a declared table gets:
@@ -606,8 +609,10 @@ generated SDL says what a field actually holds instead of falling back to `Strin
606
609
  | `json` / `jsonb` (and `mode: 'json'`) | `JSON` | the parsed value |
607
610
  | `bigint` (and `mode: 'bigint'`) | `BigInt` | a decimal string |
608
611
  | `uuid` | `UUID` | a validated UUID string |
609
- | `timestamp` / `datetime` | `DateTime` | an ISO-8601 string |
610
- | `date` | `Date` | a `YYYY-MM-DD` string |
612
+ | `timestamp` / `datetime` (either `mode`) | `DateTime` | an ISO-8601 string |
613
+ | SQLite `integer({ mode: 'timestamp' })` / `'timestamp_ms'` | `DateTime` | an ISO-8601 string |
614
+ | `date` (either `mode`) | `Date` | a `YYYY-MM-DD` string |
615
+ | `numeric` / `decimal` | `Decimal` | a decimal string |
611
616
 
612
617
  ```graphql
613
618
  mutation {
@@ -625,15 +630,100 @@ mutation {
625
630
  `Number.MAX_SAFE_INTEGER` survive the round-trip. Integer literals are accepted on input;
626
631
  floats and non-numeric strings are rejected
627
632
  - **`UUID`** validates the format on the way in, including inside `where` filters
633
+ - **`Decimal`** is a decimal string in both directions, so the precision the column declares
634
+ is not lost to a JS float. Non-numeric and non-finite input is rejected
635
+ - **`Date`** is a calendar day — no time, no offset — and is the same type in every position
636
+ a `date` column appears in: the object field, the create and update inputs, the
637
+ `DateFilter` operands, aggregate `min` / `max` and group keys. It takes and returns an
638
+ RFC 3339 `full-date` (`2026-09-15`) whichever `mode` the column is declared with; a
639
+ `mode: 'date'` column is read back from its JS `Date` as the UTC day, so no server timezone
640
+ shifts it. Input must name a real day: `2026-02-30`, `2026-13-45` and datetime strings like
641
+ `2026-09-15T10:00:00Z` are rejected. With `graphql-codegen`, map it as
642
+ `scalars: { Date: 'string' }`
643
+ - **`DateTime`** is an instant, and is likewise the same type in every position a Postgres
644
+ `timestamp`, MySQL `timestamp` / `datetime` or SQLite integer timestamp column appears in, with `DateTimeFilter` in
645
+ filters, whichever `mode` the column is declared with. It returns an ISO-8601 string in UTC.
646
+ MySQL stores these columns without an offset; the value is written and read as UTC
647
+ (`2026-09-15T10:00:00Z` is stored as `2026-09-15 10:00:00`) whatever the Node process's
648
+ timezone, the way drizzle itself treats them. Postgres `timestamp` without time zone is
649
+ treated the same way, in both modes; `timestamptz` stores the instant, and its text in
650
+ `mode: 'string'` (`2026-09-15 04:00:00-06`) is returned as the same instant in UTC. The
651
+ scalar carries milliseconds, so a `timestamp(6)` value's microseconds are truncated on read
652
+ and on write. A SQLite `integer({ mode: 'timestamp' })` or `integer({ mode: 'timestamp_ms' })`
653
+ column is `DateTime` too; it stores the instant as epoch seconds or milliseconds. Seconds
654
+ mode cannot hold a fraction, so it is floored (never rounded) on write —
655
+ `2026-09-15T10:00:00.999Z` is stored as `1789466400` and reads back as
656
+ `2026-09-15T10:00:00.000Z` — and filter operands on such a column are floored the same way,
657
+ so `eq` with the value you wrote finds the row you wrote it to
628
658
 
629
659
  The scalars are exported if you need them in a hand-written schema:
630
660
 
631
661
  ```Typescript
632
- import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQLUUID } from 'drizzle-graphql'
662
+ import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLDecimalString, GraphQLJSON, GraphQLUUID } from '@vantreeseba/drizzle-graphql'
633
663
  ```
634
664
 
635
665
  `GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
636
- called in SDL, to avoid clashing with the language's own `BigInt`.
666
+ called in SDL, to avoid clashing with the language's own `BigInt`. `GraphQLDate` is this
667
+ library's own `Date` scalar, not `graphql-scalars`' (whose `parseValue` returns a JS `Date`).
668
+
669
+ > **Upgrading — `date` columns.** A `date` column used to be typed differently depending on
670
+ > where it appeared and which `mode` it was declared with. Both modes are now `Date` everywhere:
671
+ >
672
+ > | Drizzle column | position | before | now |
673
+ > | --- | --- | --- | --- |
674
+ > | `date(…, { mode: 'string' })` | output | `Date` | `Date` |
675
+ > | | create / update input | `String` | `Date` |
676
+ > | | filter | `StringFilter` | `DateFilter` |
677
+ > | `date(…)` / `{ mode: 'date' }` (Postgres) | output, input, min/max, group keys | `DateTime` | `Date` |
678
+ > | | filter | `DateTimeFilter` | `DateFilter` |
679
+ > | `date(…, { mode: 'date' })` (MySQL) | output, input, min/max, group keys | `JSON` | `Date` |
680
+ > | | filter | `JSONFilter` | `DateFilter` |
681
+ > | `date(…, { mode: 'string' })` (MySQL) | output, input, min/max, group keys | `String` | `Date` |
682
+ > | | filter | `StringFilter` | `DateFilter` |
683
+ >
684
+ > What to change:
685
+ >
686
+ > - Variables typed `String` or `DateTime` for a date field become `Date`; filters on it are
687
+ > `DateFilter`, which has the `DateTimeFilter` operators (`eq` / `ne` / `lt` / `lte` / `gt` /
688
+ > `gte` / `inArray` / `notInArray` / `isNull` / `isNotNull`) and none of the string ones
689
+ > - Send `YYYY-MM-DD`. A datetime string such as `2026-09-15T00:00:00.000Z` used to be
690
+ > truncated to its date and is now rejected
691
+ > - A `mode: 'date'` column read `2026-09-15T00:00:00.000Z` and now reads `2026-09-15`
692
+ > - In `graphql-codegen`, map `scalars: { Date: 'string' }` — the value is a string in both
693
+ > directions
694
+ > - If you passed `graphql-scalars`' `GraphQLDate` as a column override, pass this library's
695
+ > exported `GraphQLDate` instead. They share the name `Date`, so a schema holding both
696
+ > fails to build with graphql's "multiple types named Date" error
697
+
698
+ > **Upgrading — MySQL `timestamp` / `datetime` columns.** These used to fall through to
699
+ > `JSON` in `mode: 'date'` and to `String` in `mode: 'string'`. All four are now `DateTime`, as
700
+ > Postgres `timestamp` already was:
701
+ >
702
+ > | Drizzle column (MySQL) | position | before | now |
703
+ > | --- | --- | --- | --- |
704
+ > | `timestamp(…)` / `datetime(…)` (`mode: 'date'`) | output, input, min/max, group keys | `JSON` | `DateTime` |
705
+ > | | filter | `JSONFilter` | `DateTimeFilter` |
706
+ > | `timestamp(…)` / `datetime(…)` with `{ mode: 'string' }` | output, input, min/max, group keys | `String` | `DateTime` |
707
+ > | | filter | `StringFilter` | `DateTimeFilter` |
708
+ >
709
+ > Variables for these fields become `DateTime` and their filters `DateTimeFilter`, which has no
710
+ > `JSONFilter` / `StringFilter` operators such as `contains` or `like`. Input must be an ISO-8601
711
+ > date-time (`2026-09-15T10:00:00Z`, or with an offset). A `mode: 'string'` column used to read
712
+ > MySQL's own `2026-09-15 10:00:00` and now reads `2026-09-15T10:00:00.000Z`. In
713
+ > `graphql-codegen`, map `DateTime` the way you already do for Postgres timestamps.
714
+
715
+ > **Upgrading — SQLite integer timestamps.** `integer({ mode: 'timestamp' })` and
716
+ > `integer({ mode: 'timestamp_ms' })` used to fall through to `JSON`. Both are now `DateTime`:
717
+ >
718
+ > | Drizzle column (SQLite) | position | before | now |
719
+ > | --- | --- | --- | --- |
720
+ > | `integer(…, { mode: 'timestamp' })` / `{ mode: 'timestamp_ms' }` | output, input, min/max, group keys | `JSON` | `DateTime` |
721
+ > | | filter | `JSONFilter` | `DateTimeFilter` |
722
+ >
723
+ > Variables for these fields become `DateTime`, and their filters `DateTimeFilter`, which swaps
724
+ > the JSON `path` operator for `lt` / `lte` / `gt` / `gte` / `inArray` / `notInArray`. Input must
725
+ > be an ISO-8601 date-time; a JSON value such as a bare number is rejected. Reads are unchanged:
726
+ > they were already ISO-8601 strings in UTC. The stored integers are unchanged too.
637
727
 
638
728
  ### Overriding a column's scalar
639
729
 
@@ -694,7 +784,7 @@ only has to handle the cases it cares about:
694
784
  ```Typescript
695
785
  const { schema } = buildSchema(db, {
696
786
  // `schema` is the Postgres schema the enum was declared in, if any; `values` is its
697
- // value list. Everything but `enumName` is always present.
787
+ // value list. `enumName` and `schema` are undefined for an inline value list.
698
788
  enumNameMapper: ({ enumName, schema }) =>
699
789
  schema === 'reporting' ? `Reporting${enumName}Enum` : undefined,
700
790
  })
@@ -774,9 +864,40 @@ const { schema } = buildSchema(db, {
774
864
 
775
865
  - `describeColumn` reaches every place the column appears: the object type, the create and
776
866
  update inputs, the filter input's field, `Min`/`Max` aggregates and `GroupKeys`
777
- - The hook receives the drizzle column itself, so a project that keeps its documentation in
778
- the schema (a `.$type()` brand, a custom column property, a comment table) can read it
779
- straight off
867
+ - `describeColumn` receives the drizzle column itself and `describeTable` the drizzle table
868
+ itself, so a project that keeps its documentation in the schema (a `WeakMap` keyed by
869
+ column/table, a custom property, a comment table) can read it straight off:
870
+
871
+ ```Typescript
872
+ import type { Table } from 'drizzle-orm';
873
+
874
+ const docs = new WeakMap<object, string>();
875
+
876
+ // Register docs on the *built* table and its columns. `text('email')` is only a builder:
877
+ // pgTable creates a new column object from it, so keying on the builder never matches.
878
+ const described = <T extends Table>(
879
+ table: T,
880
+ description: string,
881
+ columns: Partial<Record<keyof T['_']['columns'], string>> = {},
882
+ ): T => {
883
+ docs.set(table, description);
884
+ for (const [key, columnText] of Object.entries(columns)) {
885
+ docs.set((table as any)[key], columnText as string);
886
+ }
887
+ return table;
888
+ };
889
+
890
+ export const users = described(
891
+ pgTable('users', { id: integer('id').primaryKey(), email: text('email').notNull() }),
892
+ 'A person who can sign in',
893
+ { email: 'Primary login address' },
894
+ );
895
+
896
+ buildSchema(db, {
897
+ describeColumn: (column) => docs.get(column),
898
+ describeTable: (_tableName, table) => docs.get(table),
899
+ });
900
+ ```
780
901
  - `deprecateColumn` marks output fields and *optional* input fields. A required input field
781
902
  is skipped: GraphQL forbids deprecating one, and emitting it would make the schema invalid
782
903
  - Filter and `orderBy` fields are never deprecated — filtering on a deprecated column is how
@@ -911,7 +1032,7 @@ no others. The set follows the column's data type, never its name:
911
1032
  | column type | `eq` `ne` `inArray` `notInArray` `isNull` `isNotNull` | `lt` `lte` `gt` `gte` | pattern operators + `insensitive` |
912
1033
  | --- | --- | --- | --- |
913
1034
  | text / varchar | yes | yes | yes |
914
- | timestamp, date | yes | yes | — |
1035
+ | timestamp (`DateTimeFilter`), date (`DateFilter`) | yes | yes | — |
915
1036
  | int, float, bigint, decimal | yes | yes | — |
916
1037
  | uuid | yes | yes | — |
917
1038
  | boolean | yes | — | — |
@@ -1003,7 +1124,8 @@ that column's `inArray` list instead of by the column's value:
1003
1124
  ## Filtering inside JSON documents
1004
1125
 
1005
1126
  `json` / `jsonb` columns get a `path` filter that compares the value at one path inside the
1006
- document, alongside the whole-document `eq` / `ne` / `contains` operators:
1127
+ document, alongside the whole-document `eq` / `ne` operators and, on PostgreSQL and MySQL,
1128
+ structural `contains` (SQLite stores JSON as text and has no containment operator):
1007
1129
 
1008
1130
  ```graphql
1009
1131
  {
@@ -1169,9 +1291,10 @@ columns you group by.
1169
1291
  }
1170
1292
  ```
1171
1293
 
1172
- - `having: { count: … }` filters on the row count; `avg` / `sum` / `min` / `max` /
1173
- `countNonNull` / `countDistinct` take one filter per column, over the same columns the
1174
- matching aggregate type exposes
1294
+ - `having: { count: … }` filters on the row count; `avg` / `sum` / `min` / `max` take one
1295
+ filter per numeric column (the comparison is numeric, so a `max` over a text or date
1296
+ column has no `having` entry even though the aggregate type returns it),
1297
+ `countNonNull` one per column, and `countDistinct` one per column `countDistinct` exposes
1175
1298
  - Every entry in a `having` is ANDed together
1176
1299
  - A group filter does not need to be in the selection — `having: { count: { gt: 3 } }` works
1177
1300
  whether or not you asked for `count`
@@ -1210,6 +1333,8 @@ can count or summarise related rows without fetching them:
1210
1333
  - A parent with no related rows gets `count: 0` and `null` for every other aggregate
1211
1334
  - To-one relations get no aggregate field — there is nothing to aggregate over
1212
1335
  - The field is skipped if its name would collide with a column or another relation
1336
+ - Set `features: { relationAggregates: false }` (globally or per table) to leave these
1337
+ fields out
1213
1338
 
1214
1339
  All parents in a selection are aggregated with a single
1215
1340
  `SELECT <fk>, … WHERE <fk> IN (…) GROUP BY <fk>` per request, so `postsAggregate` on a list of
@@ -1233,7 +1358,8 @@ enum. Rows sharing the same combination of those columns collapse to one:
1233
1358
  ```
1234
1359
 
1235
1360
  - Which row survives each group is decided by `orderBy` — the first one wins. With no
1236
- `orderBy`, that's the lowest primary key
1361
+ `orderBy`, the table's [default ordering](#default-ordering) decides, and failing that the
1362
+ lowest primary key
1237
1363
  - `where` is applied **before** rows are collapsed; `limit` and `offset` are applied
1238
1364
  **after**, so `limit: 10` returns ten distinct rows
1239
1365
  - Several columns are treated as one combined key, not as independent ones
@@ -1252,7 +1378,7 @@ or **SQLite 3.25+**), and a table with no primary key cannot use it.
1252
1378
  SQL gives no ordering guarantee for a query that has no `ORDER BY`, so paging through an
1253
1379
  unordered result can return the same row twice or skip one entirely. To keep pages stable,
1254
1380
  a query that returns only part of a table is ordered by its primary key when the request
1255
- supplies no `orderBy`:
1381
+ supplies no `orderBy` and the table has no [default ordering](#default-ordering):
1256
1382
 
1257
1383
  - list queries with `limit` and/or `offset`
1258
1384
  - `<tableName>Single` queries, which are an implicit `limit 1`
@@ -1260,7 +1386,8 @@ supplies no `orderBy`:
1260
1386
  `orderBy` you do supply, so per-parent slices are deterministic)
1261
1387
 
1262
1388
  An explicit `orderBy` always takes precedence, composite primary keys are ordered by every
1263
- key column, and an unpaginated list query is left unordered so no sort is paid for.
1389
+ key column, and an unpaginated list query of a table with no `defaults` entry is left
1390
+ unordered so no sort is paid for.
1264
1391
 
1265
1392
  ## Default ordering
1266
1393
 
@@ -1347,14 +1474,16 @@ field, and passing it back as `after` resumes strictly after that row.
1347
1474
  window
1348
1475
  - `after` compiles to a keyset predicate built from drizzle's `and`/`or`/`gt`/`lt`
1349
1476
  comparisons (not SQL row-value syntax), so mixed-direction `orderBy` works
1350
- - `NULL`s in ordered columns follow each dialect's native `ORDER BY` placement —
1351
- PostgreSQL sorts them largest (last in `asc`), MySQL and SQLite smallest (first in
1352
- `asc`) — and the predicate matches, so rows with `NULL` in an ordered column are paged
1353
- through, not skipped
1477
+ - `NULL`s in ordered columns sort where the entry's `nulls: first | last` says or, without
1478
+ it, at each dialect's native `ORDER BY` placement — PostgreSQL sorts them largest (last
1479
+ in `asc`), MySQL and SQLite smallest (first in `asc`). The predicate matches either way,
1480
+ so rows with `NULL` in an ordered column are paged through, not skipped, and `nulls` is
1481
+ part of the ordering a cursor is tied to
1354
1482
  - Dates and bigints round-trip losslessly through the cursor
1355
1483
  - A cursor is only valid under the ordering it was issued for — reusing it with a
1356
1484
  different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
1357
- - `after` cannot be combined with `distinct` or with `matchFilterOrder`, and a table with
1485
+ - `after` cannot be combined with `distinct`, with `matchFilterOrder`, or with an `orderBy`
1486
+ that orders through a relation (`cursor` resolves to `null` there), and a table with
1358
1487
  no primary key cannot use cursor pagination (its `cursor` field resolves to `null`)
1359
1488
  - If a table has a real column named `cursor`, the column wins and the meta field is
1360
1489
  skipped
@@ -1508,8 +1637,10 @@ mutation {
1508
1637
  }
1509
1638
  ```
1510
1639
 
1511
- - `connect`, `disconnect` and `set` take the target's own `Filters` input, so they select
1512
- rows the same way a query does. A filter that matches everything is rejected rather than
1640
+ - `connect`, `disconnect` and `set` take the target's own `Filters` input — a list of them
1641
+ on a to-many relation, a single one that must match exactly one row on a to-one — so they
1642
+ select rows the same way a query does. A to-one `disconnect` is just `disconnect: true`,
1643
+ since there is only one row to detach. A filter that matches everything is rejected rather than
1513
1644
  attaching the whole table
1514
1645
  - `set` replaces the relation wholesale — everything attached is detached first, then the
1515
1646
  filters attach. It runs before `create`, so a row created in the same write survives it
@@ -1857,8 +1988,8 @@ const yoga = createYoga({ schema, plugins: armor.protect().plugins });
1857
1988
 
1858
1989
  Armor's `costLimit` is its own document-shape estimate and does not read the `complexity`
1859
1990
  extensions above; the two compose, if you want a cheap static ceiling alongside row-count
1860
- aware pricing. Neither one bounds an individual `limit` argument, so a single unbounded list
1861
- query is still a full table scan — cap `limit` yourself if that matters.
1991
+ aware pricing. Neither one bounds an individual `limit` argument — a list query with no
1992
+ `limit` is still a full table scan unless you set a [`limits`](#bounding-page-size) policy.
1862
1993
 
1863
1994
  ## Authorization
1864
1995
 
@@ -1900,21 +2031,17 @@ request context actually lives.
1900
2031
  or through your own resolver wrapper, and let the ability answer the questions.
1901
2032
 
1902
2033
  What neither can do is **narrow** a query. They permit or deny a field; they do not append
1903
- `WHERE user_id = …` to the SQL a generated resolver runs, and a permitted `users` query still
1904
- returns every tenant's rows. Until row scoping is a first-class option here, the ways to get
1905
- it are, in increasing order of effort:
1906
-
1907
- - supply the tenant predicate as an ordinary `where` argument from a trusted gateway, and
1908
- deny the raw generated fields to everyone else;
1909
- - build the schema by hand from `entities`, exposing only the generated resolvers you have
1910
- wrapped;
1911
- - put the scoped tables behind database row-level security and give the driver the
1912
- request's identity, so the narrowing happens below Drizzle entirely.
2034
+ `WHERE org_id = …` to the SQL a generated resolver runs, so a permitted `posts` query still
2035
+ returns every tenant's rows. That is what [`scope`](#row-scoping-and-multi-tenancy) is for: a
2036
+ per-table predicate built from the request context and ANDed onto every read and write of
2037
+ that table, nested relation fields included. Pair it with
2038
+ [`contextValues`](#columns-the-server-owns) so new rows are stamped with the caller's tenant.
2039
+ The usual split is shield (or CASL) to decide who may reach a field, and `scope` to decide
2040
+ which rows they see through it.
1913
2041
 
1914
2042
  None of this removes anything from introspection either — `deny` hides the data, not the
1915
- shape. If the schema must not so much as mention a table, keep that table out of the Drizzle
1916
- instance you pass to `buildSchema` (a second instance over a narrowed schema object is
1917
- cheap).
2043
+ shape. If the schema must not so much as mention a table or column, leave it out with
2044
+ [`exclude`](#excluding-tables-and-columns).
1918
2045
 
1919
2046
  Nor is it a row bound: a complexity ceiling prices the `limit` a client asks for, it does not
1920
2047
  cap it. Use [`limits`](#bounding-page-size) for that.
@@ -2055,6 +2182,9 @@ are untouched.
2055
2182
  - A document that mixes in mutation fields this build did not generate is left alone: their
2056
2183
  completion cannot be tracked, so no transaction is opened
2057
2184
  - It never nests — an executor supplied on the context wins, as below
2185
+ - A single mutation field can still run in a transaction of its own when it carries a
2186
+ nested write or its table has an `onWrite` hook — those open one regardless of this
2187
+ setting
2058
2188
 
2059
2189
  ### Supplying your own executor
2060
2190
 
@@ -2062,7 +2192,7 @@ To control the boundary yourself, open a transaction and put it on the GraphQL c
2062
2192
  the exported `drizzleExecutorKey`:
2063
2193
 
2064
2194
  ```Typescript
2065
- import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
2195
+ import { buildSchema, drizzleExecutorKey } from '@vantreeseba/drizzle-graphql'
2066
2196
 
2067
2197
  const { schema } = buildSchema(db)
2068
2198
 
@@ -2089,17 +2219,6 @@ example a pooled connection bound to a tenant, or a logging proxy around `db`. B
2089
2219
  key is created with `Symbol.for`, the ESM and CJS builds of this package agree on it when
2090
2220
  both end up loaded in one process.
2091
2221
 
2092
- Or let the library open one per request: `transactions: 'auto'` wraps a document that fires
2093
- more than one generated mutation in a single transaction, committed when the last one
2094
- completes and rolled back if any of them fails.
2095
-
2096
- ```Typescript
2097
- buildSchema(db, { transactions: 'auto' })
2098
- ```
2099
-
2100
- A document with one mutation, or one that selects a mutation field this build did not
2101
- generate, still runs unwrapped — there is nothing to keep atomic.
2102
-
2103
2222
  ## Write hooks
2104
2223
 
2105
2224
  `onWrite` runs a hook **inside the mutation's transaction**, so its writes commit or roll back
@@ -2260,12 +2379,12 @@ Generated schemas resolve nested relations without N+1 query explosions:
2260
2379
  relation in a single round-trip using Drizzle's relational query builder
2261
2380
  (`with:`), including nested relations and per-relation `where` / `orderBy` /
2262
2381
  `limit` / `offset` arguments.
2263
- - **Mutations (PostgreSQL & SQLite)** — after an insert or update, if the selection set
2382
+ - **Mutations (PostgreSQL & SQLite)** — after an insert, upsert, update or `updateMany`, if the selection set
2264
2383
  includes relation fields, the affected rows are re-fetched by primary key through one
2265
2384
  relational query so their relations are eagerly loaded (single- and composite-column
2266
- primary keys are supported). `delete` mutations keep using the request-scoped batch
2267
- loader, since the rows no longer exist to re-fetch. MySQL mutations return only a
2268
- success flag (no row payload), so this step does not apply there.
2385
+ primary keys are supported). `delete` and `restore` mutations keep using the
2386
+ request-scoped batch loader. MySQL mutations return only a success flag (no row
2387
+ payload), so this step does not apply there.
2269
2388
  - **Custom schemas** — each relation field also has a standalone resolver, exported as
2270
2389
  `entities.fieldResolvers[TableName][relationName]`. When you wire generated types
2271
2390
  into your own schema and your root resolver does **not** pre-fetch relations, these
@@ -2282,9 +2401,8 @@ Generated schemas resolve nested relations without N+1 query explosions:
2282
2401
 
2283
2402
  > [!IMPORTANT]
2284
2403
  > Per-parent paginated relations (a to-many relation field with `limit` or `offset`)
2285
- > rely on SQL window functions. These require **PostgreSQL**, **MySQL 8.0+**, or
2286
- > **SQLite 3.25+**. Relations without pagination, and all other query/mutation paths,
2287
- > have no such requirement.
2404
+ > and the `distinct` argument rely on SQL window functions. These require **PostgreSQL**,
2405
+ > **MySQL 8.0+**, or **SQLite 3.25+**. Everything else has no such requirement.
2288
2406
 
2289
2407
  ### Relation nullability
2290
2408
 
@@ -2345,7 +2463,9 @@ const finalSchema = addResolversToSchema({
2345
2463
  excludes that relation from `with:` (and from the mutation eager re-fetch).
2346
2464
 
2347
2465
  Opting a relation out does **not** remove its field — it keeps resolving lazily via the
2348
- request-scoped batch loader, so the field still works even before you override it. Table
2466
+ request-scoped batch loader, so the field still works even before you override it. A
2467
+ many-to-many (`.through()`) relation whose junction has a composite key cannot be loaded
2468
+ lazily (`DRIZZLE_UNSUPPORTED_RELATION`); leave it eager-loaded. Table
2349
2469
  and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
2350
2470
  of `entities.fieldResolvers`.
2351
2471
 
@@ -2388,9 +2508,10 @@ into its nested entry, and leaves `<relation>Aggregate` selections out — they
2388
2508
  `resolveSelection` additionally accepts `single`, `orderBy`, `limit`, `offset` and `after`,
2389
2509
  and returns the rows already remapped to their GraphQL transport forms.
2390
2510
 
2391
- Both take a `typeNameMapper` option. If the schema was built with one, pass the same
2392
- function here — the translation matches selections against GraphQL type names, so a build
2393
- that renames its types has to say so.
2511
+ Both take the naming options the build used — `typeNameMapper`, `derivedTypeNameMapper`,
2512
+ `typeNamePrefix` and `typeNameSuffix`. The translation matches selections against the
2513
+ published GraphQL type names, so a build that renames its types has to say so here too. If
2514
+ the build used `typeNameMapper: 'singularize'`, pass the exported `singularizeMapper`.
2394
2515
 
2395
2516
  ## Identifying generated fields
2396
2517
 
@@ -2461,7 +2582,7 @@ that explains it, where there is one.
2461
2582
  | `mutations` | `true` | `false` omits the `Mutation` type entirely |
2462
2583
  | `features` | see [Choosing what gets generated](#choosing-what-gets-generated) | which operations are generated, per operation |
2463
2584
  | `exclude` | none | [tables and columns left out](#excluding-tables-and-columns) of the generated schema entirely |
2464
- | `relationsDepthLimit` | unlimited | how deep relation fields are generated; `0` gives a flat, columns-only schema |
2585
+ | `relationsDepthLimit` | unlimited | `0` generates no relation fields (a flat, columns-only schema); any other value currently behaves like the default |
2465
2586
  | `prefixes` / `suffixes` / `typeNameMapper` | see [Naming](#naming) | operation and type names |
2466
2587
  | `typeNamePrefix` / `typeNameSuffix` / `derivedTypeNameMapper` | none | [names of the derived types](#renaming-the-derived-types) — filters, inputs, aggregates, shared inputs |
2467
2588
  | `scalars` / `mapColumnType` | built-in detection | [override a column's GraphQL type](#overriding-a-columns-scalar), by name or by rule |
@@ -2469,7 +2590,7 @@ that explains it, where there is one.
2469
2590
  | `enumValueNameMapper` | the stored value | [names the enum's values](#enum-value-names); `'constant'` for CONSTANT_CASE |
2470
2591
  | `describeColumn` / `describeTable` / `describeRelation` | none | [GraphQL descriptions](#documenting-the-schema) |
2471
2592
  | `deprecateColumn` | none | `@deprecated` on a column's output and optional input fields |
2472
- | `conflictDoNothing` | `false` | PostgreSQL only — `ON CONFLICT DO NOTHING` on inserts, which makes `create<Table>Single` nullable |
2593
+ | `conflictDoNothing` | `false` | **Deprecated** — use [`onConflict: { action: NOTHING }`](#upsert) per request. PostgreSQL and SQLite: `ON CONFLICT DO NOTHING` on every insert, which makes `create<Table>Single` nullable; ignored on MySQL |
2473
2594
  | `complexity` | on | [cost hints](#query-cost) in field extensions; `false` turns them off |
2474
2595
  | `transactions` | `'none'` | `'auto'` wraps a multi-mutation request in one [transaction](#transactions) |
2475
2596
  | `limits` | unbounded | [default and maximum page size](#bounding-page-size), globally or per table |