@pothos/plugin-drizzle 0.19.0 → 0.19.1

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
@@ -1,7 +1,7 @@
1
1
  # Drizzle Plugin for Pothos
2
2
 
3
3
  > **Note:**
4
- This plugin uses drizzle's [relational query builder v2](https://orm.drizzle.team/docs/rqb-v2),
4
+ This plugin uses drizzle's [relational query builder v2](https://orm.drizzle.team/docs/rqb-v2),
5
5
  which ships in `drizzle-orm` 1.0. That release is still a release candidate, and npm's `latest`
6
6
  tag is the 0.x line, so install drizzle with the `rc` tag. Its API can still change before 1.0 is
7
7
  final.
@@ -11,133 +11,118 @@
11
11
  Pothos specific changes.
12
12
 
13
13
 
14
- The Drizzle plugin defines GraphQL types from your tables and plans database queries from GraphQL
15
- selections. It loads relations and integrates with Relay nodes and connections.
14
+ The Drizzle plugin maps database tables to GraphQL types and plans database queries from the
15
+ fields a client selects. It supports relations, computed fields, and Relay nodes and connections.
16
16
 
17
- ## Getting started
17
+ The examples use authors and posts to demonstrate the plugin APIs.
18
18
 
19
- Install the plugin and add it to your builder, as described in [Setup](https://pothos-graphql.dev/docs/plugins/drizzle/setup):
19
+ ### Getting started
20
20
 
21
- ```package-install
22
- npm install --save @pothos/plugin-drizzle drizzle-orm@rc
23
- ```
24
-
25
- Define an object type for a table, expose the columns you want in your API, and add the relations
26
- you want clients to query. This example uses the builder, database client, and `userId` context
27
- from Setup, with a users table related to posts:
28
-
29
- ```ts
30
- const User = builder.drizzleObject('users', {
31
- name: 'User',
32
- fields: (t) => ({
33
- firstName: t.exposeString('firstName'),
34
- lastName: t.exposeString('lastName'),
35
- posts: t.relation('posts'),
36
- }),
37
- });
38
-
39
- builder.drizzleObject('posts', {
40
- name: 'Post',
41
- fields: (t) => ({ title: t.exposeString('title') }),
42
- });
21
+ [Setup](https://pothos-graphql.dev/docs/plugins/drizzle/setup) connects your Drizzle client to Pothos.
22
+ [Objects](https://pothos-graphql.dev/docs/plugins/drizzle/objects) covers type definitions and fields such as this lookup:
43
23
 
24
+ ```typescript
44
25
  builder.queryType({
45
26
  fields: (t) => ({
46
- me: t.drizzleField({
27
+ author: t.drizzleField({
47
28
  type: 'users',
48
29
  nullable: true,
49
- resolve: (query, root, args, ctx) =>
50
- db.query.users.findFirst(query({ where: { id: ctx.userId } })),
30
+ args: { id: t.arg.int({ required: true }) },
31
+ resolve: (query, _root, args) => {
32
+ return db.query.users.findFirst(
33
+ query({
34
+ where: { id: args.id },
35
+ }),
36
+ );
37
+ },
51
38
  }),
52
39
  }),
53
40
  });
54
41
  ```
55
42
 
56
- Querying `me { firstName posts { title } }` loads the user and their posts in a single query
57
- through drizzles relational query builder.
43
+ ```graphql
44
+ query AuthorPage {
45
+ author(id: 1) {
46
+ fullName
47
+ posts {
48
+ title
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ The resolver passes Pothos's selection plan to Drizzle, loading the requested posts with their
55
+ author. Adding GraphQL fields does not require rewriting this root resolver.
56
+
57
+ [Relations](https://pothos-graphql.dev/docs/plugins/drizzle/relations) covers filtering and counts; [Selections](https://pothos-graphql.dev/docs/plugins/drizzle/selections) explains computed
58
+ fields and profiles; [Connections](https://pothos-graphql.dev/docs/plugins/drizzle/connections) handles pagination. [Variants](https://pothos-graphql.dev/docs/plugins/drizzle/variants) and
59
+ [Interfaces](https://pothos-graphql.dev/docs/plugins/drizzle/interfaces) keep private account data separate from public authors. For more
60
+ specialized schemas, [Connection helpers](https://pothos-graphql.dev/docs/plugins/drizzle/connection-helpers) and [Async selections](https://pothos-graphql.dev/docs/plugins/drizzle/async-selections)
61
+ cover custom pagination and asynchronous planning.
58
62
 
59
63
  ## Setup
60
64
 
65
+ Pass your existing Drizzle client and relations to the plugin. These guides assume familiarity
66
+ with Drizzle; use its [driver setup](https://orm.drizzle.team/docs/connect-overview) and
67
+ [relations documentation](https://orm.drizzle.team/docs/relations-v2) to configure the database.
68
+
61
69
  ### Installing
62
70
 
63
71
  ```package-install
64
- npm install --save @pothos/plugin-drizzle drizzle-orm@rc
72
+ npm install --save @pothos/plugin-drizzle
65
73
  ```
66
74
 
67
- The Drizzle plugin uses the relational query builder. Define the tables and relations your schema
68
- will query; see Drizzle’s [relations API](https://orm.drizzle.team/docs/relations-v2).
75
+ The plugin requires Drizzle's relational query builder v2. See the
76
+ [version requirements](https://pothos-graphql.dev/docs/plugins/drizzle) before installing or upgrading Drizzle.
69
77
 
70
- Once you have configured your drizzle schema, you can initialize your Pothos
71
- SchemaBuilder with the drizzle plugin. This example uses SQLite through `@libsql/client`;
72
- install that driver and use the `relations` exported by your application. For another database,
73
- use its Drizzle driver and matching `getTableConfig` import:
78
+ ### Configure the builder
74
79
 
75
80
  ```ts
76
- import { createClient } from '@libsql/client';
77
- import { drizzle } from 'drizzle-orm/libsql';
78
- // Import the appropriate getTableConfig for your dialect
79
- import { getTableConfig } from 'drizzle-orm/sqlite-core';
80
- import SchemaBuilder from '@pothos/core';
81
- import DrizzlePlugin from '@pothos/plugin-drizzle';
82
- import { relations } from './db/relations';
83
-
84
- const client = createClient({ url: 'file:./dev.db' });
85
- const db = drizzle({ client, relations });
86
-
87
- type DrizzleRelations = typeof relations;
81
+ // builder.ts
88
82
 
89
83
  export interface PothosTypes {
90
- DrizzleRelations: DrizzleRelations;
84
+ DrizzleRelations: typeof relations;
91
85
  Context: { userId: number };
92
86
  }
93
87
 
94
- const builder = new SchemaBuilder<PothosTypes>({
88
+ export const builder = new SchemaBuilder<PothosTypes>({
95
89
  plugins: [DrizzlePlugin],
96
- drizzle: {
97
- client: db, // or (ctx) => db if you want to create a request specific client
98
- getTableConfig,
99
- relations,
100
- },
90
+ drizzle: { client: db, getTableConfig, relations },
101
91
  });
102
92
  ```
103
93
 
104
- The examples use an authenticated request context with `userId: number`. Supply it through your
105
- GraphQL server; see [Context](https://pothos-graphql.dev/docs/guide/context).
94
+ `DrizzleRelations` provides the table and relation types used by Pothos. Import `getTableConfig`
95
+ from the dialect used by your client (`sqlite-core` above, or `pg-core` / `mysql-core`). `client`
96
+ may also be a function `(ctx) => db` for a request-specific client. The examples use
97
+ [request context](https://pothos-graphql.dev/docs/guide/context) to supply the current `userId`.
106
98
 
107
- #### Integration with other plugins
99
+ Use this builder to [define GraphQL types and fields](https://pothos-graphql.dev/docs/plugins/drizzle/objects), then call `builder.toSchema()`.
108
100
 
109
- The drizzle plugin has integrations with several other plugins. While the `with-input` and `relay`
110
- plugins are not required, many examples will assume these plugins have been installed:
101
+ ### Integration with other plugins
102
+
103
+ Install and register `@pothos/plugin-relay` to use `drizzleNode` and connection fields:
111
104
 
112
105
  ```ts
113
- import { createClient } from '@libsql/client';
114
- import { drizzle } from 'drizzle-orm/libsql';
115
- import SchemaBuilder from '@pothos/core';
116
- import DrizzlePlugin from '@pothos/plugin-drizzle';
117
- import RelayPlugin from '@pothos/plugin-relay';
118
- import WithInputPlugin from '@pothos/plugin-with-input';
119
- import { getTableConfig } from 'drizzle-orm/sqlite-core';
120
- import { relations } from './db/relations';
121
-
122
- const client = createClient({ url: 'file:./dev.db' });
123
- const db = drizzle({ client, relations });
124
106
 
125
- export interface PothosTypes {
126
- DrizzleRelations: typeof relations;
127
- Context: { userId: number };
128
- }
107
+ // In the builder options:
108
+ plugins: [RelayPlugin, DrizzlePlugin],
109
+ relay: { nodesOnConnection: true },
110
+ ```
129
111
 
130
- const builder = new SchemaBuilder<PothosTypes>({
131
- plugins: [RelayPlugin, WithInputPlugin, DrizzlePlugin],
132
- drizzle: {
133
- client: db,
134
- getTableConfig,
135
- relations,
136
- },
137
- });
112
+ `nodesOnConnection` adds the `nodes` convenience field used in these guides. Without it,
113
+ select `edges { node { ... } }` instead.
114
+
115
+ The [with-input plugin](https://pothos-graphql.dev/docs/plugins/with-input) is another optional integration. To use
116
+ `t.drizzleFieldWithInput`, install `@pothos/plugin-with-input`, import it, and add it to the
117
+ builder's plugins:
118
+
119
+ ```ts
120
+
121
+ // In the builder options:
122
+ plugins: [RelayPlugin, WithInputPlugin, DrizzlePlugin],
138
123
  ```
139
124
 
140
- #### Plugin options
125
+ ### Plugin options
141
126
 
142
127
  - `client`: the drizzle client, or a function returning one from the request context.
143
128
  - `getTableConfig`: the `getTableConfig` of your dialect, used to read primary keys and unique
@@ -151,10 +136,19 @@ const builder = new SchemaBuilder<PothosTypes>({
151
136
  by default, and the fragment's fields are loaded through [fallback queries](https://pothos-graphql.dev/docs/plugins/drizzle/relations#fallback-queries)
152
137
  when it resolves. Set this to `false` to plan deferred selections with the rest of the query.
153
138
 
139
+ `skipDeferredFragments` controls query planning; it does not enable incremental execution. The application
140
+ must register the defer directive and use an executor, server transport, and client that support
141
+ the same incremental delivery protocol. With GraphQL.js 17, ordinary `execute` rejects schemas
142
+ containing `@defer` or `@stream`; incremental execution uses `experimentalExecuteIncrementally`.
143
+ For a server integration, see [GraphQL Yoga's defer and stream setup](https://the-guild.dev/graphql/yoga-server/docs/features/defer-stream),
144
+ which uses `@graphql-yoga/plugin-defer-stream`. Check the integration's supported versions when
145
+ choosing an executor and client; adding directives or changing `skipDeferredFragments` alone is
146
+ not sufficient.
147
+
154
148
  ## Drizzle Objects
155
149
 
156
- Use the builder and database client from [Setup](https://pothos-graphql.dev/docs/plugins/drizzle/setup). The examples below use a `users` table
157
- with an integer `id` and string `firstName` and `lastName` columns.
150
+ Use the [configured builder](https://pothos-graphql.dev/docs/plugins/drizzle/setup) to map a Drizzle table to a GraphQL type. Defining a type
151
+ registers its fields; a root lookup makes them queryable.
158
152
 
159
153
  ### Defining Objects
160
154
 
@@ -175,156 +169,134 @@ You will be able to "expose" any column in the table, and GraphQL fields do not
175
169
  names of the columns in your database. The returned `User` can be used like any other `ObjectRef`
176
170
  in Pothos.
177
171
 
172
+ The example uses `builder.drizzleNode`, which adds a Relay ID and node refetching to the
173
+ same object API. See [Relay](https://pothos-graphql.dev/docs/plugins/drizzle/relay) for node IDs and refetching.
174
+
178
175
  ### Custom fields
179
176
 
180
- You will often want to define fields in your API that do not correspond to a specific database
181
- column. For example, replace the object definition above with a computed full name:
177
+ A GraphQL field can combine several columns. The author’s full name declares both columns in
178
+ its selection and computes the string from the loaded row:
182
179
 
183
- ```ts
184
- const User = builder.drizzleObject('users', {
185
- name: 'User',
186
- fields: (t) => ({
187
- fullName: t.string({
188
- resolve: (user, args, ctx, info) => `${user.firstName} ${user.lastName}`,
189
- }),
190
- }),
191
- });
180
+ ```typescript
181
+ fullName: t.string({
182
+ select: { columns: { firstName: true, lastName: true } },
183
+ resolve: (user) => `${user.firstName} ${user.lastName}`,
184
+ }),
192
185
  ```
193
186
 
187
+ This field belongs in User's `fields` callback. With the default type selection, all scalar
188
+ columns are available; with `select: {}`, computed fields must declare what they read.
189
+ [Selections](https://pothos-graphql.dev/docs/plugins/drizzle/selections) explains when those columns and relations are loaded.
190
+
194
191
  ### Drizzle Fields
195
192
 
196
- Drizzle objects and relations allow you to define parts of your schema backed by your drizzle
197
- schema, but don't provide a clear entry point into this Graph of data. To make your drizzle objects
198
- queryable, we will need to add fields that return our drizzle objects. This can be done using the
199
- `t.drizzleField` method. This can be used to define fields on the root `Query` type, or any other
200
- object type in your schema:
193
+ `t.drizzleField` can return a Drizzle object from Query or from any other object type. Its
194
+ resolver receives a `query` function before the usual resolver arguments. Call that function and
195
+ pass its result to `findFirst` or `findMany`: it merges your filters and ordering with the
196
+ requirements of the GraphQL selection.
201
197
 
202
- ```ts
198
+ #### A nullable author lookup
199
+
200
+ The author page accepts an integer ID. A missing author returns null, matching the result of
201
+ `findFirst` and the field's `nullable: true` option:
202
+
203
+ ```typescript
203
204
  builder.queryType({
204
205
  fields: (t) => ({
205
- user: t.drizzleField({
206
+ author: t.drizzleField({
206
207
  type: 'users',
207
208
  nullable: true,
208
- args: {
209
- id: t.arg.id({ required: true }),
210
- },
211
- resolve: (query, root, args, ctx) =>
212
- db.query.users.findFirst(
209
+ args: { id: t.arg.int({ required: true }) },
210
+ resolve: (query, _root, args) => {
211
+ return db.query.users.findFirst(
213
212
  query({
214
- where: {
215
- id: Number.parseInt(args.id, 10),
216
- },
213
+ where: { id: args.id },
217
214
  }),
218
- ),
219
- }),
220
- users: t.drizzleField({
221
- type: ['users'],
222
- resolve: (query, root, args, ctx) => db.query.users.findMany(query()),
215
+ );
216
+ },
223
217
  }),
224
218
  }),
225
219
  });
226
220
  ```
227
221
 
228
- The `resolve` function of a `drizzleField` receives a `query` function. Call it and pass its result
229
- to a Drizzle `findFirst` or `findMany` query. The `query` function optionally accepts any
230
- arguments that are normally passed into the query, and will merge these options with the selection
231
- used to resolve data for the nested GraphQL selections.
222
+ `author(id: 1) { fullName }` returns Maya Chen. `author(id: 999) { fullName }` returns null.
223
+ Adding `posts { title }` loads the public posts through the same root resolver.
224
+
225
+ For a list field, use `type: ['users']` and `db.query.users.findMany(query())`. A plain lookup
226
+ without arguments can pass no options to `query`; fields with filters pass those options to it.
232
227
 
233
228
  #### `drizzleFieldWithInput`
234
229
 
235
230
  With the [with-input plugin](https://pothos-graphql.dev/docs/plugins/with-input),
236
231
  `t.drizzleFieldWithInput` combines `t.drizzleField` with `t.fieldWithInput`. The `input` fields
237
232
  become an input object argument, and the resolver still receives the `query` function as its first
238
- argument. This replaces the `user` field above:
233
+ argument. This alternative adds an `authorWithInput` lookup to the same public User type.
234
+ Install and register WithInputPlugin as described in [Setup](https://pothos-graphql.dev/docs/plugins/drizzle/setup#integration-with-other-plugins):
239
235
 
240
236
  ```ts
241
237
  builder.queryFields((t) => ({
242
- user: t.drizzleFieldWithInput({
238
+ authorWithInput: t.drizzleFieldWithInput({
243
239
  type: 'users',
244
240
  nullable: true,
245
241
  input: {
246
- id: t.input.id({ required: true }),
242
+ id: t.input.int({ required: true }),
243
+ },
244
+ resolve: (query, root, args, ctx) => {
245
+ return db.query.users.findFirst(
246
+ query({
247
+ where: { id: args.input.id },
248
+ }),
249
+ );
247
250
  },
248
- resolve: (query, root, args, ctx) =>
249
- db.query.users.findFirst(query({ where: { id: Number.parseInt(args.input.id, 10) } })),
250
251
  }),
251
252
  }));
252
253
  ```
253
254
 
254
255
  ## Relations
255
256
 
256
- ### Relations
257
-
258
- Drizzles relational query builder allows you to define the relationships between your tables. The
259
- `t.relation` method makes it easy to add fields to your GraphQL API that implement those
260
- relations:
257
+ `t.relation` turns a Drizzle relation into a GraphQL field with its target type and cardinality.
258
+ Register the target GraphQL type as well as the relation.
261
259
 
262
- ```ts
263
- builder.drizzleObject('profiles', {
264
- name: 'Profile',
265
- fields: (t) => ({
266
- bio: t.exposeString('bio'),
267
- }),
268
- });
260
+ ### Published posts and profiles
269
261
 
270
- builder.drizzleObject('posts', {
271
- name: 'Post',
272
- fields: (t) => ({
273
- title: t.exposeString('title'),
274
- author: t.relation('author'),
275
- }),
276
- });
262
+ The public author page returns published posts. Drafts belong on the [private Viewer](https://pothos-graphql.dev/docs/plugins/drizzle/variants),
263
+ so the filter belongs on the relation wherever User can be reached:
277
264
 
278
- builder.drizzleObject('users', {
279
- name: 'User',
280
- fields: (t) => ({
281
- firstName: t.exposeString('firstName'),
282
- profile: t.relation('profile'),
283
- posts: t.relation('posts'),
265
+ ```typescript
266
+ posts: t.relation('posts', {
267
+ args: { oldestFirst: t.arg.boolean() },
268
+ query: (args) => ({
269
+ where: { published: true },
270
+ orderBy: {
271
+ createdAt: args.oldestFirst ? 'asc' : 'desc',
272
+ id: args.oldestFirst ? 'asc' : 'desc',
273
+ },
284
274
  }),
285
- });
275
+ }),
286
276
  ```
287
277
 
288
- The relation will automatically define GraphQL fields of the appropriate type based on the relation
289
- defined in your drizzle schema.
278
+ The filter applies whenever this User type is queried. An author with no matching posts returns
279
+ an empty list. The `oldestFirst` argument selects the ordering.
280
+
281
+ To return the Profile object, register its GraphQL type and use `t.relation('profile')`. To
282
+ return only its nullable biography, use a [field selection](https://pothos-graphql.dev/docs/plugins/drizzle/selections#only-load-a-profile-when-requested).
290
283
 
291
284
  ### Relation queries
292
285
 
293
- For some cases, exposing relations as fields without any customization works great, but in some
294
- cases you may want to apply some filtering or ordering to your relations. This can be done by
295
- specifying a `query` option on the relation:
286
+ `query` accepts a static object or a callback that converts field arguments into Drizzle query
287
+ options. Alongside filters and ordering, a relation may use `limit` and `offset`. For example,
288
+ this alternative adds offset pagination to the public posts field:
296
289
 
297
290
  ```ts
298
- builder.drizzleObject('users', {
299
- name: 'User',
300
- fields: (t) => ({
301
- firstName: t.exposeString('firstName'),
302
- posts: t.relation('posts', {
303
- args: {
304
- limit: t.arg.int(),
305
- offset: t.arg.int(),
306
- },
307
- // query callback receives (args, ctx, pathInfo)
308
- query: (args) => ({
309
- limit: args.limit ?? 10,
310
- offset: args.offset ?? 0,
311
- where: {
312
- published: true,
313
- },
314
- orderBy: {
315
- updatedAt: 'desc',
316
- },
317
- }),
318
- }),
319
- drafts: t.relation('posts', {
320
- query: {
321
- where: {
322
- published: false,
323
- },
324
- },
325
- }),
291
+ posts: t.relation('posts', {
292
+ args: { limit: t.arg.int(), offset: t.arg.int() },
293
+ query: (args) => ({
294
+ limit: args.limit ?? 10,
295
+ offset: args.offset ?? 0,
296
+ where: { published: true },
297
+ orderBy: { createdAt: 'desc', id: 'desc' },
326
298
  }),
327
- });
299
+ }),
328
300
  ```
329
301
 
330
302
  The query API enables you to define args and convert them into parameters that will be passed into
@@ -358,114 +330,180 @@ not return a row for a parent, because it was deleted since it was loaded, or ne
358
330
  table, that field rejects with `Model users(1) not found`, where the value in parentheses is the
359
331
  key that was looked up.
360
332
 
333
+ The [alias query](https://pothos-graphql.dev/docs/plugins/drizzle/query-planning#compare-two-orderings-of-one-relation) demonstrates
334
+ this fallback: newest and oldest cannot share one loaded posts list.
335
+
336
+ ### Related count
337
+
338
+ An author’s count must use the same publication filter as the list. Otherwise the number can
339
+ reveal that unpublished posts exist:
340
+
341
+ ```typescript
342
+ postCount: t.relatedCount('posts', { where: eq(posts.published, true) }),
343
+ ```
344
+
345
+ `t.relatedCount` returns the number of related rows without loading them. Without a `where`,
346
+ it counts all rows in the relation.
347
+
348
+ The `where` option accepts either a static SQL filter or a function that receives the field arguments
349
+ and context:
350
+
351
+ ```ts
352
+ import { and, eq } from 'drizzle-orm';
353
+ import { posts } from './tables';
354
+
355
+ // In the User fields callback:
356
+ publishedPostsCount: t.relatedCount('posts', {
357
+ args: {
358
+ title: t.arg.string(),
359
+ },
360
+ where: (args, ctx) => {
361
+ if (args.title) {
362
+ return and(eq(posts.published, true), eq(posts.title, args.title));
363
+ }
364
+
365
+ return eq(posts.published, true);
366
+ },
367
+ });
368
+ ```
369
+
370
+ For a many-to-many relation (one defined with `.through(...)`), `t.relatedCount` counts distinct
371
+ related rows, so a row reachable through two junction rows counts once. A `t.relatedConnection`'s
372
+ `totalCount` counts the rows the connection pages over instead, which is one per junction row,
373
+ since that is what the relational query builder returns for the relation.
374
+
375
+
376
+ ### Many-to-many relations
377
+
378
+ A post exposes attached Media objects even though the database stores an attachment row. The
379
+ `through` relation handles that join; Pothos exposes its target like any other relation:
380
+
381
+ ```typescript
382
+ builder.drizzleObject('posts', {
383
+ name: 'Post',
384
+ select: {},
385
+ fields: (t) => ({
386
+ id: t.exposeID('id'),
387
+ title: t.exposeString('title'),
388
+ author: t.relation('author'),
389
+ media: t.relation('media'),
390
+ mediaConnection: t.relatedConnection('media', {
391
+ query: { orderBy: { id: 'asc' } },
392
+ totalCount: true,
393
+ }),
394
+ }),
395
+ });
396
+ export const Media = builder.drizzleObject('media', {
397
+ name: 'Media',
398
+ fields: (t) => ({ url: t.exposeString('url'), uploadedBy: t.relation('uploadedBy') }),
399
+ });
400
+ ```
401
+
402
+ `t.relation` and `t.relatedConnection` expose the Media type directly; no GraphQL type for the
403
+ junction table is needed.
404
+
361
405
  ### Related field
362
406
 
363
407
  The `t.relatedField` method allows you to define a field based on a relation that uses custom
364
408
  selections, including aggregations like counts. This is useful when you want to expose derived data
365
409
  from a relation without loading the full related records.
366
410
 
367
- #### Count aggregations
368
-
369
- One common use case is adding a count field that efficiently counts related records:
411
+ For a simple count, prefer `t.relatedCount`. Its equivalent using `t.relatedField` illustrates
412
+ how `buildFilter` restricts an expression to the parent's related rows:
370
413
 
371
414
  ```ts
372
- import { count } from 'drizzle-orm';
415
+ import { and, eq } from 'drizzle-orm';
416
+ import { posts } from './tables';
373
417
 
374
418
  builder.drizzleNode('users', {
375
419
  name: 'User',
376
420
  id: { column: (user) => user.id },
377
421
  fields: (t) => ({
378
422
  firstName: t.exposeString('firstName'),
379
- // Add a count of related posts
423
+ // Count only this author’s published posts
380
424
  postsCount: t.relatedField('posts', {
381
425
  type: 'Int',
382
426
  // buildFilter creates the correct WHERE clause for the relation
383
- select: (buildFilter) => ({
384
- extras: {
385
- postsCount: (parent) => db.$count(posts, buildFilter(parent)),
386
- },
387
- }),
427
+ select: (buildFilter) => {
428
+ return {
429
+ extras: {
430
+ postsCount: (parent) => {
431
+ return db.$count(
432
+ posts,
433
+ and(buildFilter(parent), eq(posts.published, true)),
434
+ );
435
+ },
436
+ },
437
+ };
438
+ },
388
439
  resolve: (user) => user.postsCount,
389
440
  }),
390
441
  }),
391
442
  });
392
443
  ```
393
444
 
394
- The `buildFilter` function passed to `select` generates the appropriate SQL filter based on the
395
- relation definition. This is no different than using `t.field`, but the `buildFilter` helper makes
396
- it easier to filter for the related records.
445
+ `buildFilter(parent)` includes the relation's join conditions and any filter defined on the
446
+ relation. Combine it with the conditions your field needs, then return the selected value from
447
+ `resolve`. Use this helper for custom expressions that `t.relatedCount` does not cover.
397
448
 
398
449
  `t.relatedField` also accepts the normal field options (`description`, `deprecationReason`,
399
450
  `extensions`, and options added by other plugins like `authScopes`). Its `resolve` may be async,
400
451
  and receives the resolve `info` as its fourth argument.
401
452
 
402
- #### SQLite many-to-many filters
453
+ ## Selections
403
454
 
404
- On SQLite, `buildFilter` can look up related target identities through the junction join when the
405
- target has a non-null unique key. PostgreSQL retains an `EXISTS` predicate to avoid an additional
406
- target join.
455
+ Selections describe the database data a GraphQL field needs. The example User starts with
456
+ `select: {}` so requesting one field does not load unrelated columns or a profile.
407
457
 
408
- For a custom `RAW` scope in a Drizzle relation definition, use the table supplied to the callback:
458
+ ### Field selections
409
459
 
410
- ```ts
411
- where: {
412
- RAW: (target) => sql`${target.published} = true`,
413
- },
414
- ```
460
+ An exposed column, such as `t.exposeString('firstName')`, contributes its own selection. A custom
461
+ resolver must declare the columns it reads:
415
462
 
416
- This lets Drizzle use the target's alias inside the lookup. A static SQL scope referencing the
417
- original target table, such as ``RAW: sql`${posts.published} = true` ``, is passed through unchanged
418
- and can still force SQLite to scan the target table. The same applies to a callback that ignores
419
- its table argument and references `posts` directly. Object filters such as
420
- `where: { published: true }` also use the supplied alias.
463
+ ```typescript
464
+ fullName: t.string({
465
+ select: { columns: { firstName: true, lastName: true } },
466
+ resolve: (user) => `${user.firstName} ${user.lastName}`,
467
+ }),
468
+ ```
421
469
 
422
- ### Related count
470
+ ### Only load a profile when requested
423
471
 
424
- For the common case of counting related records, there's a simpler `t.relatedCount` method that handles
425
- all the boilerplate for you:
472
+ A biography is a string in GraphQL, but it comes from the optional profile relation. The field
473
+ selects that relation only when requested:
426
474
 
427
- ```ts
428
- builder.drizzleNode('users', {
429
- name: 'User',
430
- id: { column: (user) => user.id },
431
- fields: (t) => ({
432
- firstName: t.exposeString('firstName'),
433
- // Simple count of all related comments
434
- commentsCount: t.relatedCount('comments'),
435
- // Count with a where filter
436
- publishedPostsCount: t.relatedCount('posts', {
437
- where: eq(posts.published, true),
438
- }),
439
- }),
440
- });
475
+ ```typescript
476
+ bio: t.string({
477
+ nullable: true,
478
+ select: { with: { profile: true } },
479
+ resolve: (user) => user.profile?.bio,
480
+ }),
441
481
  ```
442
482
 
443
- The `where` option accepts either a static SQL filter or a function that receives the field arguments
444
- and context:
483
+ A missing profile returns null. A name-only query does not load the profile; adding `bio` adds
484
+ the relation to the database query.
445
485
 
446
- ```ts
447
- publishedPostsCount: t.relatedCount('posts', {
448
- args: {
449
- category: t.arg.string(),
450
- },
451
- where: (args, ctx) => args.category
452
- ? and(eq(posts.published, true), eq(posts.category, args.category))
453
- : eq(posts.published, true),
454
- });
455
- ```
486
+ ### Computed SQL values
456
487
 
457
- For a many-to-many relation (one defined with `.through(...)`), `t.relatedCount` counts distinct
458
- related rows, so a row reachable through two junction rows counts once. A `t.relatedConnection`'s
459
- `totalCount` counts the rows the connection pages over instead, which is one per junction row,
460
- since that is what the relational query builder returns for the relation.
488
+ The same mechanism supports SQL expressions through `extras`. For example, a field can select
489
+ `lowercaseName` only when the GraphQL operation requests it:
461
490
 
462
- ## Selections
491
+ ```typescript
492
+ lowercaseName: t.string({
493
+ select: {
494
+ extras: {
495
+ lowercaseName: (users, { sql }) => sql<string>`lower(${users.firstName})`,
496
+ },
497
+ },
498
+ resolve: (user) => user.lowercaseName,
499
+ }),
500
+ ```
463
501
 
464
502
  ### Type selections
465
503
 
466
- By default, a `drizzleObject` gives its resolvers access to all columns of the table. For tables
467
- with many columns, it can be more efficient to only select the needed columns. You can configure the
468
- selected columns, and relations by passing a `select` option when defining the type:
504
+ By default, a `drizzleObject` gives its resolvers access to all columns of the table. A type-level
505
+ `select` replaces that default and makes its selected data available to every field resolver.
506
+ This alternative always loads the name, profile, and expression:
469
507
 
470
508
  ```ts
471
509
  const User = builder.drizzleObject('users', {
@@ -482,452 +520,189 @@ const User = builder.drizzleObject('users', {
482
520
  lowercaseName: (users, { sql }) => sql<string>`lower(${users.firstName})`
483
521
  },
484
522
  },
485
- fields: (t) => ({
486
- fullName: t.string({
487
- resolve: (user, args, ctx, info) => `${user.firstName} ${user.lastName}`,
488
- }),
489
- bio: t.string({
490
- nullable: true,
491
- resolve: (user) => user.profile?.bio,
492
- }),
493
- lowercaseName: t.string({
494
- resolve: (user) => user.lowercaseName,
495
- }),
496
- }),
523
+ fields: (t) => {
524
+ return {
525
+ fullName: t.string({
526
+ resolve: (user, args, ctx, info) => `${user.firstName} ${user.lastName}`,
527
+ }),
528
+ bio: t.string({
529
+ nullable: true,
530
+ resolve: (user) => user.profile?.bio,
531
+ }),
532
+ lowercaseName: t.string({
533
+ resolve: (user) => user.lowercaseName,
534
+ }),
535
+ };
536
+ },
497
537
  });
498
538
  ```
499
539
 
500
540
  Any selections added to the type will be available to consume in all resolvers. Columns that are not
501
541
  selected can still be exposed as before.
502
542
 
503
- ### Field selections
504
-
505
- The previous example allows you to control what gets selected by default, but you often want to only
506
- select the columns that are required to fulfill a specific field. You can do this by adding the
507
- appropriate selections on each field. Replace the preceding User definition with:
508
-
509
- ```ts
510
- const User = builder.drizzleObject('users', {
511
- name: 'User',
512
- select: {},
513
- fields: (t) => ({
514
- fullName: t.string({
515
- select: {
516
- columns: { firstName: true, lastName: true },
517
- },
518
- resolve: (user, args, ctx, info) => `${user.firstName} ${user.lastName}`,
519
- }),
520
- bio: t.string({
521
- nullable: true,
522
- select: {
523
- with: { profile: true },
524
- },
525
- resolve: (user) => user.profile?.bio,
526
- }),
527
- lowercaseName: t.string({
528
- select: {
529
- extras: {
530
- lowercaseName: (users, { sql }) => sql<string>`lower(${users.firstName})`
531
- },
532
- },
533
- resolve: (user) => user.lowercaseName,
534
- }),
535
- }),
536
- });
537
- ```
543
+ Use type selections for data required whenever the type is loaded, and field selections for data
544
+ needed by a particular field. Both are merged into the parent query; they do not make each field
545
+ an independent database query. See [Query planning](https://pothos-graphql.dev/docs/plugins/drizzle/query-planning).
538
546
 
539
547
  ## Connections
540
548
 
541
- Use the [builder setup with Relay](https://pothos-graphql.dev/docs/plugins/drizzle/setup#integration-with-other-plugins) and register the users
542
- and posts object types. Examples defining the same field or helper are alternatives.
543
-
544
- ### Related connections
545
-
546
- To implement a relation as a connection, you can use `t.relatedConnection` instead of `t.relation`:
547
-
548
- ```ts
549
- builder.drizzleNode('users', {
550
- name: 'User',
551
- id: { column: (user) => user.id },
552
- fields: (t) => ({
553
- posts: t.relatedConnection('posts'),
554
- }),
555
- });
556
- ```
557
-
558
- This will automatically define the `Connection`, and `Edge` types, and their respective fields. To
559
- customize the Connection and Edge types, options for these types can be passed as additional
560
- arguments to `t.relatedConnection`, just like `t.connection` from the relay plugin. See the
561
- [relay plugin docs](https://pothos-graphql.dev/docs/plugins/relay) for more details.
549
+ Connection fields combine cursor pagination with Pothos's selection planning. Define the rows a
550
+ field may return; Pothos adds pagination constraints and selects the columns and relations
551
+ requested under each node. The same field supports forward and backward pagination.
562
552
 
563
- You can also define a `query` like with `t.relation`. The only difference with `t.relatedConnection`
564
- is that the `orderBy` format is slightly changed.
553
+ Use `t.drizzleConnection` when your resolver queries the rows and `t.relatedConnection` for a
554
+ Drizzle relation. Both require the [Relay plugin](https://pothos-graphql.dev/docs/plugins/drizzle/setup#integration-with-other-plugins).
565
555
 
566
- To comply with the relay spec and efficiently support backwards pagination, some queries need to be
567
- performed in reverse order, which requires inverting the orderBy clause. To do this automatically,
568
- the `t.relatedConnection` method accepts orderBy as an object keyed by column name with `'asc'` or
569
- `'desc'` values, like `{ createdAt: 'desc' }`, rather than using the `asc(column)` and
570
- `desc(column)` helpers from drizzle. orderBy can also be returned as a single column, or an array of
571
- columns when ordering by multiple columns, which orders ascending.
556
+ ### Page through published posts
572
557
 
573
- Ordering defaults to using the table `primaryKey`, and the orderBy columns will also be used to
574
- derive the connections cursor.
558
+ The feed returns published posts ordered by creation time. Its resolver passes the result of
559
+ `query()` to Drizzle, just as a `t.drizzleField` does:
575
560
 
576
- ```ts
577
- builder.drizzleNode('users', {
578
- name: 'User',
579
- id: { column: (user) => user.id },
580
- fields: (t) => ({
581
- posts: t.relatedConnection('posts', {
582
- query: () => ({
583
- where: {
584
- published: true,
585
- },
586
- orderBy: {
587
- id: 'desc',
588
- },
561
+ ```typescript
562
+ posts: t.drizzleConnection({
563
+ type: 'posts',
564
+ totalCount: () => {
565
+ return db.$count(posts, eq(posts.published, true));
566
+ },
567
+ resolve: (query) => {
568
+ return db.query.posts.findMany(
569
+ query({
570
+ where: { published: true },
571
+ // Three posts share this timestamp. Pothos adds the primary key
572
+ // to the cursor ordering, so traversing pages still visits each once.
573
+ orderBy: { createdAt: 'desc' },
589
574
  }),
590
- }),
591
- }),
592
- });
575
+ );
576
+ },
577
+ }),
593
578
  ```
594
579
 
595
- #### Connection totalCount
596
-
597
- You can add a `totalCount` field to your connection by setting the `totalCount` option to `true`:
598
-
599
- ```ts
600
- builder.drizzleNode('users', {
601
- name: 'User',
602
- id: { column: (user) => user.id },
603
- fields: (t) => ({
604
- posts: t.relatedConnection('posts', {
605
- totalCount: true,
606
- query: () => ({
607
- where: {
608
- published: true,
609
- },
610
- orderBy: {
611
- id: 'desc',
612
- },
613
- }),
614
- }),
615
- }),
616
- });
617
- ```
580
+ Pothos uses the selected node fields to plan the database query, including the nested author.
581
+ It also adds the ordering columns needed for cursors, even when the client does not select them.
582
+ When creation times tie, Pothos appends a primary-key tie breaker so posts have distinct positions.
583
+ See [Ordering and cursors](https://pothos-graphql.dev/docs/plugins/drizzle/ordering-and-cursors) for other orderings and column requirements.
618
584
 
619
- This will automatically add a `totalCount` field to the connection type. The count query is only
620
- executed when the `totalCount` field is actually requested in the GraphQL query, and it's included
621
- as a subquery in the main database query for efficiency.
585
+ A client requests the first page with `first`:
622
586
 
623
587
  ```graphql
624
- query {
625
- user(id: "...") {
626
- posts(first: 10) {
627
- totalCount
628
- edges {
629
- node {
630
- id
631
- title
588
+ query PostFeed($after: String) {
589
+ posts(first: 2, after: $after) {
590
+ totalCount
591
+ edges {
592
+ cursor
593
+ node {
594
+ title
595
+ author {
596
+ fullName
632
597
  }
633
598
  }
634
599
  }
600
+ pageInfo {
601
+ endCursor
602
+ hasNextPage
603
+ }
635
604
  }
636
605
  }
637
606
  ```
638
607
 
639
- The count applies the `where` returned by the field's `query`, so it counts the same rows the
640
- connection paginates (only published posts in the example above). To count every related row
641
- regardless of the filter, set `filterConnectionTotalCount: false` in the `drizzle` plugin options:
642
-
643
- ```ts
644
- const builder = new SchemaBuilder<PothosTypes>({
645
- plugins: [RelayPlugin, DrizzlePlugin],
646
- drizzle: {
647
- client: db,
648
- getTableConfig,
649
- relations,
650
- // count every related row for totalCount, ignoring the where from query (defaults to true)
651
- filterConnectionTotalCount: false,
652
- },
653
- });
654
- ```
655
-
656
- A `where` on the relation itself always applies to the count, as does the junction table of a
657
- many-to-many relation defined with `.through(...)`. The count joins the junction table the same way
658
- the rows do, so a row that matches the junction twice counts twice, and appears twice in the
659
- connection.
660
-
661
- ### Drizzle connections
662
-
663
- Similar to `t.drizzleField`, `t.drizzleConnection` allows you to define a connection field that acts
664
- as an entry point to your drizzle query. The `orderBy` in `t.drizzleConnection` works the same way
665
- as it does for `t.relatedConnection`
666
-
667
- ```ts
668
- builder.queryFields((t) => ({
669
- posts: t.drizzleConnection({
670
- type: 'posts',
671
- resolve: (query, root, args, ctx) =>
672
- db.query.posts.findMany(
673
- query({
674
- where: {
675
- published: true,
676
- },
677
- orderBy: {
678
- id: 'desc',
679
- },
680
- }),
681
- ),
682
- }),
683
- }));
684
- ```
685
-
686
- #### drizzleConnection totalCount
687
-
688
- You can add a `totalCount` field to a `drizzleConnection` by providing a `totalCount` callback function that returns the count:
689
-
690
- ```ts
691
- builder.queryFields((t) => ({
692
- posts: t.drizzleConnection({
693
- type: 'posts',
694
- // Use db.$count() for a simple count query
695
- totalCount: () => db.$count(posts, eq(posts.published, true)),
696
- resolve: (query, root, args, ctx) =>
697
- db.query.posts.findMany(
698
- query({
699
- where: {
700
- published: true,
701
- },
702
- orderBy: {
703
- id: 'desc',
704
- },
705
- }),
706
- ),
707
- }),
708
- }));
709
- ```
710
-
711
- The `totalCount` callback receives the same arguments as a normal resolver (`parent`, `args`, `context`, `info`), allowing you to implement custom count logic based on the query context. The example above uses `db.$count()` for a simple count, but you can use any Drizzle query approach.
712
-
713
- When only the `totalCount` field is requested (without `edges` or `nodes`), the main query is skipped entirely and only the count query is executed for efficiency.
714
-
715
- #### Indirect relations as connections
716
-
717
- In many cases, you can define many to many connections via drizzle relations, allowing the `relatedConnection` API to work across
718
- more complex relations. In some cases you may want to define a connection for a relation not expressed directly as a relation in
719
- your drizzle schema. For these cases, you can use the `drizzleConnectionHelpers`, which allows you to define connection with the `t.connection` API.
720
-
721
- ```typescript
722
- // Create a drizzle object for the node type of your connection
723
- const Role = builder.drizzleObject('roles', {
724
- name: 'Role',
725
- fields: (t) => ({
726
- id: t.exposeID('id'),
727
- name: t.exposeString('name'),
728
- }),
729
- });
730
-
731
-
732
-
733
- // Create connection helpers for the userRoles join table. This will allow you
734
- // to use the normal t.connection with a drizzle type
735
- const rolesConnection = drizzleConnectionHelpers(builder, 'userRoles', {
736
- // select the data needed for the nodes
737
- select: (nestedSelection) => ({
738
- with: {
739
- // use nestedSelection to create the correct selection for the node
740
- role: nestedSelection(),
741
- },
742
- }),
743
- // resolve the node from the returned list item
744
- resolveNode: (userRole) => userRole.role,
745
- });
746
-
747
- builder.drizzleObjectField('users', 'rolesConnection', (t) =>
748
- t.connection({
749
- // The type for the Node
750
- type: Role,
751
- nodeNullable: true,
752
- // since we are not using t.relatedConnection we need to manually
753
- // include the selections for our connection
754
- select: (args, ctx, nestedSelection) => ({
755
- with: {
756
- userRoles: rolesConnection.getQuery(args, ctx, nestedSelection),
757
- },
758
- }),
759
- // This helper takes a list of nodes and formats them for the connection
760
- resolve: (user, args, ctx) => {
761
- return rolesConnection.resolve(user.userRoles, args, ctx, user);
762
- },
763
- }),
764
- );
765
- ```
766
-
767
- The above example assumes that you are paginating a relation to a join table, where the pagination
768
- args are applied based on the relation to that join table, but the nodes themselves are nested
769
- deeper.
770
-
771
- `drizzleConnectionHelpers` can also be used to manually create a connection where the edge and
772
- connections share the same model, and pagination happens directly on a relation to nodes type (even
773
- if that relation is nested).
774
-
775
- ```ts
776
- const commentConnectionHelpers = drizzleConnectionHelpers(builder, 'comments');
777
-
778
- const SelectPost = builder.drizzleObject('posts', {
779
- fields: (t) => ({
780
- title: t.exposeString('title'),
781
- comments: t.connection({
782
- type: commentConnectionHelpers.ref,
783
- select: (args, ctx, nestedSelection) => ({
784
- with: {
785
- comments: commentConnectionHelpers.getQuery(args, ctx, nestedSelection),
786
- },
787
- }),
788
- resolve: (parent, args, ctx) => commentConnectionHelpers.resolve(parent.comments, args, ctx),
789
- }),
790
- }),
791
- });
792
- ```
793
-
794
- Replace the preceding `rolesConnection` helper and field to add filtering and ordering:
608
+ For the next page, pass the previous `endCursor` as `after`. To paginate backward, use `last`
609
+ and `before`, taking `before` from a page's `startCursor`:
795
610
 
796
- ```ts
797
- const rolesConnection = drizzleConnectionHelpers(builder, 'userRoles', {
798
- // define additional arguments
799
- args: (t) => ({}),
800
- query: (args) => ({
801
- // define an order
802
- orderBy: {
803
- roleId: 'asc',
804
- },
805
- // define a filter
806
- where: {
807
- accepted: true,
611
+ ```graphql
612
+ query PreviousPosts($before: String) {
613
+ posts(last: 2, before: $before) {
614
+ nodes {
615
+ title
808
616
  }
809
- }),
810
- // select the data needed for the nodes
811
- select: (nestedSelection) => ({
812
- with: {
813
- // use nestedSelection to create the correct selection for the node
814
- role: nestedSelection(),
815
- },
816
- }),
817
- // resolve the node from the returned list item
818
- resolveNode: (userRole) => userRole.role,
819
- });
820
-
821
-
822
- builder.drizzleObjectField('users', 'rolesConnection', (t) =>
823
- t.connection({
824
- type: Role,
825
- nodeNullable: true,
826
- // add the args from the connection helper to the field
827
- args: rolesConnection.getArgs(),
828
- select: (args, ctx, nestedSelection) => ({
829
- with: {
830
- userRoles: rolesConnection.getQuery(args, ctx, nestedSelection),
831
- },
832
- }),
833
- resolve: (user, args, ctx) => rolesConnection.resolve(user.userRoles, args, ctx, user),
834
- }),
835
- );
617
+ pageInfo {
618
+ startCursor
619
+ hasPreviousPage
620
+ }
621
+ }
622
+ }
836
623
  ```
837
624
 
838
- #### Extending connection edges
625
+ Backward pagination retains the connection's declared ordering; it selects the preceding rows
626
+ rather than reversing the response. `nodes` is a convenience field enabled by
627
+ `relay: { nodesOnConnection: true }`. With the default Relay configuration, use `edges { node }`.
839
628
 
840
- This alternative exposes the join row’s `createdAt` on each edge. It assumes a registered
841
- `DateTime` scalar whose output type is `Date`.
629
+ ### Related connections
842
630
 
843
- ```typescript
844
- const rolesConnection = drizzleConnectionHelpers(builder, 'userRoles', {
845
- select: (nestedSelection) => ({
846
- with: {
847
- role: nestedSelection(),
848
- },
849
- }),
850
- resolveNode: (userRole) => userRole.role,
851
- });
631
+ An author's `postsConnection` applies the same publication filter as the feed. `t.relatedConnection`
632
+ plans that relation from the parent selection without a custom resolver:
852
633
 
853
- builder.drizzleObjectFields('users', (t) => ({
854
- rolesConnection: t.connection(
855
- {
856
- type: Role,
857
- nodeNullable: true,
858
- select: (args, ctx, nestedSelection) => ({
859
- with: {
860
- userRoles: rolesConnection.getQuery(args, ctx, nestedSelection),
861
- },
862
- }),
863
- resolve: (user, args, ctx) =>
864
- rolesConnection.resolve(
865
- user.userRoles,
866
- args,
867
- ctx,
868
- user,
869
- ),
870
- },
871
- {},
872
- // options for the edge object
873
- {
874
- // define the additional fields on the edge object
875
- fields: (edge) => ({
876
- createdAt: edge.field({
877
- type: 'DateTime',
878
- // the parent shape for edge fields is inferred from the connections resolve function
879
- resolve: (role) => role.createdAt,
880
- }),
881
- }),
882
- },
883
- ),
884
- }));
634
+ ```typescript
635
+ postsConnection: t.relatedConnection('posts', {
636
+ query: { where: { published: true }, orderBy: { createdAt: 'desc' } },
637
+ totalCount: true,
638
+ }),
885
639
  ```
886
640
 
887
- #### `drizzleConnectionHelpers` for non-relation connections
641
+ The `query` option accepts an object or a callback receiving field arguments and context, as it
642
+ does on `t.relation`. Connection `orderBy` accepts column names with `'asc'` or `'desc'`, or a
643
+ column or array of columns for ascending order. Pothos can invert these orderings when fetching
644
+ a backward page. The default ordering uses the table's primary key.
888
645
 
889
- You can also use `drizzleConnectionHelpers` for non-relation connections where you want a connection where your edges and nodes are not the same type.
646
+ Both connection methods create Connection and Edge types. Pass additional options to customize
647
+ those types, as with the [Relay plugin's connection fields](https://pothos-graphql.dev/docs/plugins/relay).
890
648
 
891
- Note that when doing this, you need to be careful to properly merge the `where` clause generated by the connection helper with any additional `where` clause you need to apply to your query
649
+ ### Connection totalCount
892
650
 
893
- ```typescript
894
- const rolesConnection = drizzleConnectionHelpers(builder, 'userRoles', {
895
- select: (nestedSelection) => ({
896
- with: {
897
- role: nestedSelection(),
898
- },
899
- }),
900
- resolveNode: (userRole) => userRole.role,
901
- });
651
+ `totalCount` describes the matching rows across all pages. The author's `totalCount: true`
652
+ counts both published posts even when `first: 1` returns only one. Pothos loads the count only
653
+ when it is selected; a query for only `totalCount` does not load the related posts.
902
654
 
903
- builder.queryFields((t) => ({
904
- roles: t.connection({
905
- type: Role,
906
- nodeNullable: true,
907
- args: {
908
- userId: t.arg.int({ required: true }),
909
- },
910
- resolve: async (_, args, ctx, info) => {
911
- const query = rolesConnection.getQuery(args, ctx, info);
912
- const userRoles = await db.query.userRoles.findMany({
913
- ...query,
914
- where: {
915
- AND: [query.where ?? {}, { userId: args.userId }],
916
- },
917
- });
918
- return rolesConnection.resolve(userRoles, args, ctx);
919
- },
920
- }),
921
- }));
922
- ```
655
+ For `t.relatedConnection`, Pothos derives the count from the relation and its filter. For a root
656
+ `t.drizzleConnection`, supply a `totalCount` resolver, as the feed does above. Its filter must
657
+ match the rows returned by the connection. This resolver receives the usual
658
+ `parent`, `args`, `context`, and `info` arguments. A count-only root query skips the main resolver.
923
659
 
924
- ## Ordering and cursors
660
+ By default, a related count includes the `where` returned by the connection's `query`. Setting
661
+ `filterConnectionTotalCount: false` in the builder's `drizzle` options ignores that field filter
662
+ for counts. A filter on the Drizzle relation itself still applies. Keep the default for the
663
+ public author field: counting drafts would reveal data excluded by its publication filter.
664
+
665
+ For a many-to-many relation defined with `through`, the count matches the rows being paginated.
666
+ If two junction rows reach the same target, that target appears and is counted twice. This differs
667
+ from [`t.relatedCount`](https://pothos-graphql.dev/docs/plugins/drizzle/relations#related-count), which counts distinct related targets.
668
+
669
+ ### Connections with custom edges
670
+
671
+ A direct many-to-many relation works with `t.relatedConnection`. When the attachment itself has
672
+ fields, such as a caption, the edge needs data from the junction row while its node represents
673
+ Media. [Connection helpers](https://pothos-graphql.dev/docs/plugins/drizzle/connection-helpers) shows how to combine that mapping with selection
674
+ planning, pagination, and custom edge fields in one connection.
925
675
 
926
- ### Ordering and cursors
676
+ ## Ordering and cursors
927
677
 
928
678
  Connections page with a cursor that records where in the ordering the previous page ended. This
929
679
  applies to `t.relatedConnection`, `t.drizzleConnection`, and `drizzleConnectionHelpers`.
930
680
 
681
+ ### Tied timestamps in a feed
682
+
683
+ The [post connection](https://pothos-graphql.dev/docs/plugins/drizzle/connections#page-through-published-posts) orders by `createdAt`, which
684
+ can be identical for several posts. Pothos adds a primary-key tie breaker so each post has a
685
+ distinct position in the connection.
686
+
687
+ ```typescript
688
+ posts: t.drizzleConnection({
689
+ type: 'posts',
690
+ totalCount: () => {
691
+ return db.$count(posts, eq(posts.published, true));
692
+ },
693
+ resolve: (query) => {
694
+ return db.query.posts.findMany(
695
+ query({
696
+ where: { published: true },
697
+ // Three posts share this timestamp. Pothos adds the primary key
698
+ // to the cursor ordering, so traversing pages still visits each once.
699
+ orderBy: { createdAt: 'desc' },
700
+ }),
701
+ );
702
+ },
703
+ }),
704
+ ```
705
+
931
706
  #### Unique orderings
932
707
 
933
708
  `orderBy: { createdAt: 'desc' }` does not describe a unique ordering. Rows with the same timestamp
@@ -953,9 +728,9 @@ all marked `notNull()`, are used as provided. Nullable unique columns are not tr
953
728
  because rows containing a `null` are still tied with each other. Uniqueness declared with
954
729
  `uniqueIndex()` is not detected, so those orderings still get the primary key appended.
955
730
 
956
- Primary keys are used whether or not their columns are marked `notNull()`, since SQL makes primary
957
- key columns non-nullable anyway. That includes composite keys declared with
958
- `primaryKey({ columns: [...] })`.
731
+ Pothos also recognizes composite primary keys declared with `primaryKey({ columns: [...] })`.
732
+ Primary-key columns are treated as non-null for ordering; ensure your database enforces that
733
+ constraint.
959
734
 
960
735
  Nothing is appended when Pothos cannot find a key it can rely on, which means tables with no primary
961
736
  key, and tables whose only unique column is nullable. Those connections keep the ordering you wrote,
@@ -1004,8 +779,8 @@ mapping:
1004
779
  builder.queryFields((t) => ({
1005
780
  posts: t.drizzleConnection({
1006
781
  type: 'posts',
1007
- resolve: (query) =>
1008
- db.query.posts.findMany(
782
+ resolve: (query) => {
783
+ return db.query.posts.findMany(
1009
784
  query({
1010
785
  extras: {
1011
786
  createdAtExact: (table) =>
@@ -1013,7 +788,8 @@ builder.queryFields((t) => ({
1013
788
  },
1014
789
  orderBy: { createdAtExact: 'desc' },
1015
790
  }),
1016
- ),
791
+ );
792
+ },
1017
793
  }),
1018
794
  }));
1019
795
  ```
@@ -1041,172 +817,250 @@ The expression also needs to sort in the same order its values compare. A timest
1041
817
  fixed width UTC text sorts the same way it does chronologically, but a local time or variable width
1042
818
  format does not, and will skip rows.
1043
819
 
1044
- Ordering by an expression cannot use an index on the underlying column. If the table is large enough
1045
- to need one, add an index on the expression.
1046
-
1047
- ## Relay
820
+ ## Relay nodes
1048
821
 
1049
- ### Relay integration
822
+ The Relay plugin supplies node IDs and cursor connections. Register it with Drizzle as shown in
823
+ [Setup](https://pothos-graphql.dev/docs/plugins/drizzle/setup#integration-with-other-plugins); [Connections](https://pothos-graphql.dev/docs/plugins/drizzle/connections) covers pagination.
1050
824
 
1051
- Relay provides some very useful best practices that are useful for most GraphQL APIs. To make it
1052
- easy to comply with these best practices, the drizzle plugin has built in support for defining relay
1053
- `nodes` and `connections`.
825
+ ### Define a node
1054
826
 
1055
- ### Relay Nodes
1056
-
1057
- Defining relay nodes works just like defining normal `drizzleObject`s, but requires specifying a
1058
- column to use as the node's `id` field.
827
+ `builder.drizzleNode` uses the same fields and selections as `builder.drizzleObject`, and adds
828
+ an ID and a root node lookup. The example User uses its integer primary key:
1059
829
 
1060
830
  ```ts
1061
831
  builder.drizzleNode('users', {
1062
832
  name: 'User',
1063
- id: {
1064
- column: (user) => user.id,
1065
- // other options for the ID field can be passed here
1066
- },
1067
- fields: (t) => ({
1068
- firstName: t.exposeString('firstName'),
1069
- lastName: t.exposeString('lastName'),
1070
- }),
833
+ id: { column: (user) => user.id },
834
+ fields: (t) => ({ firstName: t.exposeString('firstName') }),
1071
835
  });
1072
836
  ```
1073
837
 
1074
- The id column can also be set to a list of columns for types with a composite primary key.
838
+ This minimal alternative shows the node options. The example User adds full name, profile,
839
+ and published posts to the same type. Other ID field options can be passed alongside `column`;
840
+ for a composite primary key, `column` can return a list of columns.
841
+
842
+ ### Refetch a public author
843
+
844
+ The ID returned by User can be passed back to
845
+ `node` to fetch the author with a new selection:
846
+
847
+ ```graphql
848
+ query RefetchAuthor {
849
+ node(id: "VXNlcjox") {
850
+ ... on User {
851
+ fullName
852
+ posts {
853
+ title
854
+ }
855
+ }
856
+ }
857
+ }
858
+ ```
859
+
860
+ The operation uses the ID of User 1. The node
861
+ returns the same public fields as the author lookup, including only published posts.
862
+
863
+ ### Authorize the node load path
864
+
865
+ > [!WARNING]
866
+ > Defining a node creates a direct lookup through `node` and `nodes`. These lookups bypass custom
867
+ > root resolvers, so permission checks or visibility filters on a list or parent field do not protect
868
+ > node refetches. An encoded global ID is an identifier, not proof of permission.
869
+ >
870
+ > Apply an access policy to node loading, the type, or its fields as appropriate. See
871
+ > [Authorizing Relay nodes](https://pothos-graphql.dev/docs/plugins/scope-auth/relay-nodes) for scope patterns and their limits.
872
+
873
+ The public User type here can be refetched by anyone. Its
874
+ [published-posts relation](https://pothos-graphql.dev/docs/plugins/drizzle/relations#published-posts-and-profiles) applies the publication filter
875
+ wherever User is reached, including through `node`. Post stays an ordinary Drizzle object;
876
+ defining a public feed does not also make individual drafts globally refetchable.
1075
877
 
1076
878
  ## Type variants
1077
879
 
1078
- Use the [builder setup with Relay](https://pothos-graphql.dev/docs/plugins/drizzle/setup#integration-with-other-plugins), and include
1079
- `Context: { user: { id: number } }` in `PothosTypes`. These examples assume an authenticated user
1080
- and a registered posts type. The `users` table has a posts relation.
880
+ A variant gives another GraphQL representation to the same database row. The publishing API
881
+ uses public User fields for author pages and a private Viewer for the signed-in account.
1081
882
 
1082
- ### Variants
883
+ The API has three parts:
1083
884
 
1084
- It is often useful to be able to define multiple object types based on the same table. This can be
1085
- done using a feature called `variants`. The `variants` API consists of 3 parts:
885
+ - `variant` registers an additional type instead of naming the primary type with `name`.
886
+ - `t.variant` returns another representation of the same row.
887
+ - A relation's `type` option can select a variant using its returned object ref.
1086
888
 
1087
- - A `variant` option that can be passed instead of a name on `drizzleObjects`
1088
- - The ability to pass an `ObjectRef` to the `type` option of `t.relation` and other similar fields
1089
- - A `t.variant` method that works similar to `t.relation`, but is used to define a GraphQL field that
1090
- references a variant of the same record.
889
+ ### A single object variant
890
+
891
+ Use `variant` to define Viewer alongside the primary User type. `t.variant('users')` exposes
892
+ the public representation of the same row:
1091
893
 
1092
894
  ```ts
1093
- // Viewer type representing the current user
1094
- export const Viewer = builder.drizzleObject('users', {
895
+ const Viewer = builder.drizzleObject('users', {
1095
896
  variant: 'Viewer',
1096
897
  select: {},
1097
- fields: (t) => ({
1098
- id: t.exposeID('id'),
1099
- // A reference to the normal user type so normal user fields can be queried
1100
- user: t.variant('users'),
1101
- // Adding drafts to View allows a user to fetch their own drafts without exposing it for Other Users in the API
1102
- drafts: t.relation('posts', {
1103
- query: {
1104
- where: {
1105
- published: false,
1106
- },
1107
- orderBy: {
1108
- updatedAt: 'desc',
1109
- },
898
+ fields: (t) => {
899
+ return {
900
+ id: t.exposeID('id'),
901
+ user: t.variant('users'),
902
+ drafts: t.relation('posts', {
903
+ query: {
904
+ where: { published: false },
905
+ orderBy: { createdAt: 'desc', id: 'desc' },
1110
906
  },
1111
- }),
1112
- }),
907
+ }),
908
+ };
909
+ },
1113
910
  });
911
+ ```
1114
912
 
1115
- builder.queryType({
913
+ ### A viewer with role-specific fields
914
+
915
+ When viewer fields differ by role, Viewer can instead be an interface with object variants
916
+ for each role. The following definition uses this structure; [Interfaces](https://pothos-graphql.dev/docs/plugins/drizzle/interfaces) explains
917
+ its implementations:
918
+
919
+ ```typescript
920
+ export const Viewer = builder.drizzleInterface('users', {
921
+ variant: 'Viewer',
922
+ select: { columns: { id: true, role: true } },
923
+ resolveType: (user) => (user.role === 'editor' ? 'EditorViewer' : 'AuthorViewer'),
1116
924
  fields: (t) => ({
1117
- me: t.drizzleField({
1118
- // We can use the ref returned by builder.drizzleObject to define our `drizzleField`
1119
- type: Viewer,
1120
- resolve: (query, root, args, ctx) =>
1121
- db.query.users.findFirst(
1122
- query({
1123
- where: {
1124
- id: ctx.user.id,
1125
- },
1126
- }),
1127
- ),
925
+ user: t.variant('users'),
926
+ email: t.exposeString('email'),
927
+ drafts: t.relation('posts', {
928
+ query: { where: { published: false }, orderBy: { id: 'asc' } },
1128
929
  }),
1129
930
  }),
1130
931
  });
932
+ ```
1131
933
 
1132
- builder.drizzleNode('users', {
1133
- name: 'User',
1134
- id: { column: (user) => user.id },
1135
- fields: (t) => ({
1136
- firstName: t.exposeString('firstName'),
1137
- // This field will resolve to the Viewer type, but be set to null if the user is not the current user
1138
- viewer: t.variant(Viewer, {
1139
- isNull: (user, args, ctx) => user.id !== ctx.user?.id,
1140
- }),
934
+ The root lookup uses the authenticated context ID. It does not accept an arbitrary author's ID:
935
+
936
+ ```typescript
937
+ me: t.drizzleField({
938
+ type: Viewer,
939
+ nullable: true,
940
+ resolve: (query, _root, _args, ctx) => {
941
+ return db.query.users.findFirst(
942
+ query({
943
+ where: { id: ctx.userId },
944
+ }),
945
+ );
946
+ },
947
+ }),
948
+ ```
949
+
950
+ Maya (`userId: 1`) sees “Planning the spring exchange”; Leo (`userId: 2`) sees “Saving rainwater.”
951
+ The interface and variants describe the shapes; the root lookup restricts ownership. Public
952
+ author fields and post connections return published posts only.
953
+
954
+ ### Conditionally expose another variant
955
+
956
+ The publishing User also exposes its Viewer conditionally. Define that field after Viewer is registered,
957
+ which avoids a circular reference between their definitions:
958
+
959
+ ```typescript
960
+ builder.drizzleObjectField('users', 'viewer', (t) =>
961
+ t.variant(Viewer, {
962
+ select: { columns: { id: true } },
963
+ isNull: (user, _args, ctx) => user.id !== ctx.userId,
1141
964
  }),
1142
- });
965
+ );
1143
966
  ```
1144
967
 
1145
- As an alternative to the User node definition above, a `t.variant` field can have a `select` of its own. It is planned along with the variant's
1146
- type-level selection when the variant is queried through that field:
968
+ The ownership check is essential: without it an arbitrary author's public row could reveal that
969
+ author's drafts. `isNull` makes the variant field null when its parent is another account.
970
+
971
+ A variant field accepts a `select` just like other fields. Here it loads `id` for the ownership
972
+ check; the target variant contributes the selections required by its own fields.
973
+
974
+ ### Relation variants
975
+
976
+ To use a different representation of a related row, pass its object ref to `t.relation`'s `type`
977
+ option. The variant must represent the relation's target table. For example, this alternative
978
+ adds a compact PostSummary representation and selects it for a public author's posts:
1147
979
 
1148
980
  ```ts
1149
- builder.drizzleNode('users', {
1150
- name: 'User',
1151
- id: { column: (user) => user.id },
1152
- fields: (t) => ({
1153
- viewer: t.variant(Viewer, {
1154
- // loaded with the row when `viewer` is selected
1155
- select: { columns: { email: true } },
1156
- isNull: (user, args, ctx) => user.id !== ctx.user?.id,
1157
- }),
1158
- }),
981
+ const PostSummary = builder.drizzleObject('posts', {
982
+ variant: 'PostSummary',
983
+ fields: (t) => ({ title: t.exposeString('title') }),
1159
984
  });
985
+
986
+ builder.drizzleObjectField('users', 'postSummaries', (t) =>
987
+ t.relation('posts', {
988
+ type: PostSummary,
989
+ query: {
990
+ where: { published: true },
991
+ orderBy: { id: 'asc' },
992
+ },
993
+ }),
994
+ );
1160
995
  ```
1161
996
 
1162
- Two variants of one table selected for the same row have their type-level selections merged into a
1163
- single query, which can fail if they disagree. See
997
+ Two variants selected for the same row have their type-level selections merged. Conflicting
998
+ relation arguments or extras can fail; see
1164
999
  [Conflicting selections between variants](https://pothos-graphql.dev/docs/plugins/drizzle/query-planning#conflicting-selections-between-variants).
1165
1000
 
1166
1001
  ## Interfaces
1167
1002
 
1168
1003
  ### Interfaces
1169
1004
 
1170
- `builder.drizzleInterface` works just like `builder.drizzleObject`, and can be used to define
1171
- either the primary type or a variant of a table as an interface that other variants implement. The
1172
- interface's `select` is planned whenever a field returns the interface, and an implementation's own
1173
- `select` is planned when a fragment narrows to it. Selections are not inherited, so an
1174
- implementation that exposes more columns will need a `select` of its own.
1005
+ `builder.drizzleInterface` works like `builder.drizzleObject`. It can define the primary type
1006
+ or a variant of a table. Here Viewer is a variant, leaving User as the public primary type.
1175
1007
 
1176
- ```ts
1008
+ ### Define the interface
1009
+
1010
+ ```typescript
1177
1011
  export const Viewer = builder.drizzleInterface('users', {
1178
1012
  variant: 'Viewer',
1179
1013
  select: { columns: { id: true, role: true } },
1180
- resolveType: (user) => (user.role === 'admin' ? 'AdminViewer' : 'MemberViewer'),
1014
+ resolveType: (user) => (user.role === 'editor' ? 'EditorViewer' : 'AuthorViewer'),
1181
1015
  fields: (t) => ({
1182
- id: t.exposeID('id'),
1183
1016
  user: t.variant('users'),
1017
+ email: t.exposeString('email'),
1018
+ drafts: t.relation('posts', {
1019
+ query: { where: { published: false }, orderBy: { id: 'asc' } },
1020
+ }),
1184
1021
  }),
1185
1022
  });
1023
+ ```
1024
+
1025
+ `resolveType` uses the selected `role` discriminator and returns GraphQL type names. Returning
1026
+ names avoids a circular reference between the interface and its implementations.
1186
1027
 
1028
+ ### Selecting a viewer implementation
1029
+
1030
+ ```typescript
1187
1031
  builder.drizzleObject('users', {
1188
- variant: 'AdminViewer',
1032
+ variant: 'EditorViewer',
1189
1033
  interfaces: [Viewer],
1190
- select: { columns: { permissions: true } },
1191
- fields: (t) => ({
1192
- permissions: t.exposeStringList('permissions'),
1193
- }),
1034
+ select: { columns: { id: true, role: true } },
1035
+ fields: (t) => ({ canReviewSubmissions: t.boolean({ resolve: () => true }) }),
1194
1036
  });
1195
-
1196
1037
  builder.drizzleObject('users', {
1197
- variant: 'MemberViewer',
1038
+ variant: 'AuthorViewer',
1198
1039
  interfaces: [Viewer],
1199
- select: { columns: { id: true } },
1040
+ select: { columns: { id: true, role: true } },
1200
1041
  });
1201
1042
  ```
1202
1043
 
1044
+ The interface's `select` is planned whenever a field returns the interface. An implementation's
1045
+ own selection is planned when a fragment narrows to it. Selections are not inherited; put data
1046
+ required by an implementation in its selection as well. An implementation may select additional
1047
+ columns or expressions for fields that do not belong on the interface.
1048
+
1049
+ Maya (`userId: 1`) resolves to EditorViewer with `canReviewSubmissions: true`. Leo (`userId: 2`)
1050
+ resolves to AuthorViewer, so the editor fragment contributes no field. The interface describes
1051
+ those result shapes; the authenticated root lookup enforces whose drafts are returned.
1052
+
1053
+ ### Extending an interface
1054
+
1203
1055
  Fields can be added to an interface later with `builder.drizzleInterfaceField` and
1204
1056
  `builder.drizzleInterfaceFields`, which take the interface ref (or the table name) like their
1205
1057
  `drizzleObjectField(s)` counterparts:
1206
1058
 
1207
1059
  ```ts
1208
1060
  builder.drizzleInterfaceFields(Viewer, (t) => ({
1209
- posts: t.relatedConnection('posts'),
1061
+ publishedPosts: t.relatedConnection('posts', {
1062
+ query: { where: { published: true } },
1063
+ }),
1210
1064
  }));
1211
1065
  ```
1212
1066
 
@@ -1240,6 +1094,44 @@ the first one planned wins, and the other is loaded with a query of its own. A t
1240
1094
  is planned before any field's selection, no matter where they appear in the document, and fields
1241
1095
  are planned in the order they are selected.
1242
1096
 
1097
+ ### Compare two orderings of one relation
1098
+
1099
+ A client may need both the newest and oldest published posts on one author page:
1100
+
1101
+ ```graphql
1102
+ query CompareOrderings {
1103
+ author(id: 1) {
1104
+ newest: posts {
1105
+ title
1106
+ }
1107
+ oldest: posts(oldestFirst: true) {
1108
+ title
1109
+ }
1110
+ }
1111
+ }
1112
+ ```
1113
+
1114
+ The [published-posts field](https://pothos-graphql.dev/docs/plugins/drizzle/relations#published-posts-and-profiles) translates those arguments
1115
+ into different database orderings. The two fields cannot reuse the same loaded
1116
+ relation because their orderings differ. The additional ordering is loaded through a fallback
1117
+ query.
1118
+
1119
+ In this example, selecting one ordering executes one SQL statement; selecting both executes
1120
+ two. The number of GraphQL field resolvers does not determine the number of database queries.
1121
+
1122
+ ```typescript
1123
+ posts: t.relation('posts', {
1124
+ args: { oldestFirst: t.arg.boolean() },
1125
+ query: (args) => ({
1126
+ where: { published: true },
1127
+ orderBy: {
1128
+ createdAt: args.oldestFirst ? 'asc' : 'desc',
1129
+ id: args.oldestFirst ? 'asc' : 'desc',
1130
+ },
1131
+ }),
1132
+ }),
1133
+ ```
1134
+
1243
1135
  ### Nested selections
1244
1136
 
1245
1137
  A `select` function receives `nestedSelection`, which plans the selection beneath the field for the
@@ -1252,11 +1144,19 @@ builder.drizzleObject('users', {
1252
1144
  name: 'User',
1253
1145
  fields: (t) => ({
1254
1146
  previewPosts: t.field({
1255
- type: [Post],
1256
- select: (args, ctx, nestedSelection) => ({
1257
- // what the query selects on the posts, limited to one
1258
- with: { posts: nestedSelection({ limit: 1 }) },
1259
- }),
1147
+ type: ['posts'],
1148
+ select: (args, ctx, nestedSelection) => {
1149
+ return {
1150
+ // what the query selects on the posts, limited to one
1151
+ with: {
1152
+ posts: nestedSelection({
1153
+ where: { published: true },
1154
+ orderBy: { id: 'asc' },
1155
+ limit: 1,
1156
+ }),
1157
+ },
1158
+ };
1159
+ },
1260
1160
  resolve: (user) => user.posts,
1261
1161
  }),
1262
1162
  }),
@@ -1274,15 +1174,193 @@ which are described under [Relation queries](https://pothos-graphql.dev/docs/plu
1274
1174
 
1275
1175
  ### Async selections
1276
1176
 
1277
- Selections are synchronous unless the schema opts in with `AsyncSelections: true`. This example
1278
- uses request context methods that asynchronously return a user ID and a preview limit:
1177
+ When a selection depends on asynchronous request data, enable `AsyncSelections: true` and await
1178
+ nested plans. [Async selections](https://pothos-graphql.dev/docs/plugins/drizzle/async-selections) covers the builder configuration, supported
1179
+ callbacks, helper options, and errors caused by unsettled promises.
1180
+
1181
+ ### Conflicting selections between variants
1182
+
1183
+ When a query selects two variants of one table for the same row, either with a fragment on each
1184
+ under one field, or through a `t.variant` field, the plugin will throw a `PothosValidationError` if
1185
+ their `select` options ask for the same relation with different arguments:
1186
+
1187
+ ```
1188
+ PothosValidationError: Type-level selections of Viewer and Admin conflict on relation "posts".
1189
+ Move the relation arguments to a field-level select on one of the types.
1190
+ ```
1191
+
1192
+ The same applies to two variants defining the same `extras` key with different functions
1193
+ (`... conflict on extra "lowercaseName"`). Both variants describe one row, so their `select`
1194
+ options are merged into a single query.
1195
+
1196
+ To fix this, move the relation with its arguments, or the extra, into the `select` of the field
1197
+ that needs it on one of the variants. A field-level selection that conflicts with what the row
1198
+ already holds falls back to a query for that field, rather than failing the request.
1199
+
1200
+ ## Connection helpers
1201
+
1202
+ An image can appear in several posts, with a different caption in each. The Media row describes
1203
+ the image; the PostMedia row describes its attachment to a post. A connection can expose the
1204
+ image as its node and the attachment's caption as an edge field:
1205
+
1206
+ ```graphql
1207
+ query PostAttachments {
1208
+ author(id: 1) {
1209
+ postsConnection(first: 1) {
1210
+ nodes {
1211
+ title
1212
+ attachments(first: 2) {
1213
+ edges {
1214
+ caption
1215
+ node {
1216
+ url
1217
+ uploadedBy {
1218
+ fullName
1219
+ }
1220
+ }
1221
+ }
1222
+ pageInfo {
1223
+ endCursor
1224
+ hasNextPage
1225
+ }
1226
+ }
1227
+ }
1228
+ }
1229
+ }
1230
+ }
1231
+ ```
1232
+
1233
+ `t.relatedConnection` handles a relation whose rows are the connection's nodes. Use
1234
+ `drizzleConnectionHelpers` with `t.connection` when the rows being paginated differ from those
1235
+ nodes, or when you need to load the rows yourself. The helper supplies the selection, cursor,
1236
+ and result handling that a normal Relay connection field does not have.
1237
+
1238
+ ### Select the nodes through the attachment
1239
+
1240
+ Import `drizzleConnectionHelpers` from `@pothos/plugin-drizzle` and use the builder configured
1241
+ with the Drizzle and Relay plugins. The helper targets `postMedia`. Its `select` uses
1242
+ `nestedSelection()` to request the Media fields
1243
+ selected beneath the connection's nodes; `resolveNode` returns that related Media row:
1244
+
1245
+ ```typescript
1246
+ const attachmentArgs = builder.args((t) => ({
1247
+ hasCaption: t.boolean({ defaultValue: false }),
1248
+ }));
1249
+
1250
+ const attachments = drizzleConnectionHelpers(builder, 'postMedia', {
1251
+ args: () => attachmentArgs,
1252
+ query: (args) => ({
1253
+ orderBy: { id: 'asc' },
1254
+ where: args.hasCaption ? { caption: { isNotNull: true } } : {},
1255
+ }),
1256
+ select: (nestedSelection) => {
1257
+ return {
1258
+ columns: { caption: true },
1259
+ with: { media: nestedSelection() },
1260
+ };
1261
+ },
1262
+ resolveNode: (attachment) => attachment.media,
1263
+ });
1264
+ ```
1265
+
1266
+ The connection orders and creates cursors from attachment IDs, not Media IDs. An image shared
1267
+ by two posts can therefore have a different caption and connection position in each post.
1268
+ The nested uploader selection is still planned from the requested Media fields.
1269
+
1270
+ The helper's `args` and `query` options define a `hasCaption` argument. When it is true, the
1271
+ connection includes only attachments with a caption. Its default, false, includes all attachments.
1272
+ `getArgs()` exposes that argument on the field, and `getQuery()` combines the filter and ordering
1273
+ with the requested page and node selection.
1274
+
1275
+ ### Add the connection and its edge fields
1276
+
1277
+ `Media` below is the object ref returned by `builder.drizzleObject('media', ...)`.
1278
+ A normal `t.connection` needs an explicit `select` to load the helper's query on the parent.
1279
+ Its resolver passes the selected attachment rows to the helper's `resolve` method:
1280
+
1281
+ ```typescript
1282
+ builder.drizzleObjectField('posts', 'attachments', (t) =>
1283
+ t.connection(
1284
+ {
1285
+ type: Media,
1286
+ args: attachments.getArgs(),
1287
+ select: (args, ctx, nestedSelection) => {
1288
+ return {
1289
+ with: {
1290
+ attachments: attachments.getQuery(args, ctx, nestedSelection),
1291
+ },
1292
+ };
1293
+ },
1294
+ resolve: (post, args, ctx) => {
1295
+ return attachments.resolve(post.attachments, args, ctx);
1296
+ },
1297
+ },
1298
+ {},
1299
+ {
1300
+ fields: (edge) => ({
1301
+ caption: edge.string({
1302
+ nullable: true,
1303
+ resolve: (attachment) => attachment.caption,
1304
+ }),
1305
+ }),
1306
+ },
1307
+ ),
1308
+ );
1309
+ ```
1310
+
1311
+ The third argument to `t.connection` configures the Edge type. Each edge retains the selected
1312
+ attachment fields, so its `caption` resolver reads the attachment's caption, while `node` returns
1313
+ the Media selected by `resolveNode`. A missing caption returns null.
1314
+
1315
+ The post has three attachments. Its first unfiltered page of two has `hasNextPage: true`;
1316
+ filtering to captioned attachments returns two and has no next page. Pagination uses the same
1317
+ `first`/`after` and `last`/`before` arguments as [other connections](https://pothos-graphql.dev/docs/plugins/drizzle/connections#page-through-published-posts).
1318
+
1319
+ ### Querying the rows in a resolver
1320
+
1321
+ The same helper can build a query for a resolver that fetches the attachment rows itself.
1322
+ Pass GraphQL resolve `info` to `getQuery`, merge any additional filter with its cursor filter,
1323
+ and pass the result to `resolve`:
1324
+
1325
+ ```ts
1326
+ // Alternative resolver for the Post.attachments connection above:
1327
+ resolve: async (post, args, ctx, info) => {
1328
+ const query = attachments.getQuery(args, ctx, info);
1329
+ const attachmentRows = await db.query.postMedia.findMany({
1330
+ ...query,
1331
+ where: {
1332
+ AND: [query.where ?? {}, { postId: post.id }],
1333
+ },
1334
+ });
1335
+
1336
+ return attachments.resolve(attachmentRows, args, ctx);
1337
+ },
1338
+ ```
1339
+
1340
+ This alternative needs `db` imported from the application's database module and `id` selected
1341
+ on Post. Remove the field's relation `select` when using it, so the rows are not also loaded
1342
+ through the parent query. Replacing the helper's `where` rather than combining it would discard
1343
+ pagination constraints.
1344
+
1345
+ If the rows and nodes are the same type, omit `resolveNode` and the node-mapping `select` when
1346
+ creating the helper. Its `ref` provides the node type for `t.connection`. Prefer
1347
+ `t.relatedConnection` when you can expose that relation directly without custom loading.
1348
+
1349
+ ## Async selections
1350
+
1351
+ Pothos selections are synchronous by default. Set `AsyncSelections: true` when a selection or
1352
+ relation query must await request data before returning its query options.
1353
+
1354
+ ### Async selections
1355
+
1356
+ Use the client and relations configured in [Setup](https://pothos-graphql.dev/docs/plugins/drizzle/setup). The context method below supplies
1357
+ a preview size loaded asynchronously, for example from account settings:
1279
1358
 
1280
1359
  ```ts
1281
1360
  const builder = new SchemaBuilder<{
1282
1361
  DrizzleRelations: typeof relations;
1283
1362
  AsyncSelections: true;
1284
1363
  Context: {
1285
- currentUserId: () => Promise<number>;
1286
1364
  previewSize: () => Promise<number>;
1287
1365
  };
1288
1366
  }>({
@@ -1299,8 +1377,7 @@ With the opt-in, `select` functions, relation `query` callbacks, `relatedCount`
1299
1377
  and the `select` and `query` callbacks of `drizzleConnectionHelpers` may be async. Without it they
1300
1378
  are typed as synchronous, and an async callback is a type error.
1301
1379
 
1302
- The plugin still builds a single query. It waits for the callbacks, and merges what they return
1303
- after every synchronous selection, in document order. `t.relation`, `t.relatedCount`,
1380
+ Pothos waits for these callbacks before executing the planned query. `t.relation`, `t.relatedCount`,
1304
1381
  `t.drizzleField`, `t.drizzleConnection` and `t.relatedConnection` settle their plan before the
1305
1382
  resolver runs, and need no changes.
1306
1383
 
@@ -1308,25 +1385,34 @@ resolver runs, and need no changes.
1308
1385
  builder.drizzleObject('users', {
1309
1386
  name: 'User',
1310
1387
  fields: (t) => ({
1311
- posts: t.relation('posts', {
1312
- query: async (args, ctx) => ({ where: { authorId: await ctx.currentUserId() } }),
1313
- }),
1314
1388
  previewPosts: t.field({
1315
- type: [Post],
1316
- select: async (args, ctx, nestedSelection) => ({
1317
- with: { posts: await nestedSelection({ limit: await ctx.previewSize() }) },
1318
- }),
1389
+ type: ['posts'],
1390
+ select: async (args, ctx, nestedSelection) => {
1391
+ return {
1392
+ with: {
1393
+ posts: await nestedSelection({
1394
+ where: { published: true },
1395
+ orderBy: { id: 'asc' },
1396
+ limit: await ctx.previewSize(),
1397
+ }),
1398
+ },
1399
+ };
1400
+ },
1319
1401
  resolve: (user) => user.posts,
1320
1402
  }),
1321
1403
  }),
1322
1404
  });
1323
1405
  ```
1324
1406
 
1407
+ ### Await nested selections
1408
+
1325
1409
  `await` what `nestedSelection` returns before putting it in the selection, and the same for the
1326
1410
  `nestedQuery` passed as the fourth argument of a `t.relatedField` `select`. A selection that
1327
1411
  contains the promise itself will throw, and so will a `select` that returns while a nested
1328
- selection it started is still pending. Calling `nestedSelection` and discarding a synchronous
1329
- result is not detected, and the nested selection will not be loaded with the parent.
1412
+ selection it started is still pending. Include the nested selection in the returned query to
1413
+ load its data.
1414
+
1415
+ ### Connection helpers
1330
1416
 
1331
1417
  Pass `awaitSelections: true` to `drizzleConnectionHelpers(...).getQuery`, and `await` the query it
1332
1418
  returns. Without it, an async selection beneath the field throws, and whether there is one
@@ -1334,13 +1420,15 @@ depends on the incoming document rather than on the callback you wrote. A connec
1334
1420
  throws when its own `select` or `query` is async, whatever the document asked for:
1335
1421
 
1336
1422
  ```ts
1337
- select: async (args, ctx, nestedSelection) => ({
1338
- with: {
1339
- comments: await commentConnectionHelpers.getQuery(args, ctx, nestedSelection, {
1340
- awaitSelections: true,
1341
- }),
1342
- },
1343
- }),
1423
+ select: async (args, ctx, nestedSelection) => {
1424
+ return {
1425
+ with: {
1426
+ attachments: await attachments.getQuery(args, ctx, nestedSelection, {
1427
+ awaitSelections: true,
1428
+ }),
1429
+ },
1430
+ };
1431
+ },
1344
1432
  ```
1345
1433
 
1346
1434
  `awaitSelections` is a per-call option, and is available whether or not the schema sets
@@ -1350,21 +1438,6 @@ When `AsyncSelections: true`, also `await` a connection helper's `resolve()` bef
1350
1438
  spreading its result: an async `query` callback makes resolution asynchronous. Returning its
1351
1439
  result directly from a GraphQL resolver remains supported.
1352
1440
 
1353
- ### Conflicting selections between variants
1354
-
1355
- When a query selects two variants of one table for the same row, either with a fragment on each
1356
- under one field, or through a `t.variant` field, the plugin will throw a `PothosValidationError` if
1357
- their `select` options ask for the same relation with different arguments:
1358
-
1359
- ```
1360
- PothosValidationError: Type-level selections of Viewer and Admin conflict on relation "posts".
1361
- Move the relation arguments to a field-level select on one of the types.
1362
- ```
1363
-
1364
- The same applies to two variants defining the same `extras` key with different functions
1365
- (`... conflict on extra "lowercaseName"`). Both variants describe one row, so their `select`
1366
- options are merged into a single query.
1367
-
1368
- To fix this, move the relation with its arguments, or the extra, into the `select` of the field
1369
- that needs it on one of the variants. A field-level selection that conflicts with what the row
1370
- already holds falls back to a query for that field, rather than failing the request.
1441
+ The connection-helper excerpt uses the Post attachments relation and `attachments` helper from
1442
+ [Connection helpers](https://pothos-graphql.dev/docs/plugins/drizzle/connection-helpers). Request-context
1443
+ methods must be supplied by the application; they are not created by declaring the Context type.