@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
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  type CompatibleTypes,
3
+ completeValue,
3
4
  type ExposeNullability,
4
5
  type FieldKind,
5
6
  type FieldRef,
6
7
  type InferredFieldOptionKeys,
7
8
  type InputFieldMap,
8
- type InputShapeFromFields,
9
9
  type InterfaceParam,
10
10
  isThenable,
11
11
  type MaybePromise,
@@ -18,25 +18,25 @@ import {
18
18
  type ShapeFromTypeParam,
19
19
  type TypeParam,
20
20
  } from '@pothos/core';
21
+ import {
22
+ getLoaderMapping,
23
+ type SelectedFieldNode,
24
+ selectedFieldNames,
25
+ } from '@pothos/selection-mapper';
21
26
  import {
22
27
  and,
23
28
  type BuildQueryResult,
24
29
  type DBQueryConfig,
25
- eq,
26
30
  type InferSelectModel,
27
31
  Many,
32
+ type Relation,
33
+ relationsFilterToSQL,
28
34
  type SQL,
29
35
  type Table,
30
36
  type TableRelationalConfig,
37
+ type TablesRelationalConfig,
31
38
  } from 'drizzle-orm';
32
- import {
33
- type FieldNode,
34
- Kind as GraphQLKind,
35
- type GraphQLResolveInfo,
36
- getNamedType,
37
- isInterfaceType,
38
- isObjectType,
39
- } from 'graphql';
39
+ import type { GraphQLResolveInfo } from 'graphql';
40
40
  import type { DrizzleRef } from './interface-ref.js';
