@pothos/plugin-drizzle 0.18.1 → 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.
Files changed (165) hide show
  1. package/CHANGELOG.md +357 -0
  2. package/README.md +1199 -563
  3. package/dts/connection-helpers.d.ts +18 -8
  4. package/dts/connection-helpers.d.ts.map +1 -1
  5. package/dts/drizzle-field-builder.d.ts +4 -11
  6. package/dts/drizzle-field-builder.d.ts.map +1 -1
  7. package/dts/global-types.d.ts.map +1 -1
  8. package/dts/index.d.ts.map +1 -1
  9. package/dts/model-loader.d.ts +18 -17
  10. package/dts/model-loader.d.ts.map +1 -1
  11. package/dts/types.d.ts +82 -22
  12. package/dts/types.d.ts.map +1 -1
  13. package/dts/utils/adapter.d.ts +39 -0
  14. package/dts/utils/adapter.d.ts.map +1 -0
  15. package/dts/utils/await-selections.d.ts +12 -0
  16. package/dts/utils/await-selections.d.ts.map +1 -0
  17. package/dts/utils/config.d.ts +23 -3
  18. package/dts/utils/config.d.ts.map +1 -1
  19. package/dts/utils/cursors.d.ts +6 -6
  20. package/dts/utils/cursors.d.ts.map +1 -1
  21. package/dts/utils/map-query.d.ts +22 -21
  22. package/dts/utils/map-query.d.ts.map +1 -1
  23. package/dts/utils/path-info.d.ts +13 -0
  24. package/dts/utils/path-info.d.ts.map +1 -0
  25. package/dts/utils/relation-filter.d.ts +48 -0
  26. package/dts/utils/relation-filter.d.ts.map +1 -0
  27. package/dts/utils/selections.d.ts +2 -21
  28. package/dts/utils/selections.d.ts.map +1 -1
  29. package/esm/connection-helpers.d.ts +18 -8
  30. package/esm/connection-helpers.d.ts.map +1 -1
  31. package/esm/connection-helpers.js +52 -19
  32. package/esm/connection-helpers.js.map +1 -1
  33. package/esm/drizzle-field-builder.d.ts +4 -11
  34. package/esm/drizzle-field-builder.d.ts.map +1 -1
  35. package/esm/drizzle-field-builder.js +138 -96
  36. package/esm/drizzle-field-builder.js.map +1 -1
  37. package/esm/field-builder.js +61 -47
  38. package/esm/field-builder.js.map +1 -1
  39. package/esm/global-types.d.ts.map +1 -1
  40. package/esm/index.d.ts.map +1 -1
  41. package/esm/index.js +37 -61
  42. package/esm/index.js.map +1 -1
  43. package/esm/model-loader.d.ts +18 -17
  44. package/esm/model-loader.d.ts.map +1 -1
  45. package/esm/model-loader.js +76 -45
  46. package/esm/model-loader.js.map +1 -1
  47. package/esm/schema-builder.js +40 -149
  48. package/esm/schema-builder.js.map +1 -1
  49. package/esm/types.d.ts +82 -22
  50. package/esm/types.d.ts.map +1 -1
  51. package/esm/types.js.map +1 -1
  52. package/esm/utils/adapter.d.ts +39 -0
  53. package/esm/utils/adapter.d.ts.map +1 -0
  54. package/esm/utils/adapter.js +137 -0
  55. package/esm/utils/adapter.js.map +1 -0
  56. package/esm/utils/await-selections.d.ts +12 -0
  57. package/esm/utils/await-selections.d.ts.map +1 -0
  58. package/esm/utils/await-selections.js +19 -0
  59. package/esm/utils/await-selections.js.map +1 -0
  60. package/esm/utils/config.d.ts +23 -3
  61. package/esm/utils/config.d.ts.map +1 -1
  62. package/esm/utils/config.js +1 -4
  63. package/esm/utils/config.js.map +1 -1
  64. package/esm/utils/cursors.d.ts +6 -6
  65. package/esm/utils/cursors.d.ts.map +1 -1
  66. package/esm/utils/cursors.js +75 -92
  67. package/esm/utils/cursors.js.map +1 -1
  68. package/esm/utils/map-query.d.ts +22 -21
  69. package/esm/utils/map-query.d.ts.map +1 -1
  70. package/esm/utils/map-query.js +40 -354
  71. package/esm/utils/map-query.js.map +1 -1
  72. package/esm/utils/path-info.d.ts +13 -0
  73. package/esm/utils/path-info.d.ts.map +1 -0
  74. package/esm/utils/path-info.js +37 -0
  75. package/esm/utils/path-info.js.map +1 -0
  76. package/esm/utils/relation-filter.d.ts +48 -0
  77. package/esm/utils/relation-filter.d.ts.map +1 -0
  78. package/esm/utils/relation-filter.js +92 -0
  79. package/esm/utils/relation-filter.js.map +1 -0
  80. package/esm/utils/selections.d.ts +2 -21
  81. package/esm/utils/selections.d.ts.map +1 -1
  82. package/esm/utils/selections.js +1 -133
  83. package/esm/utils/selections.js.map +1 -1
  84. package/lib/connection-helpers.js +51 -18
  85. package/lib/connection-helpers.js.map +1 -1
  86. package/lib/drizzle-field-builder.js +136 -94
  87. package/lib/drizzle-field-builder.js.map +1 -1
  88. package/lib/field-builder.js +58 -44
  89. package/lib/field-builder.js.map +1 -1
  90. package/lib/index.js +36 -60
  91. package/lib/index.js.map +1 -1
  92. package/lib/model-loader.js +76 -45
  93. package/lib/model-loader.js.map +1 -1
  94. package/lib/schema-builder.js +40 -149
  95. package/lib/schema-builder.js.map +1 -1
  96. package/lib/types.js.map +1 -1
  97. package/lib/utils/adapter.js +147 -0
  98. package/lib/utils/adapter.js.map +1 -0
  99. package/lib/utils/await-selections.js +20 -0
  100. package/lib/utils/await-selections.js.map +1 -0
  101. package/lib/utils/config.js +1 -4
  102. package/lib/utils/config.js.map +1 -1
  103. package/lib/utils/cursors.js +67 -90
  104. package/lib/utils/cursors.js.map +1 -1
  105. package/lib/utils/map-query.js +28 -359
  106. package/lib/utils/map-query.js.map +1 -1
  107. package/lib/utils/path-info.js +39 -0
  108. package/lib/utils/path-info.js.map +1 -0
  109. package/lib/utils/relation-filter.js +91 -0
  110. package/lib/utils/relation-filter.js.map +1 -0
  111. package/lib/utils/selections.js +3 -158
  112. package/lib/utils/selections.js.map +1 -1
  113. package/package.json +10 -8
  114. package/src/connection-helpers.ts +168 -54
  115. package/src/drizzle-field-builder.ts +296 -176
  116. package/src/field-builder.ts +110 -77
  117. package/src/global-types.ts +0 -32
  118. package/src/index.ts +42 -68
  119. package/src/model-loader.ts +110 -71
  120. package/src/schema-builder.ts +76 -169
  121. package/src/types.ts +178 -46
  122. package/src/utils/adapter.ts +197 -0
  123. package/src/utils/await-selections.ts +31 -0
  124. package/src/utils/config.ts +1 -5
  125. package/src/utils/cursors.ts +84 -83
  126. package/src/utils/map-query.ts +51 -734
  127. package/src/utils/path-info.ts +40 -0
  128. package/src/utils/relation-filter.ts +185 -0
  129. package/src/utils/selections.ts +2 -207
  130. package/dts/utils/deep-equal.d.ts +0 -2
  131. package/dts/utils/deep-equal.d.ts.map +0 -1
  132. package/dts/utils/drizzle-graphql.d.ts +0 -2
  133. package/dts/utils/drizzle-graphql.d.ts.map +0 -1
  134. package/dts/utils/loader-map.d.ts +0 -11
  135. package/dts/utils/loader-map.d.ts.map +0 -1
  136. package/dts/utils/usage.d.ts +0 -5
  137. package/dts/utils/usage.d.ts.map +0 -1
  138. package/esm/utils/deep-equal.d.ts +0 -2
  139. package/esm/utils/deep-equal.d.ts.map +0 -1
  140. package/esm/utils/deep-equal.js +0 -45
  141. package/esm/utils/deep-equal.js.map +0 -1
  142. package/esm/utils/drizzle-graphql.d.ts +0 -2
  143. package/esm/utils/drizzle-graphql.d.ts.map +0 -1
  144. package/esm/utils/drizzle-graphql.js +0 -232
  145. package/esm/utils/drizzle-graphql.js.map +0 -1
  146. package/esm/utils/loader-map.d.ts +0 -11
  147. package/esm/utils/loader-map.d.ts.map +0 -1
  148. package/esm/utils/loader-map.js +0 -35
  149. package/esm/utils/loader-map.js.map +0 -1
  150. package/esm/utils/usage.d.ts +0 -5
  151. package/esm/utils/usage.d.ts.map +0 -1
  152. package/esm/utils/usage.js +0 -59
  153. package/esm/utils/usage.js.map +0 -1
  154. package/lib/utils/deep-equal.js +0 -55
  155. package/lib/utils/deep-equal.js.map +0 -1
  156. package/lib/utils/drizzle-graphql.js +0 -236
  157. package/lib/utils/drizzle-graphql.js.map +0 -1
  158. package/lib/utils/loader-map.js +0 -56
  159. package/lib/utils/loader-map.js.map +0 -1
  160. package/lib/utils/usage.js +0 -83
  161. package/lib/utils/usage.js.map +0 -1
  162. package/src/utils/deep-equal.ts +0 -57
  163. package/src/utils/drizzle-graphql.ts +0 -274
  164. package/src/utils/loader-map.ts +0 -49
  165. package/src/utils/usage.ts +0 -62
