@vantreeseba/drizzle-graphql 7.0.0 → 7.2.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
@@ -101,6 +101,7 @@ const { schema } = buildSchema(db, {
101
101
  distinct: false, // the `distinct` argument on list queries
102
102
  insert: false, // create<Table> / create<Table>Single mutations
103
103
  update: false, // update<Table> / update<Table>Single mutations
104
+ updateMany: false, // update<Table>Many batch mutation (needs `update`)
104
105
  delete: false, // delete<Table> / delete<Table>Single mutations
105
106
  upsert: true, // upsert<Table> / upsert<Table>Single mutations (off by default)
106
107
  nestedWrites: true, // relation fields on the create/update inputs (off by default)
@@ -123,6 +124,92 @@ const { schema } = buildSchema(db, {
123
124
  `mutations: false`
124
125
  - List and single queries are always generated, so `Query` is never empty
125
126
 
127
+ ## Excluding tables and columns
128
+
129
+ `features` decides which *kinds* of operation exist. `exclude` decides which tables and
130
+ columns exist at all — for the table holding session tokens, or the column holding a
131
+ password hash:
132
+
133
+ ```Typescript
134
+ const { schema } = buildSchema(db, {
135
+ exclude: {
136
+ tables: ['magicLinks', 'sessions'],
137
+ columns: { apiKeys: ['keyHash'] },
138
+ },
139
+ })
140
+ ```
141
+
142
+ Both lists are keyed by the **Drizzle schema key**, not the database name and not the
143
+ generated GraphQL name.
144
+
145
+ An excluded **table** generates nothing: no object type, no queries, no mutations, no
146
+ aggregates, and no relation field on any other table that points at it. `Users.sessions`
147
+ is gone, and so is `users(where: { sessions: { … } })` — the table is unreachable, not
148
+ merely unnamed.
149
+
150
+ An excluded **column** disappears from every surface at once:
151
+
152
+ | Surface | Effect |
153
+ | --- | --- |
154
+ | `<Type>` object type | field removed |
155
+ | `Create<Type>Input` / `Update<Type>Input` | field removed (nested-write payloads too) |
156
+ | `<Type>Filters` | field removed |
157
+ | `<Type>OrderBy` | field removed |
158
+ | `<Type>MinAggregate` / `<Type>MaxAggregate` / … | field removed |
159
+ | `<Type>DistinctColumn` / `<Type>GroupByColumn` | value removed |
160
+ | `<Type>ConflictColumn` (upsert targets) | value removed |
161
+
162
+ Two details worth stating plainly:
163
+
164
+ - **The exclusion is all-or-nothing on purpose.** A column you can filter on but not
165
+ select is an oracle: `where: { keyHash: { eq: '…' } }` confirms a value without ever
166
+ returning it. Ordering and grouping leak the same way, more slowly. There is no
167
+ read-only or write-only variant of `exclude` for this reason.
168
+ - **Nothing changes below the schema.** An excluded column is still written by its
169
+ default, still read by the primary-key and cursor machinery, and still usable by your
170
+ own resolvers. Only the generated schema stops mentioning it.
171
+
172
+ Names that match nothing throw at build time, so renaming a column fails the build instead
173
+ of quietly un-hiding it. Excluding a `NOT NULL` column with no default is allowed — the
174
+ table becomes readable but not insertable — and warns on the console, since that is more
175
+ often a mistake than a plan.
176
+
177
+ > `exclude` is a static decision: the column is absent for every caller. Deciding per
178
+ > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
179
+ > or an equivalent layer above the schema, which has the request context this library
180
+ > does not.
181
+ ## Naming
182
+
183
+ Type and field names are derived from the Drizzle schema keys. Three options change them.
184
+
185
+ `prefixes` and `suffixes` rename operations without touching type names:
186
+
187
+ ```Typescript
188
+ const { schema } = buildSchema(db, {
189
+ prefixes: { insert: 'add', update: 'edit', delete: 'remove' }, // default: create / update / delete
190
+ suffixes: { list: 'All', single: '' }, // default: '' / 'Single'
191
+ })
192
+ ```
193
+
194
+ The list and single suffixes cannot be identical unless a `typeNameMapper` is also given —
195
+ `users` and `usersSingle` would otherwise collapse onto one field name, and `buildSchema`
196
+ throws rather than generating a schema that silently drops one of them.
197
+
198
+ `typeNameMapper` renames the table itself, which is what a plural-keyed schema usually wants:
199
+ `export const users = pgTable('users', …)` otherwise produces `type Users` and a
200
+ `usersSingle` query.
201
+
202
+ ```Typescript
203
+ import pluralize from 'pluralize'
204
+
205
+ const { schema } = buildSchema(db, {
206
+ typeNameMapper: (table) => ({ singular: pluralize.singular(table), plural: table }),
207
+ })
208
+ // type User; queries `users` / `user`; mutations `createUsers` / `createUser`
209
+ ```
210
+
211
+ Return `undefined` for any table that should keep its default naming.
212
+
126
213
  ## Scalars
127
214
 
128
215
  Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
@@ -162,6 +249,25 @@ import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQL
162
249
  `GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
163
250
  called in SDL, to avoid clashing with the language's own `BigInt`.
164
251
 
252
+ ### Overriding a column's scalar
253
+
254
+ Built-in detection is a default, not a ceiling. `scalars` overrides named columns, and
255
+ `mapColumnType` applies a rule across every column at build time — the tool for a convention
256
+ ("every `numeric` column is `Money`") or for a custom column type detection does not know:
257
+
258
+ ```Typescript
259
+ const { schema } = buildSchema(db, {
260
+ scalars: {
261
+ users: { email: EmailAddressScalar }, // by table key, then column key
262
+ },
263
+ mapColumnType: (column) => (column.columnType === 'PgNumeric' ? MoneyScalar : undefined),
264
+ })
265
+ ```
266
+
267
+ Either may return a single scalar, or an `{ output, input }` pair when the read and write
268
+ types differ. `mapColumnType` returning `undefined` keeps the default, and a column matched
269
+ by both is decided by `scalars` alone.
270
+
165
271
  ## Enum types
166
272
 
167
273
  A column declared with a database enum becomes a GraphQL enum named after **the enum**, not
@@ -798,6 +904,51 @@ that out at the type level, `features: { requireWhere: true }` makes `where` non
798
904
  `update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
799
905
  Single variants. It defaults to `false` for backwards compatibility.
800
906
 
907
+ ## Bounding page size
908
+
909
+ Nothing stops a client asking a list query for every row it has. `limits` sets a page size for
910
+ a request that names none, a ceiling for one that does, or both:
911
+
912
+ ```ts
913
+ buildSchema(db, {
914
+ limits: {
915
+ defaultLimit: 25,
916
+ maxLimit: 100,
917
+ tables: {
918
+ Posts: { defaultLimit: 50 },
919
+ AuditLog: { maxLimit: 10, clampToMax: true },
920
+ },
921
+ },
922
+ });
923
+ ```
924
+
925
+ | Key | Effect |
926
+ | --- | --- |
927
+ | `defaultLimit` | The `limit` applied when the request passes none |
928
+ | `maxLimit` | The largest `limit` a request may ask for |
929
+ | `clampToMax` | `false` (default) rejects a `limit` above `maxLimit`; `true` reduces it |
930
+ | `tables` | Per-table overrides, keyed by the table's key in your Drizzle schema |
931
+
932
+ A per-table policy overrides the global one key by key, so `AuditLog` above still inherits
933
+ `defaultLimit: 25`. Everything is optional; a build with no `limits` behaves exactly as before.
934
+
935
+ Two details worth stating plainly:
936
+
937
+ - **`maxLimit` alone also bounds a request that passes no `limit`.** No limit means every
938
+ row, which is above any maximum, so such a request runs at `maxLimit`.
939
+ - **A rejection is the default because truncation lies.** Silently cutting a page down to
940
+ `maxLimit` tells a paginating client it has reached the end when it has not. `clampToMax:
941
+ true` opts into that when you would rather never fail a query.
942
+
943
+ The policy applies to root list queries and to to-many relation fields, on both the eager and
944
+ the lazy relation paths. A relation takes the policy of the table it *reads*, not its parent's:
945
+ with `tables: { Posts: { maxLimit: 10 } }`, `users { posts(limit: 50) }` is rejected. Cursor
946
+ pagination inherits it — a page defaults to `defaultLimit` and the `cursor` values stay usable.
947
+
948
+ What it does not touch: `<tableName>Single` queries, which are already one row; aggregate,
949
+ group-by and `<relation>Aggregate` fields, which count over the whole table rather than
950
+ returning rows; and to-one relation fields, which take no `limit` at all.
951
+
801
952
  ## Query cost
802
953
 
803
954
  Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
@@ -815,6 +966,10 @@ fields it mentions:
815
966
  So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
816
967
  same query without limits costs `10 * (1 + 10 * 1)` = 110.
817
968
 
969
+ A [limit policy](#bounding-page-size) feeds into the hint: a field with no `limit` is priced at
970
+ its `defaultLimit` rather than `defaultListSize`, and nothing is priced above `maxLimit`, so the
971
+ cost matches the rows the query can actually pull.
972
+
818
973
  The hints do nothing until you install a complexity rule, so they are generated by default:
819
974
 
820
975
  ```ts
@@ -840,9 +995,108 @@ buildSchema(db, { complexity: false });
840
995
 
841
996
  Cost is not a depth bound. A cyclic relation graph (`user -> posts -> author -> posts -> …`)
842
997
  lets a client nest as deep as it likes, and a deep query over cheap fields can stay under any
843
- complexity ceiling. Put a depth limit in front of a publicly exposed schema as well — e.g.
844
- [`graphql-depth-limit`](https://github.com/stems/graphql-depth-limit) — or set
845
- `relationsDepthLimit: 0` to generate no relation fields at all.
998
+ complexity ceiling. Set `relationsDepthLimit: 0` to generate no relation fields at all, or —
999
+ more usefully — bound depth at the edge, along with the rest of the document-shape defences
1000
+ below.
1001
+
1002
+ ### Hardening a public endpoint
1003
+
1004
+ A generated schema is wide by construction: every table is reachable, every relation is
1005
+ traversable, and the relation graph is usually cyclic. The `complexity` hints price that
1006
+ surface, but only for the shapes the generator knows about, and nothing in this library
1007
+ inspects the incoming document. For a schema exposed to untrusted clients, put
1008
+ [GraphQL Armor](https://github.com/Escape-Technologies/graphql-armor) in front of it — it
1009
+ bundles the document-level defences this library deliberately does not generate:
1010
+
1011
+ | Protection | What it bounds |
1012
+ | --- | --- |
1013
+ | `maxDepth` | nesting through a cyclic relation graph |
1014
+ | `maxAliases` | the same expensive list requested many times under many aliases |
1015
+ | `maxDirectives` | directive-count amplification |
1016
+ | `maxTokens` | parser blow-up, rejected before validation runs |
1017
+ | `costLimit` | a static per-document cost ceiling |
1018
+ | `blockFieldSuggestion` | `Did you mean "keyHash"?` leaking field names on a typo |
1019
+
1020
+ ```ts
1021
+ import { EnvelopArmor } from '@escape.tech/graphql-armor';
1022
+ import { createYoga } from 'graphql-yoga';
1023
+
1024
+ const { schema } = buildSchema(db);
1025
+ const armor = new EnvelopArmor({
1026
+ maxDepth: { n: 8 }, // roughly three relation hops into the generated graph
1027
+ maxAliases: { n: 15 },
1028
+ costLimit: { maxCost: 5000 },
1029
+ });
1030
+
1031
+ const yoga = createYoga({ schema, plugins: armor.protect().plugins });
1032
+ ```
1033
+
1034
+ `ApolloArmor` is the Apollo Server equivalent — its `protect()` returns `plugins` plus
1035
+ `validationRules`.
1036
+
1037
+ Armor's `costLimit` is its own document-shape estimate and does not read the `complexity`
1038
+ extensions above; the two compose, if you want a cheap static ceiling alongside row-count
1039
+ aware pricing. Neither one bounds an individual `limit` argument, so a single unbounded list
1040
+ query is still a full table scan — cap `limit` yourself if that matters.
1041
+
1042
+ ## Authorization
1043
+
1044
+ `buildSchema` is handed a Drizzle instance, not a request. It has no notion of who is asking,
1045
+ so it generates no authorization: every table it knows about is queryable and writable by
1046
+ anyone who can reach the endpoint. Auth belongs to a layer above the schema, where the
1047
+ request context actually lives.
1048
+
1049
+ - **[graphql-shield](https://github.com/maticzav/graphql-shield)** — permission rules
1050
+ mapped onto fields and applied with
1051
+ [`graphql-middleware`](https://github.com/maticzav/graphql-middleware). Rules attach to
1052
+ root fields *and* to fields of a generated object type, which is what makes it an answer
1053
+ to "this column must never leave the server" and not just "this query is blocked":
1054
+
1055
+ ```ts
1056
+ import { applyMiddleware } from 'graphql-middleware';
1057
+ import { allow, deny, rule, shield } from 'graphql-shield';
1058
+
1059
+ const isAuthenticated = rule()((parent, args, ctx) => Boolean(ctx.userId));
1060
+
1061
+ const { schema } = buildSchema(db);
1062
+ const guarded = applyMiddleware(
1063
+ schema,
1064
+ shield(
1065
+ {
1066
+ Query: { users: isAuthenticated, usersSingle: isAuthenticated },
1067
+ Mutation: { '*': isAuthenticated },
1068
+ ApiKeys: { keyHash: deny }, // field stays in the schema, never resolves
1069
+ },
1070
+ { fallbackRule: allow },
1071
+ ),
1072
+ );
1073
+ ```
1074
+
1075
+ - **[CASL](https://casl.js.org)** (`@casl/ability`) — one ability definition, conditions and
1076
+ field lists included (`can('read', 'Posts', ['title', 'body'])`), shared between the
1077
+ GraphQL layer and the rest of the app instead of a permission model that exists only in
1078
+ the API. CASL is not GraphQL middleware itself: wire it in through the shield rules above
1079
+ or through your own resolver wrapper, and let the ability answer the questions.
1080
+
1081
+ What neither can do is **narrow** a query. They permit or deny a field; they do not append
1082
+ `WHERE user_id = …` to the SQL a generated resolver runs, and a permitted `users` query still
1083
+ returns every tenant's rows. Until row scoping is a first-class option here, the ways to get
1084
+ it are, in increasing order of effort:
1085
+
1086
+ - supply the tenant predicate as an ordinary `where` argument from a trusted gateway, and
1087
+ deny the raw generated fields to everyone else;
1088
+ - build the schema by hand from `entities`, exposing only the generated resolvers you have
1089
+ wrapped;
1090
+ - put the scoped tables behind database row-level security and give the driver the
1091
+ request's identity, so the narrowing happens below Drizzle entirely.
1092
+
1093
+ None of this removes anything from introspection either — `deny` hides the data, not the
1094
+ shape. If the schema must not so much as mention a table, keep that table out of the Drizzle
1095
+ instance you pass to `buildSchema` (a second instance over a narrowed schema object is
1096
+ cheap).
1097
+
1098
+ Nor is it a row bound: a complexity ceiling prices the `limit` a client asks for, it does not
1099
+ cap it. Use [`limits`](#bounding-page-size) for that.
846
1100
 
847
1101
  ## Error handling
848
1102
 
@@ -889,10 +1143,44 @@ want to fall back to it explicitly.
889
1143
 
890
1144
  ## Transactions
891
1145
 
892
- Each resolver runs its statements on the database the schema was built from. A request that
893
- fires several mutations therefore commits each one separately, and a failure halfway leaves
894
- the earlier ones in place. To run a whole request as one unit, open a transaction yourself
895
- and put it on the GraphQL context under the exported `drizzleExecutorKey`:
1146
+ Each resolver runs its statements on the database the schema was built from, so by default a
1147
+ request that fires several mutations commits each one separately and a failure halfway leaves
1148
+ the earlier ones in place. There are two ways to make a request atomic.
1149
+
1150
+ ### Letting the library open one
1151
+
1152
+ `transactions: 'auto'` opens `db.transaction()` once per request whose document selects more
1153
+ than one root mutation field, and runs every mutation field — plus the reads nested under
1154
+ them — inside it:
1155
+
1156
+ ```Typescript
1157
+ const { schema } = buildSchema(db, { transactions: 'auto' })
1158
+
1159
+ // or, to change the safety timeout:
1160
+ const { schema } = buildSchema(db, { transactions: { mode: 'auto', timeoutMs: 10_000 } })
1161
+ ```
1162
+
1163
+ The transaction commits when the last mutation field completes and rolls back if any of them
1164
+ fails; mutation fields after a failure fail fast instead of running against a rolled-back
1165
+ transaction. A document with one mutation field or none opens nothing, so ordinary requests
1166
+ are untouched.
1167
+
1168
+ - The driver must support `db.transaction()` — `neon-http` does not. For SQLite only an
1169
+ asynchronous driver can hold a transaction open across resolvers, and `buildSchema`
1170
+ throws for a synchronous one rather than failing at request time
1171
+ - The GraphQL context value must be a fresh object per request (every mainstream server
1172
+ does this) — it keys the per-request transaction state
1173
+ - `timeoutMs` (default `30000`) rolls back an abandoned transaction, e.g. when the host
1174
+ server stopped calling resolvers because of a non-null completion error, instead of
1175
+ leaking the connection
1176
+ - A document that mixes in mutation fields this build did not generate is left alone: their
1177
+ completion cannot be tracked, so no transaction is opened
1178
+ - It never nests — an executor supplied on the context wins, as below
1179
+
1180
+ ### Supplying your own executor
1181
+
1182
+ To control the boundary yourself, open a transaction and put it on the GraphQL context under
1183
+ the exported `drizzleExecutorKey`:
896
1184
 
897
1185
  ```Typescript
898
1186
  import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
@@ -1018,3 +1306,24 @@ Opting a relation out does **not** remove its field — it keeps resolving lazil
1018
1306
  request-scoped batch loader, so the field still works even before you override it. Table
1019
1307
  and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
1020
1308
  of `entities.fieldResolvers`.
1309
+
1310
+ ## Configuration reference
1311
+
1312
+ Every key of the second argument to `buildSchema(db, config?)`. Each links to the section
1313
+ that explains it, where there is one.
1314
+
1315
+ | Key | Default | What it does |
1316
+ | --- | --- | --- |
1317
+ | `mutations` | `true` | `false` omits the `Mutation` type entirely |
1318
+ | `features` | see [Choosing what gets generated](#choosing-what-gets-generated) | which operations are generated, per operation |
1319
+ | `relationsDepthLimit` | unlimited | how deep relation fields are generated; `0` gives a flat, columns-only schema |
1320
+ | `prefixes` / `suffixes` / `typeNameMapper` | see [Naming](#naming) | operation and type names |
1321
+ | `scalars` / `mapColumnType` | built-in detection | [override a column's GraphQL type](#overriding-a-columns-scalar), by name or by rule |
1322
+ | `enumNameMapper` | shared `<EnumName>Enum` per database enum | [names the enum a column maps to](#enum-types) |
1323
+ | `describeColumn` / `describeTable` / `describeRelation` | none | [GraphQL descriptions](#documenting-the-schema) |
1324
+ | `deprecateColumn` | none | `@deprecated` on a column's output and optional input fields |
1325
+ | `conflictDoNothing` | `false` | PostgreSQL only — `ON CONFLICT DO NOTHING` on inserts, which makes `create<Table>Single` nullable |
1326
+ | `complexity` | on | [cost hints](#query-cost) in field extensions; `false` turns them off |
1327
+ | `transactions` | `'none'` | `'auto'` wraps a multi-mutation request in one [transaction](#transactions) |
1328
+ | `onError` | replaces driver errors | [error handling](#error-handling) hook — log, or surface a different error |
1329
+ | `eagerLoadRelations` | `true` | [which relations are pre-fetched](#overriding-a-relations-resolver-without-overfetching) with the parent query |