41
41
  import type {
42
42
  DrizzleConnectionShape,
@@ -45,6 +45,7 @@ import type {
45
45
  RelatedConnectionOptions,
46
46
  RelatedCountOptions,
47
47
  RelatedFieldOptions,
48
+ RelatedSelectionFieldOptions,
48
49
  ShapeFromConnection,
49
50
  TypesForRelation,
50
51
  VariantFieldOptions,
@@ -56,8 +57,10 @@ import {
56
57
  getCursorFormatter,
57
58
  wrapConnectionResult,
58
59
  } from './utils/cursors.js';
60
+ import { pathInfoFor } from './utils/path-info.js';
59
61
  import { getRefFromModel } from './utils/refs.js';
60
- import { omitUndefinedKeys, type SelectionMap } from './utils/selections.js';
62
+ import { buildRelationFilter, type RelationQueryBuilder } from './utils/relation-filter.js';
63
+ import type { SelectionMap } from './utils/selections.js';
61
64
 
62
65
  // Workaround for FieldKind not being extended on Builder classes
63
66
  const RootBuilder: {
@@ -68,6 +71,15 @@ const RootBuilder: {
68
71
  ): PothosSchemaTypes.RootFieldBuilder<Types, Shape, Kind>;
69
72
  } = RootFieldBuilder as never;
70
73
 
74
+ // drizzle-orm declares two parameters, but the implementation also takes the table's relations
75
+ // and the full relational config, which lets a `where` filter on related tables.
76
+ type RelationsFilterToSQL = (
77
+ table: Table,
78
+ filter: unknown,
79
+ tableRelations?: TableRelationalConfig['relations'],
80
+ tablesRelations?: TablesRelationalConfig,
81
+ ) => SQL | undefined;
82
+
71
83
  export class DrizzleObjectFieldBuilder<
72
84
  Types extends SchemaTypes,
73
85
  TableConfig extends TableRelationalConfig,
@@ -175,7 +187,6 @@ export class DrizzleObjectFieldBuilder<
175
187
  maxSize = this.builder.options.drizzle?.maxConnectionSize,
176
188
  defaultSize = this.builder.options.drizzle?.defaultConnectionSize,
177
189
  query,
178
- resolve: _,
179
190
  extensions,
180
191
  description,
181
192
  totalCount,
@@ -188,13 +199,6 @@ export class DrizzleObjectFieldBuilder<
188
199
  description?: string;
189
200
  query?: ((args: {}, ctx: {}) => {}) | {};
190
201
  totalCount?: boolean;
191
- resolve?: (
192
- query: {},
193
- parent: unknown,
194
- args: {},
195
- ctx: {},
196
- info: {},
197
- ) => MaybePromise<readonly {}[]>;
198
202
  } = {},
199
203
  connectionOptions = {},
200
204
  edgeOptions = {},
@@ -212,34 +216,72 @@ export class DrizzleObjectFieldBuilder<
212
216
  const ref = options.type ?? getRefFromModel(relationField.targetTableName, this.builder);
213
217
  let typeName: string | undefined;
214
218
 
215
- const buildCountFilter = (parentTable: TableConfig['table']): SQL => {
216
- const { sourceColumns, targetColumns } = relationField;
217
- return and(
218
- ...sourceColumns.map((sourceCol: { name: string }, i: number) =>
219
- eq(targetColumns[i], parentTable[sourceCol.name as never]),
219
+ const filterTotalCount = this.builder.options.drizzle?.filterConnectionTotalCount !== false;
220
+
221
+ // The count for `totalCount` matches the connection's own filter: what the relation selects
222
+ // (including a `through` join and the relation's own `where`) plus the `where` from the
223
+ // field's `query`, so the count agrees with the rows being paginated. The `where` goes
224
+ // through the same `relationsFilterToSQL` the relational query builder puts it through, and
225
+ // `countRows` combines the two in the same order, so the count's predicate is the page
226
+ // query's predicate less the limit and the keyset clauses.
227
+ const buildCount = (
228
+ client: RelationQueryBuilder,
229
+ parentTable: TableConfig['table'],
230
+ where?: unknown,
231
+ ): SQL<number> => {
232
+ const { countRows } = buildRelationFilter(
233
+ client,
234
+ relationField as Relation,
235
+ parentTable as Table,
236
+ );
237
+
238
+ if (!where || !filterTotalCount) {
239
+ return countRows();
240
+ }
241
+
242
+ return countRows(
243
+ (relationsFilterToSQL as RelationsFilterToSQL)(
244
+ relatedTable.table as Table,
245
+ where,
246
+ relatedTable.relations,
247
+ schemaConfig.relations,
220
248
  ),
221
- )!;
249
+ );
222
250
  };
223
251
 
252
+ interface ConnectionFieldQuery {
253
+ limit?: number;
254
+ orderBy?: unknown;
255
+ where?: SQL;
256
+ columns?: Record<string, boolean>;
257
+ extras?: DrizzleCursorConnectionQueryOptions['extras'];
258
+ }
259
+
260
+ // The field's `query` may be async, so the result is a promise when it is. The `PathInfo` is
261
+ // provided by the adapter, and only read for a callback that takes one.
262
+ const resolveFieldQuery = (
263
+ args: PothosSchemaTypes.DefaultConnectionArguments,
264
+ ctx: {},
265
+ pathInfo?: PathInfo,
266
+ ): MaybePromise<ConnectionFieldQuery> =>
267
+ completeValue(
268
+ (typeof query === 'function'
269
+ ? (
270
+ query as (
271
+ args: {},
272
+ ctx: {},
273
+ pathInfo?: PathInfo,
274
+ ) => MaybePromise<{} | null | undefined>
275
+ )(args, ctx, pathInfo)
276
+ : query) as MaybePromise<ConnectionFieldQuery | null | undefined>,
277
+ orEmpty,
278
+ );
279
+
224
280
  const getQuery = (
225
281
  args: PothosSchemaTypes.DefaultConnectionArguments,
226
282
  ctx: {},
227
- pathInfo?: import('./types').PathInfo,
283
+ { limit, orderBy, where, ...fieldQuery }: ConnectionFieldQuery,
228
284
  ) => {
229
- const { limit, orderBy, where, ...fieldQuery } = ((typeof query === 'function'
230
- ? (query as (args: {}, ctx: {}, pathInfo?: import('./types').PathInfo) => {})(
231
- args,
232
- ctx,
233
- pathInfo,
234
- )
235
- : query) ?? {}) as {
236
- limit?: number;
237
- orderBy?: unknown;
238
- where?: SQL;
239
- columns?: Record<string, boolean>;
240
- extras?: DrizzleCursorConnectionQueryOptions['extras'];
241
- };
242
-
243
285
  const { cursorFields, columns, ...connectionQuery } = drizzleCursorConnectionQuery({
244
286
  ctx,
245
287
  maxSize,
@@ -268,26 +310,34 @@ export class DrizzleObjectFieldBuilder<
268
310
  };
269
311
  };
270
312
 
271
- const relationSelect = (
272
- args: object,
313
+ const countKey = `_${name as string}_count`;
314
+
315
+ // What the document asks of this connection, read the way the planner reads it (through
316
+ // fragments, directives, and a wrapping type), so the resolve side agrees with the plan. The
317
+ // selection is read once per request and shared by every parent row.
318
+ const connectionSelectionFromNames = (selected: ReadonlySet<string>) => {
319
+ const hasTotalCount = !!totalCount && selected.has('totalCount');
320
+ for (const field of selected) {
321
+ if (field !== 'totalCount' && field !== '__typename') {
322
+ return { hasTotalCount, totalCountOnly: false };
323
+ }
324
+ }
325
+ return { hasTotalCount, totalCountOnly: hasTotalCount };
326
+ };
327
+ const connectionSelection = (context: object, info: GraphQLResolveInfo) =>
328
+ connectionSelectionFromNames(selectedFieldNames(context, info));
329
+
330
+ // Built once per field, so a synchronous plan allocates nothing beyond the map itself.
331
+ const selectConnection = (
332
+ fieldQuery: ConnectionFieldQuery,
333
+ nested: SelectionMap | undefined,
273
334
  context: object,
274
- nestedQuery: (query: unknown, path?: unknown) => { select?: object },
275
- getSelection: (path: string[]) => FieldNode | null,
276
- pathInfo: import('./types').PathInfo,
335
+ hasTotalCount: boolean,
336
+ totalCountOnly: boolean,
277
337
  ) => {
278
- typeName ??= this.builder.configStore.getTypeConfig(ref).name;
279
-
280
- const hasTotalCount = totalCount && !!getSelection(['totalCount']);
281
- const hasEdges = !!getSelection(['edges']);
282
- const hasNodes = !!getSelection(['nodes']);
283
- const hasPageInfo = !!getSelection(['pageInfo']);
284
- const totalCountOnly = hasTotalCount && !hasEdges && !hasNodes && !hasPageInfo;
285
338
  const countSelection = {
286
- [`_${name as string}_count`]: (parent: TableConfig['table']) =>
287
- getClient(this.builder, context).$count(
288
- relatedTable.table as Table,
289
- buildCountFilter(parent),
290
- ),
339
+ [countKey]: (parent: TableConfig['table']) =>
340
+ buildCount(getClient(this.builder, context), parent, fieldQuery.where),
291
341
  };
292
342
 
293
343
  if (totalCountOnly) {
@@ -298,11 +348,6 @@ export class DrizzleObjectFieldBuilder<
298
348
  };
299
349
  }
300
350
 
301
- const nested = nestedQuery(getQuery(args, context, pathInfo).select, {
302
- getType: () => typeName!,
303
- paths: [[{ name: 'nodes' }], [{ name: 'edges' }, { name: 'node' }]],
304
- }) as SelectionMap;
305
-
306
351
  return {
307
352
  columns: {},
308
353
  with: {
@@ -311,6 +356,59 @@ export class DrizzleObjectFieldBuilder<
311
356
  extras: hasTotalCount ? countSelection : {},
312
357
  };
313
358
  };
359
+
360
+ const relationSelect = (
361
+ args: object,
362
+ context: object,
363
+ nestedQuery: (query: unknown, path?: unknown) => { select?: object },
364
+ getSelection: SelectedFieldNode,
365
+ pathInfo: PathInfo,
366
+ ) => {
367
+ typeName ??= this.builder.configStore.getTypeConfig(ref).name;
368
+
369
+ const { hasTotalCount, totalCountOnly } = connectionSelectionFromNames(getSelection());
370
+ const fieldQuery = resolveFieldQuery(args, context, pathInfo);
371
+ // The nested plan starts now, with a query that waits for the field's `query` when that is
372
+ // async, so every callback beneath the connection runs in the same tick.
373
+ const nested = totalCountOnly
374
+ ? undefined
375
+ : (nestedQuery(
376
+ isThenable(fieldQuery)
377
+ ? fieldQuery.then((resolved) => getQuery(args, context, resolved).select)
378
+ : getQuery(args, context, fieldQuery).select,
379
+ {
380
+ getType: () => typeName!,
381
+ paths: [[{ name: 'nodes' }], [{ name: 'edges' }, { name: 'node' }]],
382
+ },
383
+ ) as MaybePromise<SelectionMap>);
384
+
385
+ return isThenable(fieldQuery) || isThenable(nested)
386
+ ? Promise.all([fieldQuery, nested]).then(([resolvedQuery, resolvedNested]) =>
387
+ selectConnection(resolvedQuery, resolvedNested, context, hasTotalCount, totalCountOnly),
388
+ )
389
+ : selectConnection(fieldQuery, nested, context, hasTotalCount, totalCountOnly);
390
+ };
391
+
392
+ // The loaded path, per parent row: the rows are on the parent, only the page is needed.
393
+ const resolveLoaded = (
394
+ fieldQuery: ConnectionFieldQuery,
395
+ parent: unknown,
396
+ args: PothosSchemaTypes.DefaultConnectionArguments,
397
+ context: {},
398
+ countValue: number | undefined,
399
+ ) => {
400
+ const { select, cursorFields } = getQuery(args, context, fieldQuery);
401
+
402
+ return wrapConnectionResult(
403
+ (parent as Record<string, unknown>)[name] as readonly {}[],
404
+ args,
405
+ select.limit,
406
+ getCursorFormatter(cursorFields, schemaConfig),
407
+ undefined,
408
+ parent,
409
+ countValue,
410
+ );
411
+ };
314
412
  const fieldRef = (
315
413
  this as unknown as {
316
414
  connection: (...args: unknown[]) => FieldRef<Types, unknown>;
@@ -321,6 +419,18 @@ export class DrizzleObjectFieldBuilder<
321
419
  extensions: {
322
420
  ...extensions,
323
421
  pothosDrizzleSelect: relationSelect,
422
+ pothosDrizzleLoaded: (
423
+ value: Record<string, unknown>,
424
+ info: GraphQLResolveInfo,
425
+ context: object,
426
+ ) => {
427
+ const { hasTotalCount, totalCountOnly } = connectionSelection(context, info);
428
+
429
+ return (
430
+ (!hasTotalCount || value[countKey] !== undefined) &&
431
+ (totalCountOnly || value[name as string] !== undefined)
432
+ );
433
+ },
324
434
  },
325
435
  description,
326
436
  type: ref,
@@ -330,41 +440,13 @@ export class DrizzleObjectFieldBuilder<
330
440
  context: {},
331
441
  info: GraphQLResolveInfo,
332
442
  ) => {
333
- const countKey = `_${name as string}_count`;
334
443
  const parentRecord = parent as Record<string, unknown>;
335
444
  const countValue = totalCount
336
445
  ? (parentRecord[countKey] as number | undefined)
337
446
  : undefined;
338
447
 
339
- if (!(name in parentRecord)) {
340
- return {
341
- parent,
342
- args,
343
- totalCount: countValue,
344
- edges: [],
345
- pageInfo: {
346
- startCursor: null,
347
- endCursor: null,
348
- hasPreviousPage: false,
349
- hasNextPage: false,
350
- },
351
- };
352
- }
353
-
354
- // Detect totalCountOnly to skip cursor computation when only totalCount is requested
355
- const returnType = getNamedType(info.returnType);
356
- const fields =
357
- isObjectType(returnType) || isInterfaceType(returnType) ? returnType.getFields() : {};
358
- const totalCountOnly = info.fieldNodes.every((selection) =>
359
- selection.selectionSet?.selections.every(
360
- (s) =>
361
- s.kind === GraphQLKind.FIELD &&
362
- (fields[s.name.value]?.extensions?.pothosDrizzleTotalCount ||
363
- s.name.value === '__typename'),
364
- ),
365
- );
366
-
367
- if (totalCountOnly) {
448
+ // Only totalCount was requested: the relation was never selected, so skip the cursors.
449
+ if (connectionSelection(context, info).totalCountOnly) {
368
450
  return {
369
451
  parent,
370
452
  args,
@@ -379,17 +461,23 @@ export class DrizzleObjectFieldBuilder<
379
461
  };
380
462
  }
381
463
 
382
- const { select, cursorFields } = getQuery(args, context);
383
-
384
- return wrapConnectionResult(
385
- parentRecord[name] as readonly {}[],
386
- args,
387
- select.limit,
388
- getCursorFormatter(cursorFields, schemaConfig),
389
- undefined,
464
+ // The same position the select path planned this field at, recorded alongside its
465
+ // loader mapping, so a `query` that branches on its path pages the rows it selected.
466
+ // Asked of `parent`, because that is whose rows are being paged: a sibling row of the
467
+ // same list may have been loaded by a different plan, at a different position.
468
+ const position = getLoaderMapping(
469
+ context,
470
+ info.path,
471
+ info.parentType.name,
390
472
  parent,
391
- countValue,
392
- );
473
+ )?.position;
474
+ const fieldQuery = resolveFieldQuery(args, context, pathInfoFor(position));
475
+
476
+ return isThenable(fieldQuery)
477
+ ? fieldQuery.then((resolved) =>
478
+ resolveLoaded(resolved, parent, args, context, countValue),
479
+ )
480
+ : resolveLoaded(fieldQuery, parent, args, context, countValue);
393
481
  },
394
482
  },
395
483
  connectionOptions instanceof ObjectRef
@@ -519,31 +607,25 @@ export class DrizzleObjectFieldBuilder<
519
607
 
520
608
  const { query = {}, extensions, ...rest } = options;
521
609
 
610
+ // Built once per field: the select allocates nothing when the nested selection is sync. The
611
+ // nested selection already carries the field's `query` (it is merged into the child first),
612
+ // so nothing is spread over it.
613
+ const selectRelation = (nested: unknown) => ({ columns: {}, with: { [name]: nested } });
522
614
  const relationSelect = (
523
615
  args: object,
524
616
  context: object,
525
617
  nestedQuery: (query: unknown) => {},
526
618
  _resolveSelection: unknown,
527
619
  pathInfo: PathInfo,
528
- ) => {
529
- const relQuery = {
530
- columns: {},
531
- with: {
532
- [name]: omitUndefinedKeys({
533
- ...nestedQuery(query),
534
- ...((typeof query === 'function'
535
- ? (query as (args: {}, context: {}, pathInfo: PathInfo) => {})(
536
- args,
537
- context,
538
- pathInfo,
539
- )
540
- : query) as {}),
541
- }),
542
- },
543
- };
544
-
545
- return relQuery;
546
- };
620
+ ) =>
621
+ completeValue(
622
+ nestedQuery(
623
+ typeof query === 'function'
624
+ ? (query as (args: {}, context: {}, pathInfo?: PathInfo) => {})(args, context, pathInfo)
625
+ : query,
626
+ ),
627
+ selectRelation,
628
+ );
547
629
 
548
630
  return this.field({
549
631
  ...(rest as {}),
@@ -551,6 +633,8 @@ export class DrizzleObjectFieldBuilder<
551
633
  extensions: {
552
634
  ...extensions,
553
635
  pothosDrizzleSelect: relationSelect as never,
636
+ pothosDrizzleLoaded: (value: Record<string, unknown>) =>
637
+ value[name as string] !== undefined,
554
638
  },
555
639
  resolve: (parent: Record<string, never>) => parent[name as string],
556
640
  } as never) as never;
@@ -566,26 +650,15 @@ export class DrizzleObjectFieldBuilder<
566
650
  BuildQueryResult<Types['DrizzleRelations'], TableConfig, Select & { columns: {} }>,
567
651
  >(
568
652
  relationName: Field,
569
- options: {
570
- type: Type;
571
- nullable?: Nullable;
572
- args?: Args;
573
- description?: string;
574
- select: (
575
- buildFilter: (parentTable: TableConfig['table']) => SQL,
576
- args: Args extends InputFieldMap ? InputShapeFromFields<Args> : {},
577
- ctx: Types['Context'],
578
- nestedQuery: (
579
- query: DBQueryConfig<'many', Types['DrizzleRelations'], TableConfig>,
580
- ) => DBQueryConfig<'many', Types['DrizzleRelations'], TableConfig>,
581
- ) => Select;
582
- resolve: (
583
- parent: ShapeWithSelection,
584
- args: Args extends InputFieldMap ? InputShapeFromFields<Args> : {},
585
- ctx: Types['Context'],
586
- info: unknown,
587
- ) => ShapeFromTypeParam<Types, Type, Nullable>;
588
- },
653
+ options: RelatedSelectionFieldOptions<
654
+ Types,
655
+ TableConfig,
656
+ Type,
657
+ Nullable,
658
+ Args,
659
+ Select,
660
+ ShapeWithSelection
661
+ >,
589
662
  ): FieldRef<Types, ShapeFromTypeParam<Types, Type, Nullable>, 'DrizzleObject'> {
590
663
  const schemaConfig = getSchemaConfig(this.builder);
591
664
  const relationField = schemaConfig.relations?.[this.table].relations[relationName as string];
@@ -596,40 +669,41 @@ export class DrizzleObjectFieldBuilder<
596
669
  );
597
670
  }
598
671
 
599
- const buildFilter = (parentTable: TableConfig['table']): SQL => {
600
- const { sourceColumns, targetColumns } = relationField;
601
-
602
- return and(
603
- ...sourceColumns.map((sourceCol, i) =>
604
- eq(targetColumns[i], parentTable[sourceCol.name as never]),
605
- ),
606
- )!;
607
- };
672
+ // Explicit NOT NULL is required even for primary keys: SQLite permits nullable text and
673
+ // composite primary keys. Without a total identity the relation filter keeps EXISTS.
674
+ const targetKey = relationField.throughTable
675
+ ? schemaConfig
676
+ .getUniqueConstraints(relationField.targetTableName)
677
+ .find((columns) => columns.every((column) => column.notNull))
678
+ : undefined;
608
679
 
609
680
  const relationSelect = (
610
681
  args: object,
611
682
  context: Types['Context'],
612
683
  nestedQuery: (query: unknown) => unknown,
613
684
  ) => {
614
- const selection = options.select(
615
- buildFilter as never,
616
- args as never,
617
- context,
618
- nestedQuery as never,
619
- ) as Select & { with?: unknown };
620
- return {
621
- columns: selection.columns ?? {},
622
- extras: selection.extras,
623
- with: selection.with,
624
- };
685
+ const buildFilter = (parentTable: TableConfig['table']): SQL =>
686
+ buildRelationFilter(
687
+ getClient(this.builder, context),
688
+ relationField as Relation,
689
+ parentTable as Table,
690
+ targetKey,
691
+ ).filter;
692
+
693
+ return completeValue(
694
+ options.select(buildFilter as never, args as never, context, nestedQuery as never) as
695
+ | MaybePromise<Select & { with?: unknown }>
696
+ | undefined,
697
+ pickSelection,
698
+ );
625
699
  };
626
700
 
701
+ const { select: _select, extensions, ...fieldOptions } = options;
702
+
627
703
  return this.field({
628
- type: options.type,
629
- nullable: options.nullable,
630
- args: options.args,
631
- description: options.description,
704
+ ...fieldOptions,
632
705
  extensions: {
706
+ ...extensions,
633
707
  pothosDrizzleSelect: relationSelect as never,
634
708
  },
635
709
  resolve: options.resolve as never,
@@ -637,7 +711,7 @@ export class DrizzleObjectFieldBuilder<
637
711
  }
638
712
 
639
713
  relatedCount<
640
- Field extends keyof TableConfig['relations'],
714
+ Field extends ListRelation<TableConfig>,
641
715
  Args extends InputFieldMap,
642
716
  Where extends SQL | undefined,
643
717
  >(
@@ -649,11 +723,47 @@ export class DrizzleObjectFieldBuilder<
649
723
  const schemaConfig = getSchemaConfig(this.builder);
650
724
  const relationField = schemaConfig.relations?.[this.table].relations[relationName as string];
651
725
  const relatedTable = schemaConfig.relations[relationField.targetTableName];
726
+ // A related row counts once however many junction rows lead to it, which the count needs a
727
+ // column identifying a target row to say. Without one it falls back to counting the target
728
+ // table filtered by the relation, which says the same thing more slowly.
729
+ const targetKey = schemaConfig.findPrimaryKey(relationField.targetTableName);
730
+ const distinctBy = targetKey?.length === 1 && targetKey[0].notNull ? targetKey[0] : undefined;
731
+
732
+ // Built once per field; the `extras` function it returns is what the plan carried before.
733
+ const countExtras = (
734
+ whereClause: SQL | undefined,
735
+ ctx: Types['Context'],
736
+ buildFilter: (parent: TableConfig['table']) => SQL,
737
+ ) =>
738
+ ({
739
+ extras: {
740
+ [countKey]: (parent: TableConfig['table']) => {
741
+ const client = getClient(this.builder, ctx);
742
+
743
+ if (!distinctBy) {
744
+ return client.$count(
745
+ relatedTable.table as Table,
746
+ whereClause ? and(buildFilter(parent), whereClause) : buildFilter(parent),
747
+ );
748
+ }
749
+
750
+ return buildRelationFilter(
751
+ client,
752
+ relationField as Relation,
753
+ parent as Table,
754
+ ).countDistinctRows(distinctBy, whereClause);
755
+ },
756
+ },
757
+ }) as never;
652
758
 
653
759
  return this.relatedField(relationName, {
654
760
  ...options,
655
761
  type: 'Int' as never,
656
762
  nullable: false,
763
+ extensions: {
764
+ ...(options as { extensions?: Record<string, unknown> }).extensions,
765
+ pothosDrizzleLoaded: (value: Record<string, unknown>) => value[countKey] !== undefined,
766
+ },
657
767
  select: (
658
768
  buildFilter: (parent: TableConfig['table']) => SQL,
659
769
  args: object,
@@ -661,18 +771,14 @@ export class DrizzleObjectFieldBuilder<
661
771
  ) => {
662
772
  const whereClause =
663
773
  typeof where === 'function'
664
- ? (where as (args: unknown, ctx: unknown) => SQL | undefined)(args, ctx)
774
+ ? (where as (args: unknown, ctx: unknown) => MaybePromise<SQL | undefined>)(args, ctx)
665
775
  : where;
666
776
 
667
- return {
668
- extras: {
669
- [countKey]: (parent: TableConfig['table']) =>
670
- getClient(this.builder, ctx).$count(
671
- relatedTable.table as Table,
672
- whereClause ? and(buildFilter(parent), whereClause) : buildFilter(parent),
673
- ),
674
- },
675
- } as never;
777
+ return isThenable(whereClause)
778
+ ? whereClause.then((resolved) =>
779
+ countExtras(resolved as SQL | undefined, ctx, buildFilter),
780
+ )
781
+ : countExtras(whereClause, ctx, buildFilter);
676
782
  },
677
783
  resolve: (parent: Record<string, number>) => parent[countKey],
678
784
  } as never) as FieldRef<Types, number, 'DrizzleObject'>;
@@ -762,3 +868,17 @@ export class DrizzleObjectFieldBuilder<
762
868
  };
763
869
  }
764
870
  }
871
+
872
+ /** A relation `query` callback may return nothing; the connection then adds no filter. */
873
+ function orEmpty<T extends object>(query: T | null | undefined): T {
874
+ return query ?? ({} as T);
875
+ }
876
+
877
+ /** A `relatedField` select's map, with the columns it may have left out defaulted. */
878
+ function pickSelection(selection: { columns?: {}; extras?: unknown; with?: unknown } | undefined) {
879
+ return {
880
+ columns: selection?.columns ?? {},
881
+ extras: selection?.extras,
882
+ with: selection?.with,
883
+ };
884
+ }