package/README.md CHANGED
@@ -1,81 +1,162 @@
1
- # Drizzle Plugin
1
+ # Drizzle Plugin for Pothos
2
2
 
3
- ## Installing
3
+ > **Note:**
4
+ This plugin uses drizzle's [relational query builder v2](https://orm.drizzle.team/docs/rqb-v2),
5
+ which ships in `drizzle-orm` 1.0. That release is still a release candidate, and npm's `latest`
6
+ tag is the 0.x line, so install drizzle with the `rc` tag. Its API can still change before 1.0 is
7
+ final.
4
8
 
5
- ```package-install
6
- npm install --save @pothos/plugin-drizzle drizzle-orm@beta
9
+ If you are upgrading from an older version of this plugin, read drizzle's [relations v1 to v2
10
+ guide](https://orm.drizzle.team/docs/relations-v1-v2), then this package's changelog for the
11
+ Pothos specific changes.
12
+
13
+
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
+
17
+ The examples use authors and posts to demonstrate the plugin APIs.
18
+
19
+ ### Getting started
20
+
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:
23
+
24
+ ```typescript
25
+ builder.queryType({
26
+ fields: (t) => ({
27
+ author: t.drizzleField({
28
+ type: 'users',
29
+ nullable: true,
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
+ },
38
+ }),
39
+ }),
40
+ });
7
41
  ```
8
42
 
9
- The drizzle plugin is built on top of drizzles relational query builder, and requires that you
10
- define and configure all the relevant relations in your drizzle schema. See
11
- https://rqbv2.drizzle-orm-fe.pages.dev/docs/relations-v2 for detailed documentation on the relations API.
43
+ ```graphql
44
+ query AuthorPage {
45
+ author(id: 1) {
46
+ fullName
47
+ posts {
48
+ title
49
+ }
50
+ }
51
+ }
52
+ ```
12
53
 
13
- Once you have configured your drizzle schema, you can initialize your Pothos
14
- SchemaBuilder with the drizzle plugin:
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.
15
56
 
16
- ```ts
17
- import { drizzle } from 'drizzle-orm/...';
18
- // Import the appropriate getTableConfig for your dialect
19
- import { getTableConfig } from 'drizzle-orm/sqlite-core';
20
- import SchemaBuilder from '@pothos/core';
21
- import DrizzlePlugin from '@pothos/plugin-drizzle';
22
- import { relations } from './db/relations';
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.
62
+
63
+ ## Setup
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.
23
68
 
24
- const db = drizzle({ client, relations });
69
+ ### Installing
25
70
 
26
- type DrizzleRelations = typeof relations
71
+ ```package-install
72
+ npm install --save @pothos/plugin-drizzle
73
+ ```
74
+
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.
77
+
78
+ ### Configure the builder
79
+
80
+ ```ts
81
+ // builder.ts
27
82
 
28
83
  export interface PothosTypes {
29
- DrizzleRelations: DrizzleRelations;
84
+ DrizzleRelations: typeof relations;
85
+ Context: { userId: number };
30
86
  }
31
87
 
32
- const builder = new SchemaBuilder<PothosTypes>({
88
+ export const builder = new SchemaBuilder<PothosTypes>({
33
89
  plugins: [DrizzlePlugin],
34
- drizzle: {
35
- client: db, // or (ctx) => db if you want to create a request specific client
36
- getTableConfig,
37
- relations,
38
- },
90
+ drizzle: { client: db, getTableConfig, relations },
39
91
  });
40
92
  ```
41
93
 
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`.
98
+
99
+ Use this builder to [define GraphQL types and fields](https://pothos-graphql.dev/docs/plugins/drizzle/objects), then call `builder.toSchema()`.
100
+
42
101
  ### Integration with other plugins
43
102
 
44
- The drizzle plugin has integrations with several other plugins. While the `with-input` and `relay`
45
- plugins are not required, many examples will assume these plugins have been installed:
103
+ Install and register `@pothos/plugin-relay` to use `drizzleNode` and connection fields:
46
104
 
47
105
  ```ts
48
- import { drizzle } from 'drizzle-orm/...';
49
- import SchemaBuilder from '@pothos/core';
50
- import DrizzlePlugin from '@pothos/plugin-drizzle';
51
- import RelayPlugin from '@pothos/plugin-relay';
52
- import WithInputPlugin from '@pothos/plugin-with-input';
53
- import { getTableConfig } from 'drizzle-orm/sqlite-core';
54
- import { relations } from './db/relations';
55
106
 
56
- const db = drizzle({ client, relations });
107
+ // In the builder options:
108
+ plugins: [RelayPlugin, DrizzlePlugin],
109
+ relay: { nodesOnConnection: true },
110
+ ```
57
111
 
58
- export interface PothosTypes {
59
- DrizzleRelations: typeof relations;
60
- }
112
+ `nodesOnConnection` adds the `nodes` convenience field used in these guides. Without it,
113
+ select `edges { node { ... } }` instead.
61
114
 
62
- const builder = new SchemaBuilder<PothosTypes>({
63
- plugins: [RelayPlugin, WithInputPlugin, DrizzlePlugin],
64
- drizzle: {
65
- client: db,
66
- getTableConfig,
67
- relations,
68
- },
69
- });
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],
70
123
  ```
71
124
 
