@vantreeseba/drizzle-graphql 7.0.0 → 7.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 +262 -7
- package/dist/README.md +262 -7
- package/dist/index.cjs +214 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +73 -2
- package/dist/index.d.ts +73 -2
- package/dist/index.js +214 -68
- package/dist/index.js.map +1 -1
- package/dist/package.json +1 -1
- package/package.json +1 -1
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,38 @@ 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
|
+
## Naming
|
|
128
|
+
|
|
129
|
+
Type and field names are derived from the Drizzle schema keys. Three options change them.
|
|
130
|
+
|
|
131
|
+
`prefixes` and `suffixes` rename operations without touching type names:
|
|
132
|
+
|
|
133
|
+
```Typescript
|
|
134
|
+
const { schema } = buildSchema(db, {
|
|
135
|
+
prefixes: { insert: 'add', update: 'edit', delete: 'remove' }, // default: create / update / delete
|
|
136
|
+
suffixes: { list: 'All', single: '' }, // default: '' / 'Single'
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The list and single suffixes cannot be identical unless a `typeNameMapper` is also given —
|
|
141
|
+
`users` and `usersSingle` would otherwise collapse onto one field name, and `buildSchema`
|
|
142
|
+
throws rather than generating a schema that silently drops one of them.
|
|
143
|
+
|
|
144
|
+
`typeNameMapper` renames the table itself, which is what a plural-keyed schema usually wants:
|
|
145
|
+
`export const users = pgTable('users', …)` otherwise produces `type Users` and a
|
|
146
|
+
`usersSingle` query.
|
|
147
|
+
|
|
148
|
+
```Typescript
|
|
149
|
+
import pluralize from 'pluralize'
|
|
150
|
+
|
|
151
|
+
const { schema } = buildSchema(db, {
|
|
152
|
+
typeNameMapper: (table) => ({ singular: pluralize.singular(table), plural: table }),
|
|
153
|
+
})
|
|
154
|
+
// type User; queries `users` / `user`; mutations `createUsers` / `createUser`
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Return `undefined` for any table that should keep its default naming.
|
|
158
|
+
|
|
126
159
|
## Scalars
|
|
127
160
|
|
|
128
161
|
Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
|
|
@@ -162,6 +195,25 @@ import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQL
|
|
|
162
195
|
`GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
|
|
163
196
|
called in SDL, to avoid clashing with the language's own `BigInt`.
|
|
164
197
|
|
|
198
|
+
### Overriding a column's scalar
|
|
199
|
+
|
|
200
|
+
Built-in detection is a default, not a ceiling. `scalars` overrides named columns, and
|
|
201
|
+
`mapColumnType` applies a rule across every column at build time — the tool for a convention
|
|
202
|
+
("every `numeric` column is `Money`") or for a custom column type detection does not know:
|
|
203
|
+
|
|
204
|
+
```Typescript
|
|
205
|
+
const { schema } = buildSchema(db, {
|
|
206
|
+
scalars: {
|
|
207
|
+
users: { email: EmailAddressScalar }, // by table key, then column key
|
|
208
|
+
},
|
|
209
|
+
mapColumnType: (column) => (column.columnType === 'PgNumeric' ? MoneyScalar : undefined),
|
|
210
|
+
})
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Either may return a single scalar, or an `{ output, input }` pair when the read and write
|
|
214
|
+
types differ. `mapColumnType` returning `undefined` keeps the default, and a column matched
|
|
215
|
+
by both is decided by `scalars` alone.
|
|
216
|
+
|
|
165
217
|
## Enum types
|
|
166
218
|
|
|
167
219
|
A column declared with a database enum becomes a GraphQL enum named after **the enum**, not
|
|
@@ -798,6 +850,51 @@ that out at the type level, `features: { requireWhere: true }` makes `where` non
|
|
|
798
850
|
`update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
|
|
799
851
|
Single variants. It defaults to `false` for backwards compatibility.
|
|
800
852
|
|
|
853
|
+
## Bounding page size
|
|
854
|
+
|
|
855
|
+
Nothing stops a client asking a list query for every row it has. `limits` sets a page size for
|
|
856
|
+
a request that names none, a ceiling for one that does, or both:
|
|
857
|
+
|
|
858
|
+
```ts
|
|
859
|
+
buildSchema(db, {
|
|
860
|
+
limits: {
|
|
861
|
+
defaultLimit: 25,
|
|
862
|
+
maxLimit: 100,
|
|
863
|
+
tables: {
|
|
864
|
+
Posts: { defaultLimit: 50 },
|
|
865
|
+
AuditLog: { maxLimit: 10, clampToMax: true },
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
});
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
| Key | Effect |
|
|
872
|
+
| --- | --- |
|
|
873
|
+
| `defaultLimit` | The `limit` applied when the request passes none |
|
|
874
|
+
| `maxLimit` | The largest `limit` a request may ask for |
|
|
875
|
+
| `clampToMax` | `false` (default) rejects a `limit` above `maxLimit`; `true` reduces it |
|
|
876
|
+
| `tables` | Per-table overrides, keyed by the table's key in your Drizzle schema |
|
|
877
|
+
|
|
878
|
+
A per-table policy overrides the global one key by key, so `AuditLog` above still inherits
|
|
879
|
+
`defaultLimit: 25`. Everything is optional; a build with no `limits` behaves exactly as before.
|
|
880
|
+
|
|
881
|
+
Two details worth stating plainly:
|
|
882
|
+
|
|
883
|
+
- **`maxLimit` alone also bounds a request that passes no `limit`.** No limit means every
|
|
884
|
+
row, which is above any maximum, so such a request runs at `maxLimit`.
|
|
885
|
+
- **A rejection is the default because truncation lies.** Silently cutting a page down to
|
|
886
|
+
`maxLimit` tells a paginating client it has reached the end when it has not. `clampToMax:
|
|
887
|
+
true` opts into that when you would rather never fail a query.
|
|
888
|
+
|
|
889
|
+
The policy applies to root list queries and to to-many relation fields, on both the eager and
|
|
890
|
+
the lazy relation paths. A relation takes the policy of the table it *reads*, not its parent's:
|
|
891
|
+
with `tables: { Posts: { maxLimit: 10 } }`, `users { posts(limit: 50) }` is rejected. Cursor
|
|
892
|
+
pagination inherits it — a page defaults to `defaultLimit` and the `cursor` values stay usable.
|
|
893
|
+
|
|
894
|
+
What it does not touch: `<tableName>Single` queries, which are already one row; aggregate,
|
|
895
|
+
group-by and `<relation>Aggregate` fields, which count over the whole table rather than
|
|
896
|
+
returning rows; and to-one relation fields, which take no `limit` at all.
|
|
897
|
+
|
|
801
898
|
## Query cost
|
|
802
899
|
|
|
803
900
|
Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
|
|
@@ -815,6 +912,10 @@ fields it mentions:
|
|
|
815
912
|
So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
|
|
816
913
|
same query without limits costs `10 * (1 + 10 * 1)` = 110.
|
|
817
914
|
|
|
915
|
+
A [limit policy](#bounding-page-size) feeds into the hint: a field with no `limit` is priced at
|
|
916
|
+
its `defaultLimit` rather than `defaultListSize`, and nothing is priced above `maxLimit`, so the
|
|
917
|
+
cost matches the rows the query can actually pull.
|
|
918
|
+
|
|
818
919
|
The hints do nothing until you install a complexity rule, so they are generated by default:
|
|
819
920
|
|
|
820
921
|
```ts
|
|
@@ -840,9 +941,108 @@ buildSchema(db, { complexity: false });
|
|
|
840
941
|
|
|
841
942
|
Cost is not a depth bound. A cyclic relation graph (`user -> posts -> author -> posts -> …`)
|
|
842
943
|
lets a client nest as deep as it likes, and a deep query over cheap fields can stay under any
|
|
843
|
-
complexity ceiling.
|
|
844
|
-
|
|
845
|
-
|
|
944
|
+
complexity ceiling. Set `relationsDepthLimit: 0` to generate no relation fields at all, or —
|
|
945
|
+
more usefully — bound depth at the edge, along with the rest of the document-shape defences
|
|
946
|
+
below.
|
|
947
|
+
|
|
948
|
+
### Hardening a public endpoint
|
|
949
|
+
|
|
950
|
+
A generated schema is wide by construction: every table is reachable, every relation is
|
|
951
|
+
traversable, and the relation graph is usually cyclic. The `complexity` hints price that
|
|
952
|
+
surface, but only for the shapes the generator knows about, and nothing in this library
|
|
953
|
+
inspects the incoming document. For a schema exposed to untrusted clients, put
|
|
954
|
+
[GraphQL Armor](https://github.com/Escape-Technologies/graphql-armor) in front of it — it
|
|
955
|
+
bundles the document-level defences this library deliberately does not generate:
|
|
956
|
+
|
|
957
|
+
| Protection | What it bounds |
|
|
958
|
+
| --- | --- |
|
|
959
|
+
| `maxDepth` | nesting through a cyclic relation graph |
|
|
960
|
+
| `maxAliases` | the same expensive list requested many times under many aliases |
|
|
961
|
+
| `maxDirectives` | directive-count amplification |
|
|
962
|
+
| `maxTokens` | parser blow-up, rejected before validation runs |
|
|
963
|
+
| `costLimit` | a static per-document cost ceiling |
|
|
964
|
+
| `blockFieldSuggestion` | `Did you mean "keyHash"?` leaking field names on a typo |
|
|
965
|
+
|
|
966
|
+
```ts
|
|
967
|
+
import { EnvelopArmor } from '@escape.tech/graphql-armor';
|
|
968
|
+
import { createYoga } from 'graphql-yoga';
|
|
969
|
+
|
|
970
|
+
const { schema } = buildSchema(db);
|
|
971
|
+
const armor = new EnvelopArmor({
|
|
972
|
+
maxDepth: { n: 8 }, // roughly three relation hops into the generated graph
|
|
973
|
+
maxAliases: { n: 15 },
|
|
974
|
+
costLimit: { maxCost: 5000 },
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
const yoga = createYoga({ schema, plugins: armor.protect().plugins });
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
`ApolloArmor` is the Apollo Server equivalent — its `protect()` returns `plugins` plus
|
|
981
|
+
`validationRules`.
|
|
982
|
+
|
|
983
|
+
Armor's `costLimit` is its own document-shape estimate and does not read the `complexity`
|
|
984
|
+
extensions above; the two compose, if you want a cheap static ceiling alongside row-count
|
|
985
|
+
aware pricing. Neither one bounds an individual `limit` argument, so a single unbounded list
|
|
986
|
+
query is still a full table scan — cap `limit` yourself if that matters.
|
|
987
|
+
|
|
988
|
+
## Authorization
|
|
989
|
+
|
|
990
|
+
`buildSchema` is handed a Drizzle instance, not a request. It has no notion of who is asking,
|
|
991
|
+
so it generates no authorization: every table it knows about is queryable and writable by
|
|
992
|
+
anyone who can reach the endpoint. Auth belongs to a layer above the schema, where the
|
|
993
|
+
request context actually lives.
|
|
994
|
+
|
|
995
|
+
- **[graphql-shield](https://github.com/maticzav/graphql-shield)** — permission rules
|
|
996
|
+
mapped onto fields and applied with
|
|
997
|
+
[`graphql-middleware`](https://github.com/maticzav/graphql-middleware). Rules attach to
|
|
998
|
+
root fields *and* to fields of a generated object type, which is what makes it an answer
|
|
999
|
+
to "this column must never leave the server" and not just "this query is blocked":
|
|
1000
|
+
|
|
1001
|
+
```ts
|
|
1002
|
+
import { applyMiddleware } from 'graphql-middleware';
|
|
1003
|
+
import { allow, deny, rule, shield } from 'graphql-shield';
|
|
1004
|
+
|
|
1005
|
+
const isAuthenticated = rule()((parent, args, ctx) => Boolean(ctx.userId));
|
|
1006
|
+
|
|
1007
|
+
const { schema } = buildSchema(db);
|
|
1008
|
+
const guarded = applyMiddleware(
|
|
1009
|
+
schema,
|
|
1010
|
+
shield(
|
|
1011
|
+
{
|
|
1012
|
+
Query: { users: isAuthenticated, usersSingle: isAuthenticated },
|
|
1013
|
+
Mutation: { '*': isAuthenticated },
|
|
1014
|
+
ApiKeys: { keyHash: deny }, // field stays in the schema, never resolves
|
|
1015
|
+
},
|
|
1016
|
+
{ fallbackRule: allow },
|
|
1017
|
+
),
|
|
1018
|
+
);
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
- **[CASL](https://casl.js.org)** (`@casl/ability`) — one ability definition, conditions and
|
|
1022
|
+
field lists included (`can('read', 'Posts', ['title', 'body'])`), shared between the
|
|
1023
|
+
GraphQL layer and the rest of the app instead of a permission model that exists only in
|
|
1024
|
+
the API. CASL is not GraphQL middleware itself: wire it in through the shield rules above
|
|
1025
|
+
or through your own resolver wrapper, and let the ability answer the questions.
|
|
1026
|
+
|
|
1027
|
+
What neither can do is **narrow** a query. They permit or deny a field; they do not append
|
|
1028
|
+
`WHERE user_id = …` to the SQL a generated resolver runs, and a permitted `users` query still
|
|
1029
|
+
returns every tenant's rows. Until row scoping is a first-class option here, the ways to get
|
|
1030
|
+
it are, in increasing order of effort:
|
|
1031
|
+
|
|
1032
|
+
- supply the tenant predicate as an ordinary `where` argument from a trusted gateway, and
|
|
1033
|
+
deny the raw generated fields to everyone else;
|
|
1034
|
+
- build the schema by hand from `entities`, exposing only the generated resolvers you have
|
|
1035
|
+
wrapped;
|
|
1036
|
+
- put the scoped tables behind database row-level security and give the driver the
|
|
1037
|
+
request's identity, so the narrowing happens below Drizzle entirely.
|
|
1038
|
+
|
|
1039
|
+
None of this removes anything from introspection either — `deny` hides the data, not the
|
|
1040
|
+
shape. If the schema must not so much as mention a table, keep that table out of the Drizzle
|
|
1041
|
+
instance you pass to `buildSchema` (a second instance over a narrowed schema object is
|
|
1042
|
+
cheap).
|
|
1043
|
+
|
|
1044
|
+
Nor is it a row bound: a complexity ceiling prices the `limit` a client asks for, it does not
|
|
1045
|
+
cap it. Use [`limits`](#bounding-page-size) for that.
|
|
846
1046
|
|
|
847
1047
|
## Error handling
|
|
848
1048
|
|
|
@@ -889,10 +1089,44 @@ want to fall back to it explicitly.
|
|
|
889
1089
|
|
|
890
1090
|
## Transactions
|
|
891
1091
|
|
|
892
|
-
Each resolver runs its statements on the database the schema was built from
|
|
893
|
-
fires several mutations
|
|
894
|
-
the earlier ones in place.
|
|
895
|
-
|
|
1092
|
+
Each resolver runs its statements on the database the schema was built from, so by default a
|
|
1093
|
+
request that fires several mutations commits each one separately and a failure halfway leaves
|
|
1094
|
+
the earlier ones in place. There are two ways to make a request atomic.
|
|
1095
|
+
|
|
1096
|
+
### Letting the library open one
|
|
1097
|
+
|
|
1098
|
+
`transactions: 'auto'` opens `db.transaction()` once per request whose document selects more
|
|
1099
|
+
than one root mutation field, and runs every mutation field — plus the reads nested under
|
|
1100
|
+
them — inside it:
|
|
1101
|
+
|
|
1102
|
+
```Typescript
|
|
1103
|
+
const { schema } = buildSchema(db, { transactions: 'auto' })
|
|
1104
|
+
|
|
1105
|
+
// or, to change the safety timeout:
|
|
1106
|
+
const { schema } = buildSchema(db, { transactions: { mode: 'auto', timeoutMs: 10_000 } })
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
The transaction commits when the last mutation field completes and rolls back if any of them
|
|
1110
|
+
fails; mutation fields after a failure fail fast instead of running against a rolled-back
|
|
1111
|
+
transaction. A document with one mutation field or none opens nothing, so ordinary requests
|
|
1112
|
+
are untouched.
|
|
1113
|
+
|
|
1114
|
+
- The driver must support `db.transaction()` — `neon-http` does not. For SQLite only an
|
|
1115
|
+
asynchronous driver can hold a transaction open across resolvers, and `buildSchema`
|
|
1116
|
+
throws for a synchronous one rather than failing at request time
|
|
1117
|
+
- The GraphQL context value must be a fresh object per request (every mainstream server
|
|
1118
|
+
does this) — it keys the per-request transaction state
|
|
1119
|
+
- `timeoutMs` (default `30000`) rolls back an abandoned transaction, e.g. when the host
|
|
1120
|
+
server stopped calling resolvers because of a non-null completion error, instead of
|
|
1121
|
+
leaking the connection
|
|
1122
|
+
- A document that mixes in mutation fields this build did not generate is left alone: their
|
|
1123
|
+
completion cannot be tracked, so no transaction is opened
|
|
1124
|
+
- It never nests — an executor supplied on the context wins, as below
|
|
1125
|
+
|
|
1126
|
+
### Supplying your own executor
|
|
1127
|
+
|
|
1128
|
+
To control the boundary yourself, open a transaction and put it on the GraphQL context under
|
|
1129
|
+
the exported `drizzleExecutorKey`:
|
|
896
1130
|
|
|
897
1131
|
```Typescript
|
|
898
1132
|
import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
|
|
@@ -1018,3 +1252,24 @@ Opting a relation out does **not** remove its field — it keeps resolving lazil
|
|
|
1018
1252
|
request-scoped batch loader, so the field still works even before you override it. Table
|
|
1019
1253
|
and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
|
|
1020
1254
|
of `entities.fieldResolvers`.
|
|
1255
|
+
|
|
1256
|
+
## Configuration reference
|
|
1257
|
+
|
|
1258
|
+
Every key of the second argument to `buildSchema(db, config?)`. Each links to the section
|
|
1259
|
+
that explains it, where there is one.
|
|
1260
|
+
|
|
1261
|
+
| Key | Default | What it does |
|
|
1262
|
+
| --- | --- | --- |
|
|
1263
|
+
| `mutations` | `true` | `false` omits the `Mutation` type entirely |
|
|
1264
|
+
| `features` | see [Choosing what gets generated](#choosing-what-gets-generated) | which operations are generated, per operation |
|
|
1265
|
+
| `relationsDepthLimit` | unlimited | how deep relation fields are generated; `0` gives a flat, columns-only schema |
|
|
1266
|
+
| `prefixes` / `suffixes` / `typeNameMapper` | see [Naming](#naming) | operation and type names |
|
|
1267
|
+
| `scalars` / `mapColumnType` | built-in detection | [override a column's GraphQL type](#overriding-a-columns-scalar), by name or by rule |
|
|
1268
|
+
| `enumNameMapper` | shared `<EnumName>Enum` per database enum | [names the enum a column maps to](#enum-types) |
|
|
1269
|
+
| `describeColumn` / `describeTable` / `describeRelation` | none | [GraphQL descriptions](#documenting-the-schema) |
|
|
1270
|
+
| `deprecateColumn` | none | `@deprecated` on a column's output and optional input fields |
|
|
1271
|
+
| `conflictDoNothing` | `false` | PostgreSQL only — `ON CONFLICT DO NOTHING` on inserts, which makes `create<Table>Single` nullable |
|
|
1272
|
+
| `complexity` | on | [cost hints](#query-cost) in field extensions; `false` turns them off |
|
|
1273
|
+
| `transactions` | `'none'` | `'auto'` wraps a multi-mutation request in one [transaction](#transactions) |
|
|
1274
|
+
| `onError` | replaces driver errors | [error handling](#error-handling) hook — log, or surface a different error |
|
|
1275
|
+
| `eagerLoadRelations` | `true` | [which relations are pre-fetched](#overriding-a-relations-resolver-without-overfetching) with the parent query |
|
package/dist/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,38 @@ 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
|
+
## Naming
|
|
128
|
+
|
|
129
|
+
Type and field names are derived from the Drizzle schema keys. Three options change them.
|
|
130
|
+
|
|
131
|
+
`prefixes` and `suffixes` rename operations without touching type names:
|
|
132
|
+
|
|
133
|
+
```Typescript
|
|
134
|
+
const { schema } = buildSchema(db, {
|
|
135
|
+
prefixes: { insert: 'add', update: 'edit', delete: 'remove' }, // default: create / update / delete
|
|
136
|
+
suffixes: { list: 'All', single: '' }, // default: '' / 'Single'
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The list and single suffixes cannot be identical unless a `typeNameMapper` is also given —
|
|
141
|
+
`users` and `usersSingle` would otherwise collapse onto one field name, and `buildSchema`
|
|
142
|
+
throws rather than generating a schema that silently drops one of them.
|
|
143
|
+
|
|
144
|
+
`typeNameMapper` renames the table itself, which is what a plural-keyed schema usually wants:
|
|
145
|
+
`export const users = pgTable('users', …)` otherwise produces `type Users` and a
|
|
146
|
+
`usersSingle` query.
|
|
147
|
+
|
|
148
|
+
```Typescript
|
|
149
|
+
import pluralize from 'pluralize'
|
|
150
|
+
|
|
151
|
+
const { schema } = buildSchema(db, {
|
|
152
|
+
typeNameMapper: (table) => ({ singular: pluralize.singular(table), plural: table }),
|
|
153
|
+
})
|
|
154
|
+
// type User; queries `users` / `user`; mutations `createUsers` / `createUser`
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Return `undefined` for any table that should keep its default naming.
|
|
158
|
+
|
|
126
159
|
## Scalars
|
|
127
160
|
|
|
128
161
|
Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
|
|
@@ -162,6 +195,25 @@ import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQL
|
|
|
162
195
|
`GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
|
|
163
196
|
called in SDL, to avoid clashing with the language's own `BigInt`.
|
|
164
197
|
|
|
198
|
+
### Overriding a column's scalar
|
|
199
|
+
|
|
200
|
+
Built-in detection is a default, not a ceiling. `scalars` overrides named columns, and
|
|
201
|
+
`mapColumnType` applies a rule across every column at build time — the tool for a convention
|
|
202
|
+
("every `numeric` column is `Money`") or for a custom column type detection does not know:
|
|
203
|
+
|
|
204
|
+
```Typescript
|
|
205
|
+
const { schema } = buildSchema(db, {
|
|
206
|
+
scalars: {
|
|
207
|
+
users: { email: EmailAddressScalar }, // by table key, then column key
|
|
208
|
+
},
|
|
209
|
+
mapColumnType: (column) => (column.columnType === 'PgNumeric' ? MoneyScalar : undefined),
|
|
210
|
+
})
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Either may return a single scalar, or an `{ output, input }` pair when the read and write
|
|
214
|
+
types differ. `mapColumnType` returning `undefined` keeps the default, and a column matched
|
|
215
|
+
by both is decided by `scalars` alone.
|
|
216
|
+
|
|
165
217
|
## Enum types
|
|
166
218
|
|
|
167
219
|
A column declared with a database enum becomes a GraphQL enum named after **the enum**, not
|
|
@@ -798,6 +850,51 @@ that out at the type level, `features: { requireWhere: true }` makes `where` non
|
|
|
798
850
|
`update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
|
|
799
851
|
Single variants. It defaults to `false` for backwards compatibility.
|
|
800
852
|
|
|
853
|
+
## Bounding page size
|
|
854
|
+
|
|
855
|
+
Nothing stops a client asking a list query for every row it has. `limits` sets a page size for
|
|
856
|
+
a request that names none, a ceiling for one that does, or both:
|
|
857
|
+
|
|
858
|
+
```ts
|
|
859
|
+
buildSchema(db, {
|
|
860
|
+
limits: {
|
|
861
|
+
defaultLimit: 25,
|
|
862
|
+
maxLimit: 100,
|
|
863
|
+
tables: {
|
|
864
|
+
Posts: { defaultLimit: 50 },
|
|
865
|
+
AuditLog: { maxLimit: 10, clampToMax: true },
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
});
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
| Key | Effect |
|
|
872
|
+
| --- | --- |
|
|
873
|
+
| `defaultLimit` | The `limit` applied when the request passes none |
|
|
874
|
+
| `maxLimit` | The largest `limit` a request may ask for |
|
|
875
|
+
| `clampToMax` | `false` (default) rejects a `limit` above `maxLimit`; `true` reduces it |
|
|
876
|
+
| `tables` | Per-table overrides, keyed by the table's key in your Drizzle schema |
|
|
877
|
+
|
|
878
|
+
A per-table policy overrides the global one key by key, so `AuditLog` above still inherits
|
|
879
|
+
`defaultLimit: 25`. Everything is optional; a build with no `limits` behaves exactly as before.
|
|
880
|
+
|
|
881
|
+
Two details worth stating plainly:
|
|
882
|
+
|
|
883
|
+
- **`maxLimit` alone also bounds a request that passes no `limit`.** No limit means every
|
|
884
|
+
row, which is above any maximum, so such a request runs at `maxLimit`.
|
|
885
|
+
- **A rejection is the default because truncation lies.** Silently cutting a page down to
|
|
886
|
+
`maxLimit` tells a paginating client it has reached the end when it has not. `clampToMax:
|
|
887
|
+
true` opts into that when you would rather never fail a query.
|
|
888
|
+
|
|
889
|
+
The policy applies to root list queries and to to-many relation fields, on both the eager and
|
|
890
|
+
the lazy relation paths. A relation takes the policy of the table it *reads*, not its parent's:
|
|
891
|
+
with `tables: { Posts: { maxLimit: 10 } }`, `users { posts(limit: 50) }` is rejected. Cursor
|
|
892
|
+
pagination inherits it — a page defaults to `defaultLimit` and the `cursor` values stay usable.
|
|
893
|
+
|
|
894
|
+
What it does not touch: `<tableName>Single` queries, which are already one row; aggregate,
|
|
895
|
+
group-by and `<relation>Aggregate` fields, which count over the whole table rather than
|
|
896
|
+
returning rows; and to-one relation fields, which take no `limit` at all.
|
|
897
|
+
|
|
801
898
|
## Query cost
|
|
802
899
|
|
|
803
900
|
Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
|
|
@@ -815,6 +912,10 @@ fields it mentions:
|
|
|
815
912
|
So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
|
|
816
913
|
same query without limits costs `10 * (1 + 10 * 1)` = 110.
|
|
817
914
|
|
|
915
|
+
A [limit policy](#bounding-page-size) feeds into the hint: a field with no `limit` is priced at
|
|
916
|
+
its `defaultLimit` rather than `defaultListSize`, and nothing is priced above `maxLimit`, so the
|
|
917
|
+
cost matches the rows the query can actually pull.
|
|
918
|
+
|
|
818
919
|
The hints do nothing until you install a complexity rule, so they are generated by default:
|
|
819
920
|
|
|
820
921
|
```ts
|
|
@@ -840,9 +941,108 @@ buildSchema(db, { complexity: false });
|
|
|
840
941
|
|
|
841
942
|
Cost is not a depth bound. A cyclic relation graph (`user -> posts -> author -> posts -> …`)
|
|
842
943
|
lets a client nest as deep as it likes, and a deep query over cheap fields can stay under any
|
|
843
|
-
complexity ceiling.
|
|
844
|
-
|
|
845
|
-
|
|
944
|
+
complexity ceiling. Set `relationsDepthLimit: 0` to generate no relation fields at all, or —
|
|
945
|
+
more usefully — bound depth at the edge, along with the rest of the document-shape defences
|
|
946
|
+
below.
|
|
947
|
+
|
|
948
|
+
### Hardening a public endpoint
|
|
949
|
+
|
|
950
|
+
A generated schema is wide by construction: every table is reachable, every relation is
|
|
951
|
+
traversable, and the relation graph is usually cyclic. The `complexity` hints price that
|
|
952
|
+
surface, but only for the shapes the generator knows about, and nothing in this library
|
|
953
|
+
inspects the incoming document. For a schema exposed to untrusted clients, put
|
|
954
|
+
[GraphQL Armor](https://github.com/Escape-Technologies/graphql-armor) in front of it — it
|
|
955
|
+
bundles the document-level defences this library deliberately does not generate:
|
|
956
|
+
|
|
957
|
+
| Protection | What it bounds |
|
|
958
|
+
| --- | --- |
|
|
959
|
+
| `maxDepth` | nesting through a cyclic relation graph |
|
|
960
|
+
| `maxAliases` | the same expensive list requested many times under many aliases |
|
|
961
|
+
| `maxDirectives` | directive-count amplification |
|
|
962
|
+
| `maxTokens` | parser blow-up, rejected before validation runs |
|
|
963
|
+
| `costLimit` | a static per-document cost ceiling |
|
|
964
|
+
| `blockFieldSuggestion` | `Did you mean "keyHash"?` leaking field names on a typo |
|
|
965
|
+
|
|
966
|
+
```ts
|
|
967
|
+
import { EnvelopArmor } from '@escape.tech/graphql-armor';
|
|
968
|
+
import { createYoga } from 'graphql-yoga';
|
|
969
|
+
|
|
970
|
+
const { schema } = buildSchema(db);
|
|
971
|
+
const armor = new EnvelopArmor({
|
|
972
|
+
maxDepth: { n: 8 }, // roughly three relation hops into the generated graph
|
|
973
|
+
maxAliases: { n: 15 },
|
|
974
|
+
costLimit: { maxCost: 5000 },
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
const yoga = createYoga({ schema, plugins: armor.protect().plugins });
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
`ApolloArmor` is the Apollo Server equivalent — its `protect()` returns `plugins` plus
|
|
981
|
+
`validationRules`.
|
|
982
|
+
|
|
983
|
+
Armor's `costLimit` is its own document-shape estimate and does not read the `complexity`
|
|
984
|
+
extensions above; the two compose, if you want a cheap static ceiling alongside row-count
|
|
985
|
+
aware pricing. Neither one bounds an individual `limit` argument, so a single unbounded list
|
|
986
|
+
query is still a full table scan — cap `limit` yourself if that matters.
|
|
987
|
+
|
|
988
|
+
## Authorization
|
|
989
|
+
|
|
990
|
+
`buildSchema` is handed a Drizzle instance, not a request. It has no notion of who is asking,
|
|
991
|
+
so it generates no authorization: every table it knows about is queryable and writable by
|
|
992
|
+
anyone who can reach the endpoint. Auth belongs to a layer above the schema, where the
|
|
993
|
+
request context actually lives.
|
|
994
|
+
|
|
995
|
+
- **[graphql-shield](https://github.com/maticzav/graphql-shield)** — permission rules
|
|
996
|
+
mapped onto fields and applied with
|
|
997
|
+
[`graphql-middleware`](https://github.com/maticzav/graphql-middleware). Rules attach to
|
|
998
|
+
root fields *and* to fields of a generated object type, which is what makes it an answer
|
|
999
|
+
to "this column must never leave the server" and not just "this query is blocked":
|
|
1000
|
+
|
|
1001
|
+
```ts
|
|
1002
|
+
import { applyMiddleware } from 'graphql-middleware';
|
|
1003
|
+
import { allow, deny, rule, shield } from 'graphql-shield';
|
|
1004
|
+
|
|
1005
|
+
const isAuthenticated = rule()((parent, args, ctx) => Boolean(ctx.userId));
|
|
1006
|
+
|
|
1007
|
+
const { schema } = buildSchema(db);
|
|
1008
|
+
const guarded = applyMiddleware(
|
|
1009
|
+
schema,
|
|
1010
|
+
shield(
|
|
1011
|
+
{
|
|
1012
|
+
Query: { users: isAuthenticated, usersSingle: isAuthenticated },
|
|
1013
|
+
Mutation: { '*': isAuthenticated },
|
|
1014
|
+
ApiKeys: { keyHash: deny }, // field stays in the schema, never resolves
|
|
1015
|
+
},
|
|
1016
|
+
{ fallbackRule: allow },
|
|
1017
|
+
),
|
|
1018
|
+
);
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
- **[CASL](https://casl.js.org)** (`@casl/ability`) — one ability definition, conditions and
|
|
1022
|
+
field lists included (`can('read', 'Posts', ['title', 'body'])`), shared between the
|
|
1023
|
+
GraphQL layer and the rest of the app instead of a permission model that exists only in
|
|
1024
|
+
the API. CASL is not GraphQL middleware itself: wire it in through the shield rules above
|
|
1025
|
+
or through your own resolver wrapper, and let the ability answer the questions.
|
|
1026
|
+
|
|
1027
|
+
What neither can do is **narrow** a query. They permit or deny a field; they do not append
|
|
1028
|
+
`WHERE user_id = …` to the SQL a generated resolver runs, and a permitted `users` query still
|
|
1029
|
+
returns every tenant's rows. Until row scoping is a first-class option here, the ways to get
|
|
1030
|
+
it are, in increasing order of effort:
|
|
1031
|
+
|
|
1032
|
+
- supply the tenant predicate as an ordinary `where` argument from a trusted gateway, and
|
|
1033
|
+
deny the raw generated fields to everyone else;
|
|
1034
|
+
- build the schema by hand from `entities`, exposing only the generated resolvers you have
|
|
1035
|
+
wrapped;
|
|
1036
|
+
- put the scoped tables behind database row-level security and give the driver the
|
|
1037
|
+
request's identity, so the narrowing happens below Drizzle entirely.
|
|
1038
|
+
|
|
1039
|
+
None of this removes anything from introspection either — `deny` hides the data, not the
|
|
1040
|
+
shape. If the schema must not so much as mention a table, keep that table out of the Drizzle
|
|
1041
|
+
instance you pass to `buildSchema` (a second instance over a narrowed schema object is
|
|
1042
|
+
cheap).
|
|
1043
|
+
|
|
1044
|
+
Nor is it a row bound: a complexity ceiling prices the `limit` a client asks for, it does not
|
|
1045
|
+
cap it. Use [`limits`](#bounding-page-size) for that.
|
|
846
1046
|
|
|
847
1047
|
## Error handling
|
|
848
1048
|
|
|
@@ -889,10 +1089,44 @@ want to fall back to it explicitly.
|
|
|
889
1089
|
|
|
890
1090
|
## Transactions
|
|
891
1091
|
|
|
892
|
-
Each resolver runs its statements on the database the schema was built from
|
|
893
|
-
fires several mutations
|
|
894
|
-
the earlier ones in place.
|
|
895
|
-
|
|
1092
|
+
Each resolver runs its statements on the database the schema was built from, so by default a
|
|
1093
|
+
request that fires several mutations commits each one separately and a failure halfway leaves
|
|
1094
|
+
the earlier ones in place. There are two ways to make a request atomic.
|
|
1095
|
+
|
|
1096
|
+
### Letting the library open one
|
|
1097
|
+
|
|
1098
|
+
`transactions: 'auto'` opens `db.transaction()` once per request whose document selects more
|
|
1099
|
+
than one root mutation field, and runs every mutation field — plus the reads nested under
|
|
1100
|
+
them — inside it:
|
|
1101
|
+
|
|
1102
|
+
```Typescript
|
|
1103
|
+
const { schema } = buildSchema(db, { transactions: 'auto' })
|
|
1104
|
+
|
|
1105
|
+
// or, to change the safety timeout:
|
|
1106
|
+
const { schema } = buildSchema(db, { transactions: { mode: 'auto', timeoutMs: 10_000 } })
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
The transaction commits when the last mutation field completes and rolls back if any of them
|
|
1110
|
+
fails; mutation fields after a failure fail fast instead of running against a rolled-back
|
|
1111
|
+
transaction. A document with one mutation field or none opens nothing, so ordinary requests
|
|
1112
|
+
are untouched.
|
|
1113
|
+
|
|
1114
|
+
- The driver must support `db.transaction()` — `neon-http` does not. For SQLite only an
|
|
1115
|
+
asynchronous driver can hold a transaction open across resolvers, and `buildSchema`
|
|
1116
|
+
throws for a synchronous one rather than failing at request time
|
|
1117
|
+
- The GraphQL context value must be a fresh object per request (every mainstream server
|
|
1118
|
+
does this) — it keys the per-request transaction state
|
|
1119
|
+
- `timeoutMs` (default `30000`) rolls back an abandoned transaction, e.g. when the host
|
|
1120
|
+
server stopped calling resolvers because of a non-null completion error, instead of
|
|
1121
|
+
leaking the connection
|
|
1122
|
+
- A document that mixes in mutation fields this build did not generate is left alone: their
|
|
1123
|
+
completion cannot be tracked, so no transaction is opened
|
|
1124
|
+
- It never nests — an executor supplied on the context wins, as below
|
|
1125
|
+
|
|
1126
|
+
### Supplying your own executor
|
|
1127
|
+
|
|
1128
|
+
To control the boundary yourself, open a transaction and put it on the GraphQL context under
|
|
1129
|
+
the exported `drizzleExecutorKey`:
|
|
896
1130
|
|
|
897
1131
|
```Typescript
|
|
898
1132
|
import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
|
|
@@ -1018,3 +1252,24 @@ Opting a relation out does **not** remove its field — it keeps resolving lazil
|
|
|
1018
1252
|
request-scoped batch loader, so the field still works even before you override it. Table
|
|
1019
1253
|
and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
|
|
1020
1254
|
of `entities.fieldResolvers`.
|
|
1255
|
+
|
|
1256
|
+
## Configuration reference
|
|
1257
|
+
|
|
1258
|
+
Every key of the second argument to `buildSchema(db, config?)`. Each links to the section
|
|
1259
|
+
that explains it, where there is one.
|
|
1260
|
+
|
|
1261
|
+
| Key | Default | What it does |
|
|
1262
|
+
| --- | --- | --- |
|
|
1263
|
+
| `mutations` | `true` | `false` omits the `Mutation` type entirely |
|
|
1264
|
+
| `features` | see [Choosing what gets generated](#choosing-what-gets-generated) | which operations are generated, per operation |
|
|
1265
|
+
| `relationsDepthLimit` | unlimited | how deep relation fields are generated; `0` gives a flat, columns-only schema |
|
|
1266
|
+
| `prefixes` / `suffixes` / `typeNameMapper` | see [Naming](#naming) | operation and type names |
|
|
1267
|
+
| `scalars` / `mapColumnType` | built-in detection | [override a column's GraphQL type](#overriding-a-columns-scalar), by name or by rule |
|
|
1268
|
+
| `enumNameMapper` | shared `<EnumName>Enum` per database enum | [names the enum a column maps to](#enum-types) |
|
|
1269
|
+
| `describeColumn` / `describeTable` / `describeRelation` | none | [GraphQL descriptions](#documenting-the-schema) |
|
|
1270
|
+
| `deprecateColumn` | none | `@deprecated` on a column's output and optional input fields |
|
|
1271
|
+
| `conflictDoNothing` | `false` | PostgreSQL only — `ON CONFLICT DO NOTHING` on inserts, which makes `create<Table>Single` nullable |
|
|
1272
|
+
| `complexity` | on | [cost hints](#query-cost) in field extensions; `false` turns them off |
|
|
1273
|
+
| `transactions` | `'none'` | `'auto'` wraps a multi-mutation request in one [transaction](#transactions) |
|
|
1274
|
+
| `onError` | replaces driver errors | [error handling](#error-handling) hook — log, or surface a different error |
|
|
1275
|
+
| `eagerLoadRelations` | `true` | [which relations are pre-fetched](#overriding-a-relations-resolver-without-overfetching) with the parent query |
|