72
- ## Defining Objects
125
+ ### Plugin options
126
+
127
+ - `client`: the drizzle client, or a function returning one from the request context.
128
+ - `getTableConfig`: the `getTableConfig` of your dialect, used to read primary keys and unique
129
+ constraints.
130
+ - `relations`: the relations passed to `drizzle()`.
131
+ - `defaultConnectionSize` / `maxConnectionSize`: the page size a connection uses when the query
132
+ does not ask for one (defaults to 20), and the largest size it will accept (defaults to 100).
133
+ Both can also be set per field with `defaultSize` and `maxSize`.
134
+ - `filterConnectionTotalCount`: see [Connection totalCount](https://pothos-graphql.dev/docs/plugins/drizzle/connections#connection-totalcount).
135
+ - `skipDeferredFragments`: selections inside a `@defer` fragment are left out of the planned query
136
+ by default, and the fragment's fields are loaded through [fallback queries](https://pothos-graphql.dev/docs/plugins/drizzle/relations#fallback-queries)
137
+ when it resolves. Set this to `false` to plan deferred selections with the rest of the query.
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
+
148
+ ## Drizzle Objects
149
+
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.
152
+
153
+ ### Defining Objects
73
154
 
74
155
  The `builder.drizzleObject` method can be used to define GraphQL Object types based on a drizzle
75
156
  table:
76
157
 
77
158
  ```ts
78
- const UserRef = builder.drizzleObject('users', {
159
+ const User = builder.drizzleObject('users', {
79
160
  name: 'User',
80
161
  fields: (t) => ({
81
162
  firstName: t.exposeString('firstName'),
@@ -85,723 +166,1278 @@ const UserRef = builder.drizzleObject('users', {
85
166
  ```
86
167
 
87
168
  You will be able to "expose" any column in the table, and GraphQL fields do not need to match the
88
- names of the columns in your database. The returned `UserRef` can be used like any other `ObjectRef`
169
+ names of the columns in your database. The returned `User` can be used like any other `ObjectRef`
89
170
  in Pothos.
90
171
 
91
- ## Custom fields
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.
92
174
 
93
- You will often want to define fields in your API that do not correspond to a specific database
94
- column. To do this, you can define fields with a resolver like any other Pothos object type:
175
+ ### Custom fields
95
176
 
96
- ```ts
97
- const UserRef = builder.drizzleObject('users', {
98
- name: 'User',
99
- fields: (t) => ({
100
- fullName: t.string({
101
- resolve: (user, args, ctx, info) => `${user.firstName} ${user.lastName}`,
102
- }),
103
- }),
104
- });
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:
179
+
180
+ ```typescript
181
+ fullName: t.string({
182
+ select: { columns: { firstName: true, lastName: true } },
183
+ resolve: (user) => `${user.firstName} ${user.lastName}`,
184
+ }),
105
185
  ```
106
186
 
107
- ## Type selections
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.
108
190
 
109
- In the above example, you can see that by default we have access to all columns of our table. For
110
- tables with many columns, it can be more efficient to only select the needed columns. You can
111
- configure the selected columns, and relations by passing a `select` option when defining the type:
191
+ ### Drizzle Fields
112
192
 
113
- ```ts
114
- const UserRef = builder.drizzleObject('users', {
115
- name: 'User',
116
- select: {
117
- columns: {
118
- firstName: true,
119
- lastName: true,
120
- },
121
- with: {
122
- profile: true,
123
- },
124
- extras: {
125
- lowercaseName: (users, sql) => sql<string>`lower(${users.firstName})`
126
- },
127
- },
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.
197
+
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
204
+ builder.queryType({
128
205
  fields: (t) => ({
129
- fullName: t.string({
130
- resolve: (user, args, ctx, info) => `${user.firstName} ${user.lastName}`,
131
- }),
132
- bio: t.string({
133
- resolve: (user) => user.profile.bio,
134
- }),
135
- email: t.string({
136
- resolve: (user) => `${user.lowercaseName}@example.com`,
206
+ author: t.drizzleField({
207
+ type: 'users',
208
+ nullable: true,
209
+ args: { id: t.arg.int({ required: true }) },
210
+ resolve: (query, _root, args) => {
211
+ return db.query.users.findFirst(
212
+ query({
213
+ where: { id: args.id },
214
+ }),
215
+ );
216
+ },
137
217
  }),
138
218
  }),
139
219
  });
140
220
  ```
141
221
 
142
- Any selections added to the type will be available to consume in all resolvers. Columns that are not
143
- selected can still be exposed as before.
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.
144
227
 
145
- ## Field selections
228
+ #### `drizzleFieldWithInput`
146
229
 
147
- The previous example allows you to control what gets selected by default, but you often want to only
148
- select the columns that are required to fulfill a specific field. You can do this by adding the
149
- appropriate selections on each field:
230
+ With the [with-input plugin](https://pothos-graphql.dev/docs/plugins/with-input),
231
+ `t.drizzleFieldWithInput` combines `t.drizzleField` with `t.fieldWithInput`. The `input` fields
232
+ become an input object argument, and the resolver still receives the `query` function as its first
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):
150
235
 
151
236
  ```ts
152
- const UserRef = builder.drizzleObject('users', {
153
- name: 'User',
154
- select: {},
155
- fields: (t) => ({
156
- fullName: t.string({
157
- select: {
158
- columns: { firstName: true, lastName: true },
159
- },
160
- resolve: (user, args, ctx, info) => `${user.firstName} ${user.lastName}`,
161
- }),
162
- bio: t.string({
163
- select: {
164
- with: { profile: true },
165
- },
166
- resolve: (user) => user.profile.bio,
167
- }),
168
- email: t.string({
169
- select: {
170
- extras: {
171
- lowercaseName: (users, sql) => sql<string>`lower(${users.firstName})`
172
- },
173
- },
174
- resolve: (user) => `${user.lowercaseName}@example.com`,
175
- }),
237
+ builder.queryFields((t) => ({
238
+ authorWithInput: t.drizzleFieldWithInput({
239
+ type: 'users',
240
+ nullable: true,
241
+ input: {
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
+ );
250
+ },
176
251
  }),
177
- });
252
+ }));
178
253
  ```
179
254
 
180
255
  ## Relations
181
256
 
182
- Drizzles relational query builder allows you to define the relationships between your tables. The
183
- `t.relation` method makes it easy to add fields to your GraphQL API that implement those
184
- 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.
185
259
 
186
- ```ts
187
- builder.drizzleObject('profiles', {
188
- name: 'Profile',
189
- fields: (t) => ({
190
- bio: t.exposeString('bio'),
191
- }),
192
- });
260
+ ### Published posts and profiles
193
261
 
194
- builder.drizzleObject('posts', {
195
- name: 'Post',
196
- fields: (t) => ({
197
- title: t.exposeString('title'),
198
- author: t.relation('author'),
199
- }),
200
- });
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:
201
264
 
202
- builder.drizzleObject('users', {
203
- name: 'User',
204
- fields: (t) => ({
205
- firstName: t.exposeString('firstName'),
206
- profile: t.relation('profile'),
207
- 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
+ },
208
274
  }),
209
- });
275
+ }),
210
276
  ```
211
277
 
212
- The relation will automatically define GraphQL fields of the appropriate type based on the relation
213
- 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).
214
283
 
215
- ## Relation queries
284
+ ### Relation queries
216
285
 
217
- For some cases, exposing relations as fields without any customization works great, but in some
218
- cases you may want to apply some filtering or ordering to your relations. This can be done by
219
- 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:
220
289
 
221
290
  ```ts
222
- builder.drizzleObject('users', {
223
- name: 'User',
224
- fields: (t) => ({
225
- firstName: t.exposeString('firstName'),
226
- posts: t.relation('posts', {
227
- args: {
228
- limit: t.arg.int(),
229
- offset: t.arg.int(),
230
- },
231
- // query callback receives (args, ctx, pathInfo)
232
- query: (args) => ({
233
- limit: args.limit ?? 10,
234
- offset: args.offset ?? 0,
235
- where: {
236
- published: true,
237
- },
238
- orderBy: {
239
- updatedAt: 'desc',
240
- },
241
- }),
242
- }),
243
- drafts: t.relation('posts', {
244
- query: {
245
- where: {
246
- published: false,
247
- },
248
- },
249
- }),
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' },
250
298
  }),
251
- });
299
+ }),
252
300
  ```
253
301
 
254
302
  The query API enables you to define args and convert them into parameters that will be passed into
255
303
  the relational query builder. The `query` callback receives `(args, ctx, pathInfo)` where `pathInfo`
256
- contains the GraphQL query path information (`path` and `segments`). You can read more about the
257
- relation query builder api [here](https://orm.drizzle.team/docs/rqb#querying)
304
+ describes where in the GraphQL query the relation is being loaded:
305
+
306
+ - `path`: a list of `ParentType.fieldName` strings, from the root field down to the field being
307
+ resolved (eg. `['Query.user', 'User.posts']`).
308
+ - `segments`: one object per entry in `path`, with `field` (the field name), `alias` (the alias used
309
+ in the query, or the field name if none), `parentType` (the name of the type the field is defined
310
+ on), and `isList` (whether the field returns a list).
311
+
312
+ You can read more about the relation query builder api
313
+ [here](https://orm.drizzle.team/docs/rqb-v2)
314
+
315
+ ### Fallback queries
258
316
 
259
- ## Drizzle Fields
317
+ A field whose data is not on the row it resolves from is loaded with a fallback query. This happens
318
+ when:
260
319
 
261
- Drizzle objects and relations allow you to define parts of your schema backed by your drizzle
262
- schema, but don't provide a clear entry point into this Graph of data. To make your drizzle objects
263
- queryable, we will need to add fields that return our drizzle objects. This can be done using the
264
- `t.drizzleField` method. This can be used to define fields on the root `Query` type, or any other
265
- object type in your schema:
320
+ - The parent row was not loaded through a `t.drizzleField`, `t.relation`, or connection. This
321
+ covers rows a resolver queried itself, and rows that came from somewhere else entirely.
322
+ - A `drizzleField` resolver did not pass the result of `query()` to drizzle.
323
+ - A relation's arguments conflict with a sibling selection of the same relation that was planned
324
+ first.
325
+
326
+ Fallback queries are batched. Every row of a table that needs the same selection in the same tick
327
+ is loaded with one `findMany` filtered on the primary key, or on the first unique column for a
328
+ table that has no primary key, and the rows are matched back to their parents. If the query does
329
+ not return a row for a parent, because it was deleted since it was loaded, or never came from the
330
+ table, that field rejects with `Model users(1) not found`, where the value in parentheses is the
331
+ key that was looked up.
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:
266
350
 
267
351
  ```ts
268
- builder.queryType({
269
- fields: (t) => ({
270
- post: t.drizzleField({
271
- type: 'posts',
272
- args: {
273
- id: t.arg.id({ required: true }),
274
- },
275
- resolve: (query, root, args, ctx) =>
276
- db.query.posts.findFirst(
277
- query({
278
- where: {
279
- id: Number.parseInt(args.id, 10),
280
- },
281
- }),
282
- ),
283
- }),
284
- posts: t.drizzleField({
285
- type: ['posts'],
286
- resolve: (query, root, args, ctx) => db.query.posts.findMany(query()),
287
- }),
288
- }),
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
+ },
289
367
  });
290
368
  ```
291
369
 
292
- The `resolve` function of a `drizzleField` will be passed a `query` function that MUST be called and
293
- passed to a drizzle `findOne` or `findMany` query. The `query` function optionally accepts any
294
- arguments that are normally passed into the query, and will merge these options with the selection
295
- used to resolve data for the nested GraphQL selections.
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.
296
374
 
297
- ## Variants
298
375
 
299
- It is often useful to be able to define multiple object types based on the same table. This can be
300
- done using a feature called `variants`. The `variants` API consists of 3 parts:
376
+ ### Many-to-many relations
301
377
 
302
- - A `variant` option that can be passed instead of a name on `drizzleObjects`
303
- - The ability to pass an `ObjectRef` to the `type` option of `t.relation` and other similar fields
304
- - A `t.field` method that works similar to `t.relation`, but is used to define a GraphQL field that
305
- references a variant of the same record.
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:
306
380
 
307
- ```ts
308
- // Viewer type representing the current user
309
- export const Viewer = builder.drizzleObject('users', {
310
- variant: 'Viewer',
381
+ ```typescript
382
+ builder.drizzleObject('posts', {
383
+ name: 'Post',
311
384
  select: {},
312
385
  fields: (t) => ({
313
386
  id: t.exposeID('id'),
314
- // A reference to the normal user type so normal user fields can be queried
315
- user: t.variant('users'),
316
- // Adding drafts to View allows a user to fetch their own drafts without exposing it for Other Users in the API
317
- drafts: t.relation('posts', {
318
- query: {
319
- where: {
320
- published: false,
321
- },
322
- orderBy: {
323
- updatedAt: 'desc',
324
- },
325
- },
326
- }),
327
- }),
328
- });
329
-
330
- builder.queryType({
331
- fields: (t) => ({
332
- me: t.drizzleField({
333
- // We can use the ref returned by builder.drizzleObject to define our `drizzleField`
334
- type: Viewer,
335
- resolve: (query, root, args, ctx) =>
336
- db.query.users.findFirst(
337
- query({
338
- where: {
339
- id: ctx.user.id,
340
- },
341
- }),
342
- ),
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,
343
393
  }),
344
394
  }),
345
395
  });
346
-
347
- builder.drizzleNode('users', {
348
- name: 'User',
349
- fields: (t) => ({
350
- firstName: t.exposeString('firstName'),
351
- // This field will resolve to the Viewer type, but be set to null if the user is not the current user
352
- viewer: t.variant(Viewer, {
353
- isNull: (user, args, ctx) => user.id !== ctx.user?.id,
354
- }),
355
- }),
396
+ export const Media = builder.drizzleObject('media', {
397
+ name: 'Media',
398
+ fields: (t) => ({ url: t.exposeString('url'), uploadedBy: t.relation('uploadedBy') }),
356
399
  });
357
400
  ```
358
401
 
402
+ `t.relation` and `t.relatedConnection` expose the Media type directly; no GraphQL type for the
403
+ junction table is needed.
359
404
 
360
- ## Related field
405
+ ### Related field
361
406
 
362
407
  The `t.relatedField` method allows you to define a field based on a relation that uses custom
363
408
  selections, including aggregations like counts. This is useful when you want to expose derived data
364
409
  from a relation without loading the full related records.
365
410
 
366
- ### Count aggregations
367
-
368
- 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:
369
413
 
370
414
  ```ts
371
- import { count } from 'drizzle-orm';
415
+ import { and, eq } from 'drizzle-orm';
416
+ import { posts } from './tables';
372
417
 
373
418
  builder.drizzleNode('users', {
374
419
  name: 'User',
375
420
  id: { column: (user) => user.id },
376
421
  fields: (t) => ({
377
422
  firstName: t.exposeString('firstName'),
378
- // Add a count of related posts
423
+ // Count only this author’s published posts
379
424
  postsCount: t.relatedField('posts', {
380
425
  type: 'Int',
381
426
  // buildFilter creates the correct WHERE clause for the relation
382
- select: (buildFilter) => ({
383
- extras: {
384
- postsCount: (parent) => db.$count(posts, buildFilter(parent)),
385
- },
386
- }),
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
+ },
387
439
  resolve: (user) => user.postsCount,
388
440
  }),
389
441
  }),
390
442
  });
391
443
  ```
392
444
 
393
- The `buildFilter` function passed to `select` generates the appropriate SQL filter based on the
394
- relation definition. This is no different than using `t.field`, but the `buildFilter` helper makes
395
- 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.
396
448
 
397
- ## Related count
449
+ `t.relatedField` also accepts the normal field options (`description`, `deprecationReason`,
450
+ `extensions`, and options added by other plugins like `authScopes`). Its `resolve` may be async,
451
+ and receives the resolve `info` as its fourth argument.
398
452
 
399
- For the common case of counting related records, there's a simpler `t.relatedCount` method that handles
400
- all the boilerplate for you:
453
+ ## Selections
401
454
 
402
- ```ts
403
- builder.drizzleNode('users', {
404
- name: 'User',
405
- id: { column: (user) => user.id },
406
- fields: (t) => ({
407
- firstName: t.exposeString('firstName'),
408
- // Simple count of all related comments
409
- commentsCount: t.relatedCount('comments'),
410
- // Count with a where filter
411
- publishedPostsCount: t.relatedCount('posts', {
412
- where: eq(posts.published, true),
413
- }),
414
- }),
415
- });
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.
457
+
458
+ ### Field selections
459
+
460
+ An exposed column, such as `t.exposeString('firstName')`, contributes its own selection. A custom
461
+ resolver must declare the columns it reads:
462
+
463
+ ```typescript
464
+ fullName: t.string({
465
+ select: { columns: { firstName: true, lastName: true } },
466
+ resolve: (user) => `${user.firstName} ${user.lastName}`,
467
+ }),
416
468
  ```
417
469
 
418
- The `where` option accepts either a static SQL filter or a function that receives the field arguments
419
- and context:
470
+ ### Only load a profile when requested
420
471
 
421
- ```ts
422
- publishedPostsCount: t.relatedCount('posts', {
423
- args: {
424
- category: t.arg.string(),
425
- },
426
- where: (args, ctx) => args.category
427
- ? and(eq(posts.published, true), eq(posts.category, args.category))
428
- : eq(posts.published, true),
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:
474
+
475
+ ```typescript
476
+ bio: t.string({
477
+ nullable: true,
478
+ select: { with: { profile: true } },
479
+ resolve: (user) => user.profile?.bio,
429
480
  }),
430
481
  ```
431
482
 
432
- ## Relay integration
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.
485
+
486
+ ### Computed SQL values
487
+
488
+ The same mechanism supports SQL expressions through `extras`. For example, a field can select
489
+ `lowercaseName` only when the GraphQL operation requests it:
433
490
 
434
- Relay provides some very useful best practices that are useful for most GraphQL APIs. To make it
435
- easy to comply with these best practices, the drizzle plugin has built in support for defining relay
436
- `nodes` and `connections`.
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
+ ```
437
501
 
438
- ## Relay Nodes
502
+ ### Type selections
439
503
 
440
- Defining relay nodes works just like defining normal `drizzleObject`s, but requires specifying a
441
- column to use as the nodes `id` field.
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:
442
507
 
443
508
  ```ts
444
- builder.drizzleNode('users', {
509
+ const User = builder.drizzleObject('users', {
445
510
  name: 'User',
446
- id: {
447
- column: (user) => user.id,
448
- // other options for the ID field can be passed here
511
+ select: {
512
+ columns: {
513
+ firstName: true,
514
+ lastName: true,
515
+ },
516
+ with: {
517
+ profile: true,
518
+ },
519
+ extras: {
520
+ lowercaseName: (users, { sql }) => sql<string>`lower(${users.firstName})`
521
+ },
522
+ },
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
+ };
449
536
  },
450
- fields: (t) => ({
451
- firstName: t.exposeString('firstName'),
452
- lastName: t.exposeString('lastName'),
453
- }),
454
537
  });
455
538
  ```
456
539
 
457
- The id column can also be set to a list of columns for types with a composite primary key.
540
+ Any selections added to the type will be available to consume in all resolvers. Columns that are not
541
+ selected can still be exposed as before.
458
542
 
459
- ## Related connections
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).
460
546
 
461
- To implement a relation as a connection, you can use `t.relatedConnection` instead of `t.relation`:
547
+ ## Connections
462
548
 
463
- ```ts
464
- builder.drizzleNode('users', {
465
- name: 'User',
466
- fields: (t) => ({
467
- posts: t.relatedConnection('posts'),
468
- }),
469
- });
470
- ```
471
-
472
- This will automatically define the `Connection`, and `Edge` types, and their respective fields. To
473
- customize the Connection and Edge types, options for these types can be passed as additional
474
- arguments to `t.relatedConnection`, just like `t.connection` from the relay plugin. See the
475
- [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.
476
552
 
477
- You can also define a `query` like with `t.relation`. The only difference with `t.relatedConnection`
478
- 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).
479
555
 
480
- To comply with the relay spec and efficiently support backwards pagination, some queries need to be
481
- performed in reverse order, which requires inverting the orderBy clause. To do this automatically,
482
- the `t.relatedConnection` method accepts orderBy as an object like `{ asc: column }` or
483
- `{ desc: column }` rather than using the `asc(column)` and `desc(column)` helpers from drizzle.
484
- orderBy can still be returned as either a single column or array when ordering by multiple columns.
556
+ ### Page through published posts
485
557
 
486
- Ordering defaults to using the table `primaryKey`, and the orderBy columns will also be used to
487
- 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:
488
560
 
489
- ```ts
490
- builder.drizzleNode('users', {
491
- name: 'User',
492
- fields: (t) => ({
493
- posts: t.relatedConnection('posts', {
494
- query: () => ({
495
- where: {
496
- published: true,
497
- },
498
- orderBy: {
499
- id: 'desc',
500
- },
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' },
501
574
  }),
502
- }),
503
- }),
504
- });
575
+ );
576
+ },
577
+ }),
505
578
  ```
506
579
 
507
- ### Connection totalCount
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.
508
584
 
509
- You can add a `totalCount` field to your connection by setting the `totalCount` option to `true`:
585
+ A client requests the first page with `first`:
510
586
 
511
- ```ts
512
- builder.drizzleNode('users', {
513
- name: 'User',
514
- fields: (t) => ({
515
- posts: t.relatedConnection('posts', {
516
- totalCount: true,
517
- query: () => ({
518
- where: {
519
- published: true,
520
- },
521
- orderBy: {
522
- id: 'desc',
523
- },
524
- }),
525
- }),
526
- }),
527
- });
587
+ ```graphql
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
597
+ }
598
+ }
599
+ }
600
+ pageInfo {
601
+ endCursor
602
+ hasNextPage
603
+ }
604
+ }
605
+ }
528
606
  ```
529
607
 
530
- This will automatically add a `totalCount` field to the connection type. The count query is only
531
- executed when the `totalCount` field is actually requested in the GraphQL query, and it's included
532
- as a subquery in the main database query for efficiency.
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`:
533
610
 
534
611
  ```graphql
535
- query {
536
- user(id: "...") {
537
- posts(first: 10) {
538
- totalCount
539
- edges {
540
- node {
541
- id
542
- title
543
- }
544
- }
612
+ query PreviousPosts($before: String) {
613
+ posts(last: 2, before: $before) {
614
+ nodes {
615
+ title
616
+ }
617
+ pageInfo {
618
+ startCursor
619
+ hasPreviousPage
545
620
  }
546
621
  }
547
622
  }
548
623
  ```
549
624
 
550
- ## Drizzle connections
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 }`.
628
+
629
+ ### Related connections
630
+
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:
633
+
634
+ ```typescript
635
+ postsConnection: t.relatedConnection('posts', {
636
+ query: { where: { published: true }, orderBy: { createdAt: 'desc' } },
637
+ totalCount: true,
638
+ }),
639
+ ```
640
+
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.
645
+
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).
648
+
649
+ ### Connection totalCount
650
+
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.
654
+
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.
659
+
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.
551
664
 
552
- Similar to `t.drizzleField`, `t.drizzleConnection` allows you to define a connection field that acts
553
- as an entry point to your drizzle query. The `orderBy` in `t.drizzleConnection` works the same way
554
- as it does for `t.relatedConnection`
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.
675
+
676
+ ## Ordering and cursors
677
+
678
+ Connections page with a cursor that records where in the ordering the previous page ended. This
679
+ applies to `t.relatedConnection`, `t.drizzleConnection`, and `drizzleConnectionHelpers`.
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
+
706
+ #### Unique orderings
707
+
708
+ `orderBy: { createdAt: 'desc' }` does not describe a unique ordering. Rows with the same timestamp
709
+ can be returned in a different order on each query, so paging through the connection may return a row
710
+ twice, or skip it.
711
+
712
+ Pothos adds the primary key to the ordering when the columns you provide are not already unique, so
713
+ this:
555
714
 
556
715
  ```ts
557
- builder.queryFields((t) => ({
558
- posts: t.drizzleConnection({
559
- type: 'posts',
560
- resolve: (query, root, args, ctx) =>
561
- db.query.posts.findMany(
562
- query({
563
- where: {
564
- published: true,
565
- },
566
- orderBy: {
567
- id: 'desc',
568
- },
569
- }),
570
- ),
571
- }),
572
- }));
716
+ orderBy: { createdAt: 'desc' }
573
717
  ```
574
718
 
575
- ### drizzleConnection totalCount
719
+ is queried as `createdAt desc, id desc`. You can add the tie breaker yourself if you want it in a
720
+ different position or direction:
721
+
722
+ ```ts
723
+ orderBy: { createdAt: 'desc', id: 'asc' }
724
+ ```
725
+
726
+ Orderings that already contain the primary key, or a unique column or constraint whose columns are
727
+ all marked `notNull()`, are used as provided. Nullable unique columns are not treated as unique,
728
+ because rows containing a `null` are still tied with each other. Uniqueness declared with
729
+ `uniqueIndex()` is not detected, so those orderings still get the primary key appended.
730
+
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.
576
734
 
577
- You can add a `totalCount` field to a `drizzleConnection` by providing a `totalCount` callback function that returns the count:
735
+ Nothing is appended when Pothos cannot find a key it can rely on, which means tables with no primary
736
+ key, and tables whose only unique column is nullable. Those connections keep the ordering you wrote,
737
+ so give them a tie breaker yourself.
738
+
739
+ #### Nullable ordering columns
740
+
741
+ A row whose ordering value is `null` cannot be paged past. `null` is not greater or less than
742
+ anything in SQL, so no row compares as coming after it and the next page comes back empty. The same
743
+ applies to an ordering expression that can evaluate to `null`.
744
+
745
+ Order by columns that are `notNull()`, or give the expression a real value to fall back to with
746
+ `coalesce`.
747
+
748
+ #### Cursor values and precision
749
+
750
+ A cursor stores the values your driver returned to JavaScript, and those values are compared against
751
+ the column when the next page is requested. If the value in the cursor is not the value the database
752
+ has, the comparison will not match the rows you expect.
753
+
754
+ This comes up with timestamps. `timestamp({ mode: 'date' })` returns a JavaScript `Date`, which only
755
+ holds milliseconds, while a Postgres `timestamptz` column stores microseconds. A row stored at
756
+ `.086068` produces a cursor containing `.086`, and `created_at < '.086'` does not match the other
757
+ rows in that millisecond, so they are never returned on any page.
758
+
759
+ There are two ways to avoid this.
760
+
761
+ The first is to map the column so the full value reaches JavaScript. `mode: 'string'` returns the
762
+ timestamp as text, with the microseconds intact:
763
+
764
+ ```ts
765
+ export const posts = pgTable('posts', {
766
+ id: serial('id').primaryKey(),
767
+ createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).notNull(),
768
+ });
769
+ ```
770
+
771
+ The cursor stores that text, and Postgres parses it back to the value it stored. The column is still
772
+ what gets ordered and compared, so an index on it is still used. The field is typed as a `string`
773
+ rather than a `Date`.
774
+
775
+ The second is to order by an expression that returns the full value, which lets you keep the `Date`
776
+ mapping:
578
777
 
579
778
  ```ts
580
779
  builder.queryFields((t) => ({
581
780
  posts: t.drizzleConnection({
582
781
  type: 'posts',
583
- // Use db.$count() for a simple count query
584
- totalCount: () => db.$count(posts, eq(posts.published, true)),
585
- resolve: (query, root, args, ctx) =>
586
- db.query.posts.findMany(
782
+ resolve: (query) => {
783
+ return db.query.posts.findMany(
587
784
  query({
588
- where: {
589
- published: true,
590
- },
591
- orderBy: {
592
- id: 'desc',
785
+ extras: {
786
+ createdAtExact: (table) =>
787
+ sql`to_char(${table.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
593
788
  },
789
+ orderBy: { createdAtExact: 'desc' },
594
790
  }),
595
- ),
791
+ );
792
+ },
596
793
  }),
597
794
  }));
598
795
  ```
599
796
 
600
- 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.
797
+ #### Ordering by an expression
798
+
799
+ `orderBy` accepts the name of any extra declared in the same query. Pothos orders by the expression,
800
+ builds the cursor from the value it returns, and compares the expression when paging:
801
+
802
+ ```ts
803
+ query({
804
+ extras: { titleLength: (table) => sql`length(${table.title})` },
805
+ orderBy: { titleLength: 'asc' },
806
+ })
807
+ ```
808
+
809
+ Extras used this way should be written as a callback. Drizzle aliases the table it queries and passes
810
+ that alias to the callback, so an expression built from the imported table object will reference a
811
+ name the query does not have.
601
812
 
602
- 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.
813
+ With `drizzleConnectionHelpers`, declare the extra in `query` rather than in `select`. Only `query`
814
+ is read when the ordering is resolved.
603
815
 
604
- ### Indirect relations as connections
816
+ The expression also needs to sort in the same order its values compare. A timestamp formatted as
817
+ fixed width UTC text sorts the same way it does chronologically, but a local time or variable width
818
+ format does not, and will skip rows.
605
819
 
606
- In many cases, you can define many to many connections via drizzle relations, allowing the `relatedConnection` API to work across
607
- more complex relations. In some cases you may want to define a connection for a relation not expressed directly as a relation in
608
- your drizzle schema. For these cases, you can use the `drizzleConnectionHelpers`, which allows you to define connection with the `t.connection` API.
820
+ ## Relay nodes
821
+
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.
824
+
825
+ ### Define a node
826
+
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:
829
+
830
+ ```ts
831
+ builder.drizzleNode('users', {
832
+ name: 'User',
833
+ id: { column: (user) => user.id },
834
+ fields: (t) => ({ firstName: t.exposeString('firstName') }),
835
+ });
836
+ ```
837
+
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.
877
+
878
+ ## Type variants
879
+
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.
882
+
883
+ The API has three parts:
884
+
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.
888
+
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:
893
+
894
+ ```ts
895
+ const Viewer = builder.drizzleObject('users', {
896
+ variant: 'Viewer',
897
+ select: {},
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' },
906
+ },
907
+ }),
908
+ };
909
+ },
910
+ });
911
+ ```
912
+
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:
609
918
 
610
919
  ```typescript
611
- // Create a drizzle object for the node type of your connection
612
- const Role = builder.drizzleObject('roles', {
613
- name: 'Role',
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'),
614
924
  fields: (t) => ({
615
- id: t.exposeID('id'),
616
- name: t.exposeString('name'),
925
+ user: t.variant('users'),
926
+ email: t.exposeString('email'),
927
+ drafts: t.relation('posts', {
928
+ query: { where: { published: false }, orderBy: { id: 'asc' } },
929
+ }),
617
930
  }),
618
931
  });
932
+ ```
933
+
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
+ ```
619
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.
620
953
 
954
+ ### Conditionally expose another variant
621
955
 
622
- // Create connection helpers for the media type. This will allow you
623
- // to use the normal t.connection with a drizzle type
624
- const rolesConnection = drizzleConnectionHelpers(builder, 'userRoles', {
625
- // select the data needed for the nodes
626
- select: (nestedSelection) => ({
627
- with: {
628
- // use nestedSelection to create the correct selection for the node
629
- role: nestedSelection(),
630
- },
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,
631
964
  }),
632
- // resolve the node from the returned list item
633
- resolveNode: (userRole) => userRole.role,
965
+ );
966
+ ```
967
+
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:
979
+
980
+ ```ts
981
+ const PostSummary = builder.drizzleObject('posts', {
982
+ variant: 'PostSummary',
983
+ fields: (t) => ({ title: t.exposeString('title') }),
634
984
  });
635
985
 
636
- builder.drizzleObjectField('User', 'rolesConnection', (t) =>
637
- t.connection({
638
- // The type for the Node
639
- type: Role,
640
- // since we are not using t.relatedConnection we need to manually
641
- // include the selections for our connection
642
- select: (args, ctx, nestedSelection) => ({
643
- with: {
644
- userRoles: rolesConnection.getQuery(args, ctx, nestedSelection),
645
- },
646
- }),
647
- // This helper takes a list of nodes and formats them for the connection
648
- resolve: (user, args, ctx) => {
649
- return rolesConnection.resolve(user.userRoles, args, ctx, user);
986
+ builder.drizzleObjectField('users', 'postSummaries', (t) =>
987
+ t.relation('posts', {
988
+ type: PostSummary,
989
+ query: {
990
+ where: { published: true },
991
+ orderBy: { id: 'asc' },
650
992
  },
651
993
  }),
652
994
  );
653
995
  ```
654
996
 
655
- The above example assumes that you are paginating a relation to a join table, where the pagination
656
- args are applied based on the relation to that join table, but the nodes themselves are nested
657
- deeper.
997
+ Two variants selected for the same row have their type-level selections merged. Conflicting
998
+ relation arguments or extras can fail; see
999
+ [Conflicting selections between variants](https://pothos-graphql.dev/docs/plugins/drizzle/query-planning#conflicting-selections-between-variants).
658
1000
 
659
- `drizzleConnectionHelpers` can also be used to manually create a connection where the edge and
660
- connections share the same model, and pagination happens directly on a relation to nodes type (even
661
- if that relation is nested).
1001
+ ## Interfaces
662
1002
 
663
- ```ts
664
- const commentConnectionHelpers = drizzleConnectionHelpers(builder, 'Comment');
1003
+ ### Interfaces
1004
+
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.
1007
+
1008
+ ### Define the interface
665
1009
 
666
- const SelectPost = builder.drizzleObject('posts', {
1010
+ ```typescript
1011
+ export const Viewer = builder.drizzleInterface('users', {
1012
+ variant: 'Viewer',
1013
+ select: { columns: { id: true, role: true } },
1014
+ resolveType: (user) => (user.role === 'editor' ? 'EditorViewer' : 'AuthorViewer'),
667
1015
  fields: (t) => ({
668
- title: t.exposeString('title'),
669
- comments: t.connection({
670
- type: commentConnectionHelpers.ref,
671
- select: (args, ctx, nestedSelection) => ({
672
- with: {
673
- comments: commentConnectionHelpers.getQuery(args, ctx, nestedSelection),
674
- },
675
- }),
676
- resolve: (parent, args, ctx) => commentConnectionHelpers.resolve(parent.comments, args, ctx),
1016
+ user: t.variant('users'),
1017
+ email: t.exposeString('email'),
1018
+ drafts: t.relation('posts', {
1019
+ query: { where: { published: false }, orderBy: { id: 'asc' } },
677
1020
  }),
678
1021
  }),
679
1022
  });
680
1023
  ```
681
1024
 
682
- Arguments, ordering and filtering can also be defined in the helpers:
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.
1027
+
1028
+ ### Selecting a viewer implementation
1029
+
1030
+ ```typescript
1031
+ builder.drizzleObject('users', {
1032
+ variant: 'EditorViewer',
1033
+ interfaces: [Viewer],
1034
+ select: { columns: { id: true, role: true } },
1035
+ fields: (t) => ({ canReviewSubmissions: t.boolean({ resolve: () => true }) }),
1036
+ });
1037
+ builder.drizzleObject('users', {
1038
+ variant: 'AuthorViewer',
1039
+ interfaces: [Viewer],
1040
+ select: { columns: { id: true, role: true } },
1041
+ });
1042
+ ```
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
+
1055
+ Fields can be added to an interface later with `builder.drizzleInterfaceField` and
1056
+ `builder.drizzleInterfaceFields`, which take the interface ref (or the table name) like their
1057
+ `drizzleObjectField(s)` counterparts:
683
1058
 
684
1059
  ```ts
685
- const rolesConnection = drizzleConnectionHelpers(builder, 'userRoles', {
686
- // define additional arguments
687
- args: (t) => ({}),
688
- query: (args) => ({
689
- // define an order
690
- orderBy: {
691
- roleId: 'asc',
1060
+ builder.drizzleInterfaceFields(Viewer, (t) => ({
1061
+ publishedPosts: t.relatedConnection('posts', {
1062
+ query: { where: { published: true } },
1063
+ }),
1064
+ }));
1065
+ ```
1066
+
1067
+ An object type implementing a drizzle interface must be based on the same table. A plain object
1068
+ type that implements one is planned with the interface's table, so fragments on it will select the
1069
+ relations it inherits.
1070
+
1071
+ ## Query planning
1072
+
1073
+ This page describes how the plugin turns a GraphQL query into drizzle queries, which is worth
1074
+ knowing when a schema issues more queries than you expect.
1075
+
1076
+ ### How fields get their data
1077
+
1078
+ A field either reads its data from a row that has already been loaded, or runs a query of its own.
1079
+
1080
+ A field's `select` is planned into the query of the nearest ancestor that runs one: a
1081
+ `t.drizzleField`, a `t.relation`, a connection, or a [fallback query](https://pothos-graphql.dev/docs/plugins/drizzle/relations#fallback-queries). The field
1082
+ then reads what it needs off the loaded row, without a query of its own. A field runs its own query
1083
+ when its `resolve` queries drizzle directly, and when the plugin issues a fallback query for a
1084
+ relation that is missing from the row.
1085
+
1086
+ A field can do both. A `t.drizzleField`, or any other field with a `select`, nested under one of
1087
+ those ancestors has its `select` planned into the parent's row, and still runs its own query when
1088
+ it resolves.
1089
+
1090
+ A field-level `select` is merged into the same query as its siblings and the type-level selection,
1091
+ rather than being kept separate for that field. Two selections of the same relation share a place
1092
+ in that query only when their arguments (`where`, `orderBy`, `limit`, ...) match. When they differ,
1093
+ the first one planned wins, and the other is loaded with a query of its own. A type-level `select`
1094
+ is planned before any field's selection, no matter where they appear in the document, and fields
1095
+ are planned in the order they are selected.
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
692
1106
  }
693
- // define a filter
694
- where: {
695
- accepted: true,
1107
+ oldest: posts(oldestFirst: true) {
1108
+ title
696
1109
  }
697
- }),
698
- // select the data needed for the nodes
699
- select: (nestedSelection) => ({
700
- with: {
701
- // use nestedSelection to create the correct selection for the node
702
- role: nestedSelection(),
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',
703
1130
  },
704
1131
  }),
705
- // resolve the node from the returned list item
706
- resolveNode: (userRole) => userRole.role,
707
- });
1132
+ }),
1133
+ ```
708
1134
 
1135
+ ### Nested selections
709
1136
 
710
- builder.drizzleObjectField('User', 'rolesConnection', (t) =>
711
- t.connection({
712
- type: Role,
713
- // add the args from the connection helper to the field
714
- args: rolesConnection.getArgs(),
715
- select: (args, ctx, nestedSelection) => ({
716
- with: {
717
- userRoles: rolesConnection.getQuery(args, ctx, nestedSelection),
1137
+ A `select` function receives `nestedSelection`, which plans the selection beneath the field for the
1138
+ field's own type. It returns the query config for that table, or a `many` config for a list field.
1139
+ Any keys you pass in are kept as they were given, so `columns` passed to `nestedSelection` will
1140
+ still narrow the parent shape:
1141
+
1142
+ ```ts
1143
+ builder.drizzleObject('users', {
1144
+ name: 'User',
1145
+ fields: (t) => ({
1146
+ previewPosts: t.field({
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
+ };
718
1159
  },
1160
+ resolve: (user) => user.posts,
719
1161
  }),
720
- resolve: (user, args, ctx) => rolesConnection.resolve(user.userRoles, args, ctx, user),
721
1162
  }),
722
- );
1163
+ });
1164
+ ```
1165
+
1166
+ `nestedSelection(query, path, type)` takes two more arguments:
1167
+
1168
+ - `path`: a list of field names, for selecting a field nested under the field's type. A segment can
1169
+ also be written as `{ name, type }` to pin the implementation the field must be found under.
1170
+ - `type`: a member of an interface or union, to read the selection as that type.
1171
+
1172
+ The `nestedSelection` function also carries the `path` and `segments` of the field being planned,
1173
+ which are described under [Relation queries](https://pothos-graphql.dev/docs/plugins/drizzle/relations#relation-queries).
1174
+
1175
+ ### Async selections
1176
+
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
+ }
723
1231
  ```
724
1232
 
725
- ### Extending connection edges
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.
726
1237
 
727
- In some cases you may want to expose some data from an indirect connection on the edge object.
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:
728
1244
 
729
1245
  ```typescript
730
- const rolesConnection = drizzleConnectionHelpers(builder, 'userRoles', {
731
- select: (nestedSelection) => ({
732
- with: {
733
- role: nestedSelection(),
734
- },
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 } } : {},
735
1255
  }),
736
- resolveNode: (userRole) => userRole.role,
1256
+ select: (nestedSelection) => {
1257
+ return {
1258
+ columns: { caption: true },
1259
+ with: { media: nestedSelection() },
1260
+ };
1261
+ },
1262
+ resolveNode: (attachment) => attachment.media,
737
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.
738
1274
 
739
- builder.drizzleObjectFields('User', (t) => ({
740
- rolesConnection: t.connection(
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(
741
1284
  {
742
- type: Role,
743
- select: (args, ctx, nestedSelection) => ({
744
- with: {
745
- userRoles: rolesConnection.getQuery(args, ctx, nestedSelection),
746
- },
747
- }),
748
- resolve: (user, args, ctx) =>
749
- rolesConnection.resolve(
750
- user.userRoles,
751
- args,
752
- ctx,
753
- user,
754
- ),
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
+ },
755
1297
  },
756
1298
  {},
757
- // options for the edge object
758
1299
  {
759
- // define the additional fields on the edge object
760
1300
  fields: (edge) => ({
761
- createdAt: edge.field({
762
- type: 'DateTime',
763
- // the parent shape for edge fields is inferred from the connections resolve function
764
- resolve: (role) => role.createdAt,
1301
+ caption: edge.string({
1302
+ nullable: true,
1303
+ resolve: (attachment) => attachment.caption,
765
1304
  }),
766
1305
  }),
767
1306
  },
768
1307
  ),
769
- }));
1308
+ );
770
1309
  ```
771
1310
 
772
- ### `drizzleConnectionHelpers` for non-relation connections
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.
773
1314
 
774
- You can also use `drizzleConnectionHelpers` for non-relation connections where you want a connection where your edges and nodes are not the same type.
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).
775
1318
 
776
- 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
1319
+ ### Querying the rows in a resolver
777
1320
 
778
- ```typescript
779
- const rolesConnection = drizzleConnectionHelpers(builder, 'userRoles', {
780
- select: (nestedSelection) => ({
781
- with: {
782
- role: nestedSelection(),
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 }],
783
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:
1358
+
1359
+ ```ts
1360
+ const builder = new SchemaBuilder<{
1361
+ DrizzleRelations: typeof relations;
1362
+ AsyncSelections: true;
1363
+ Context: {
1364
+ previewSize: () => Promise<number>;
1365
+ };
1366
+ }>({
1367
+ plugins: [DrizzlePlugin],
1368
+ drizzle: {
1369
+ client: db,
1370
+ getTableConfig,
1371
+ relations,
1372
+ },
1373
+ });
1374
+ ```
1375
+
1376
+ With the opt-in, `select` functions, relation `query` callbacks, `relatedCount` `where` callbacks,
1377
+ and the `select` and `query` callbacks of `drizzleConnectionHelpers` may be async. Without it they
1378
+ are typed as synchronous, and an async callback is a type error.
1379
+
1380
+ Pothos waits for these callbacks before executing the planned query. `t.relation`, `t.relatedCount`,
1381
+ `t.drizzleField`, `t.drizzleConnection` and `t.relatedConnection` settle their plan before the
1382
+ resolver runs, and need no changes.
1383
+
1384
+ ```ts
1385
+ builder.drizzleObject('users', {
1386
+ name: 'User',
1387
+ fields: (t) => ({
1388
+ previewPosts: t.field({
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
+ },
1401
+ resolve: (user) => user.posts,
1402
+ }),
784
1403
  }),
785
- resolveNode: (userRole) => userRole.role,
786
1404
  });
1405
+ ```
787
1406
 
788
- builder.queryFields((t) => ({
789
- roles: t.connection({
790
- type: Role,
791
- args: {
792
- userId: t.arg.int({ required: true }),
793
- },
794
- nodeNullable: true,
795
- resolve: async (_, args, ctx, info) => {
796
- const query = rolesConnection.getQuery(args, ctx, info);
797
- const userRoles = await db.query.userRoles.findMany({
798
- ...query,
799
- where: {
800
- ...query.where,
801
- userId: args.userId,
802
- },
803
- });
804
- return rolesConnection.resolve(userRoles, args, ctx);
1407
+ ### Await nested selections
1408
+
1409
+ `await` what `nestedSelection` returns before putting it in the selection, and the same for the
1410
+ `nestedQuery` passed as the fourth argument of a `t.relatedField` `select`. A selection that
1411
+ contains the promise itself will throw, and so will a `select` that returns while a nested
1412
+ selection it started is still pending. Include the nested selection in the returned query to
1413
+ load its data.
1414
+
1415
+ ### Connection helpers
1416
+
1417
+ Pass `awaitSelections: true` to `drizzleConnectionHelpers(...).getQuery`, and `await` the query it
1418
+ returns. Without it, an async selection beneath the field throws, and whether there is one
1419
+ depends on the incoming document rather than on the callback you wrote. A connection helper also
1420
+ throws when its own `select` or `query` is async, whatever the document asked for:
1421
+
1422
+ ```ts
1423
+ select: async (args, ctx, nestedSelection) => {
1424
+ return {
1425
+ with: {
1426
+ attachments: await attachments.getQuery(args, ctx, nestedSelection, {
1427
+ awaitSelections: true,
1428
+ }),
805
1429
  },
806
- }),
807
- }));
1430
+ };
1431
+ },
1432
+ ```
1433
+
1434
+ `awaitSelections` is a per-call option, and is available whether or not the schema sets
1435
+ `AsyncSelections`.
1436
+
1437
+ When `AsyncSelections: true`, also `await` a connection helper's `resolve()` before inspecting or
1438
+ spreading its result: an async `query` callback makes resolution asynchronous. Returning its
1439
+ result directly from a GraphQL resolver remains supported.
1440
+
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.