@pothos/plugin-drizzle 0.17.6 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dts/connection-helpers.d.ts.map +1 -1
  3. package/dts/drizzle-field-builder.d.ts.map +1 -1
  4. package/dts/types.d.ts +5 -2
  5. package/dts/types.d.ts.map +1 -1
  6. package/dts/utils/config.d.ts +3 -0
  7. package/dts/utils/config.d.ts.map +1 -1
  8. package/dts/utils/cursors.d.ts +11 -7
  9. package/dts/utils/cursors.d.ts.map +1 -1
  10. package/esm/connection-helpers.d.ts.map +1 -1
  11. package/esm/connection-helpers.js +5 -4
  12. package/esm/connection-helpers.js.map +1 -1
  13. package/esm/drizzle-field-builder.d.ts.map +1 -1
  14. package/esm/drizzle-field-builder.js +5 -4
  15. package/esm/drizzle-field-builder.js.map +1 -1
  16. package/esm/types.d.ts +5 -2
  17. package/esm/types.d.ts.map +1 -1
  18. package/esm/types.js.map +1 -1
  19. package/esm/utils/config.d.ts +3 -0
  20. package/esm/utils/config.d.ts.map +1 -1
  21. package/esm/utils/config.js +95 -17
  22. package/esm/utils/config.js.map +1 -1
  23. package/esm/utils/cursors.d.ts +11 -7
  24. package/esm/utils/cursors.d.ts.map +1 -1
  25. package/esm/utils/cursors.js +194 -113
  26. package/esm/utils/cursors.js.map +1 -1
  27. package/lib/connection-helpers.js +5 -4
  28. package/lib/connection-helpers.js.map +1 -1
  29. package/lib/drizzle-field-builder.js +5 -4
  30. package/lib/drizzle-field-builder.js.map +1 -1
  31. package/lib/types.js.map +1 -1
  32. package/lib/utils/config.js +95 -17
  33. package/lib/utils/config.js.map +1 -1
  34. package/lib/utils/cursors.js +196 -112
  35. package/lib/utils/cursors.js.map +1 -1
  36. package/package.json +5 -5
  37. package/src/connection-helpers.ts +6 -4
  38. package/src/drizzle-field-builder.ts +7 -4
  39. package/src/types.ts +8 -4
  40. package/src/utils/config.ts +117 -20
  41. package/src/utils/cursors.ts +248 -87
@@ -6,10 +6,17 @@ import {
6
6
  type SchemaTypes,
7
7
  } from '@pothos/core';
8
8
  import {
9
+ asc,
9
10
  type Column,
10
11
  type DBQueryConfig,
12
+ desc,
13
+ eq,
11
14
  getColumns,
15
+ gt,
16
+ isNull,
17
+ lt,
12
18
  type SQL,
19
+ sql,
13
20
  type Table,
14
21
  type TableRelationalConfig,
15
22
  } from 'drizzle-orm';
@@ -81,21 +88,40 @@ export function getIDSerializer(fields: Column[], config: PothosDrizzleSchemaCon
81
88
  };
82
89
  }
83
90
 
84
- export function getColumnSerializer(fields: Column[], config: PothosDrizzleSchemaConfig) {
91
+ // A cursor is built from the ordering, and the ordering can name a column or an
92
+ // extra the query selected. Either way what the cursor needs is the key the
93
+ // value arrives under on the row.
94
+ export type CursorField = Column | string;
95
+
96
+ export function cursorFieldKey(field: CursorField, config: PothosDrizzleSchemaConfig) {
97
+ return typeof field === 'string' ? field : config.columnToTsName(field);
98
+ }
99
+
100
+ export function getColumnSerializer(
101
+ fields: readonly CursorField[],
102
+ config: PothosDrizzleSchemaConfig,
103
+ ) {
85
104
  if (fields.length === 0) {
86
105
  throw new PothosValidationError('Column serializer must have at least one field');
87
106
  }
88
107
 
89
108
  return (value: Record<string, unknown>) => {
90
109
  if (fields.length > 1) {
91
- return `J:${JSON.stringify(fields.map((col) => value[config.columnToTsName(col)]))}`;
110
+ // each value carries its own type tag, the same ones a single value gets.
111
+ // Plain JSON would turn a Date into a string and refuse a bigint outright.
112
+ return `T:${JSON.stringify(
113
+ fields.map((field) => formatCursorChunk(value[cursorFieldKey(field, config)])),
114
+ )}`;
92
115
  }
93
116
 
94
- return formatCursorChunk(value[config.columnToTsName(fields[0])]);
117
+ return formatCursorChunk(value[cursorFieldKey(fields[0], config)]);
95
118
  };
96
119
  }
97
120
 
98
- export function getCursorFormatter(fields: Column[], config: PothosDrizzleSchemaConfig) {
121
+ export function getCursorFormatter(
122
+ fields: readonly CursorField[],
123
+ config: PothosDrizzleSchemaConfig,
124
+ ) {
99
125
  if (fields.length === 0) {
100
126
  throw new PothosValidationError('Cursor must have at least one field');
101
127
  }
@@ -124,13 +150,13 @@ export function parseDrizzleCursor(cursor: unknown) {
124
150
  }
125
151
  }
126
152
 
127
- export function parseSerializedDrizzleColumn(value: unknown) {
153
+ export function parseSerializedDrizzleColumn(value: unknown): unknown {
128
154
  if (typeof value !== 'string') {
129
155
  throw new PothosValidationError('value must be a string');
130
156
  }
131
157
 
132
158
  try {
133
- const [, type, rawValue] = value.match(/^(S|N|D|J|I):(.*)/) as [string, string, string];
159
+ const [, type, rawValue] = value.match(/^(S|N|D|J|I|T):(.*)/) as [string, string, string];
134
160
 
135
161
  switch (type) {
136
162
  case 'S':
@@ -140,7 +166,13 @@ export function parseSerializedDrizzleColumn(value: unknown) {
140
166
  case 'D':
141
167
  return new Date(Number.parseInt(rawValue, 10));
142
168
  case 'J':
169
+ // compound values from before each one carried a tag: whatever JSON
170
+ // preserved is the best that can be recovered
143
171
  return JSON.parse(rawValue) as unknown;
172
+ case 'T':
173
+ return (JSON.parse(rawValue) as (string | null)[]).map((chunk) =>
174
+ chunk === null ? null : parseSerializedDrizzleColumn(chunk),
175
+ );
144
176
  case 'I':
145
177
  return BigInt(rawValue);
146
178
  default:
@@ -259,28 +291,38 @@ export function getColumnParser(fields: readonly Column[]) {
259
291
  };
260
292
  }
261
293
 
262
- export function getCursorParser(fields: readonly Column[]) {
263
- if (fields.length === 0) {
294
+ export function getCursorParser(keys: readonly string[]) {
295
+ if (keys.length === 0) {
264
296
  throw new PothosValidationError('Cursor must have at least one field');
265
297
  }
266
298
 
267
299
  return (cursor: unknown) => {
268
- const parsed = parseDrizzleCursor(cursor) as unknown[];
300
+ const parsed = parseDrizzleCursor(cursor);
269
301
 
270
- if (fields.length === 1) {
271
- return { [fields[0].name]: parsed };
302
+ if (keys.length === 1) {
303
+ return { [keys[0]]: parsed };
272
304
  }
273
305
 
274
- if (!Array.isArray(parsed)) {
306
+ // A cursor issued before a column joined the ordering only holds values for
307
+ // the columns that came before it. Those still describe a position, just a
308
+ // less precise one, so the page is keyed off the prefix the cursor covers
309
+ // rather than rejected. Cursors returned by that page carry every column.
310
+ const values = Array.isArray(parsed) ? parsed : [parsed];
311
+
312
+ if (values.length === 0) {
313
+ throw new PothosValidationError('Cursor contains no values');
314
+ }
315
+
316
+ if (values.length > keys.length) {
275
317
  throw new PothosValidationError(
276
- `Expected compound cursor to contain an array, but got ${parsed}`,
318
+ `Expected cursor to contain at most ${keys.length} values, but got ${values.length}`,
277
319
  );
278
320
  }
279
321
 
280
322
  const record: Record<string, unknown> = {};
281
323
 
282
- fields.forEach((field, i) => {
283
- record[field.name] = parsed[i];
324
+ values.forEach((value, i) => {
325
+ record[keys[i]] = value;
284
326
  });
285
327
 
286
328
  return record;
@@ -293,33 +335,113 @@ export interface DrizzleCursorConnectionQueryOptions {
293
335
  defaultSize?: number | ((args: {}, ctx: {}) => number);
294
336
  maxSize?: number | ((args: {}, ctx: {}) => number);
295
337
  orderBy: ConnectionOrderBy<TableRelationalConfig>;
338
+ extras?: Record<string, OrderByExpression | undefined>;
296
339
  where?: SQL;
297
340
  config: PothosDrizzleSchemaConfig;
298
341
  table: TableRelationalConfig;
299
342
  }
300
343
 
344
+ export type OrderByExpression = SQL | ((table: never, operators: never) => SQL);
345
+
346
+ // An ordering entry is either a column or an expression the query selected as
347
+ // an extra. `key` is where the value lands on the row, and so what the cursor
348
+ // is built from; `target` is what SQL orders and compares.
349
+ type OrderByEntry = {
350
+ direction: 'asc' | 'desc';
351
+ key: string;
352
+ column?: Column;
353
+ expression?: OrderByExpression;
354
+ };
355
+
356
+ function resolveExpression(expression: OrderByExpression, table: Table) {
357
+ return typeof expression === 'function'
358
+ ? (expression as (table: Table, operators: { sql: typeof sql }) => SQL)(table, { sql })
359
+ : expression;
360
+ }
361
+
362
+ // Drizzle aliases the table it is querying (`d0`), so ordering and comparing
363
+ // have to reach for the column on that alias rather than the original table.
364
+ // Expressions get the alias handed to them and resolve themselves.
365
+ function orderTarget(entry: OrderByEntry, table: Table) {
366
+ if (entry.column) {
367
+ return (table as unknown as Record<string, Column>)[entry.key] ?? entry.column;
368
+ }
369
+
370
+ return resolveExpression(entry.expression!, table);
371
+ }
372
+
373
+ function flipDirection(direction: 'asc' | 'desc') {
374
+ return direction === 'asc' ? 'desc' : 'asc';
375
+ }
376
+
377
+ function ordersBy(entries: OrderByEntry[], column: Column) {
378
+ return entries.some((entry) => entry.column?.name === column.name);
379
+ }
380
+
381
+ // The ordering fixes a row's position only if some set of columns the database
382
+ // keeps unique is fully covered by it. A nullable column can't contribute:
383
+ // rows sharing a null tie with each other, and a cursor compared against null
384
+ // matches nothing.
385
+ function orderIsUnique(
386
+ entries: OrderByEntry[],
387
+ config: PothosDrizzleSchemaConfig,
388
+ table: TableRelationalConfig,
389
+ ) {
390
+ return config
391
+ .getUniqueConstraints(table.name)
392
+ .some((columns) => columns.every((column) => column.notNull && ordersBy(entries, column)));
393
+ }
394
+
395
+ // A cursor names a row's position in the ordering, which only works if no two
396
+ // rows can share a position. Ordering by a column with duplicate values (a
397
+ // timestamp, a status) leaves ties to be broken arbitrarily, so a row can move
398
+ // between pages and end up returned twice or skipped entirely. Appending a key
399
+ // the database keeps unique fixes that. Key columns already in the ordering are
400
+ // left where the user put them.
401
+ function appendTieBreaker(
402
+ entries: OrderByEntry[],
403
+ config: PothosDrizzleSchemaConfig,
404
+ table: TableRelationalConfig,
405
+ ) {
406
+ if (orderIsUnique(entries, config, table)) {
407
+ return;
408
+ }
409
+
410
+ const tieBreaker = config.findTieBreaker(table.name);
411
+
412
+ if (!tieBreaker) {
413
+ return;
414
+ }
415
+
416
+ const direction = entries[entries.length - 1]?.direction ?? 'asc';
417
+
418
+ for (const column of tieBreaker) {
419
+ if (!ordersBy(entries, column)) {
420
+ entries.push({ direction, key: config.columnToTsName(column), column });
421
+ }
422
+ }
423
+ }
424
+
301
425
  function parseOrderBy(
302
426
  config: PothosDrizzleSchemaConfig,
303
427
  table: TableRelationalConfig,
304
428
  orderBy: ConnectionOrderBy<TableRelationalConfig>,
305
429
  invert: boolean,
430
+ extras?: Record<string, OrderByExpression | undefined>,
306
431
  ) {
307
- const entries: [name: string, direction: 'asc' | 'desc'][] = [];
308
- const columns: Column[] = [];
309
- const normalized: { direction: 'asc' | 'desc'; column: Column }[] = [];
432
+ const normalized: OrderByEntry[] = [];
310
433
 
311
- const asc = invert ? 'desc' : 'asc';
312
- const desc = invert ? 'asc' : 'desc';
434
+ const columnEntry = (column: Column, direction: 'asc' | 'desc') => ({
435
+ direction,
436
+ key: config.columnToTsName(column),
437
+ column,
438
+ });
313
439
 
314
440
  if ('table' in orderBy && orderBy.table && typeof orderBy.table === 'object') {
315
- entries.push([config.columnToTsName(orderBy as Column), asc]);
316
- columns.push(orderBy as Column);
317
- normalized.push({ direction: 'asc', column: orderBy as Column });
441
+ normalized.push(columnEntry(orderBy as unknown as Column, 'asc'));
318
442
  } else if (Array.isArray(orderBy)) {
319
443
  for (const field of orderBy) {
320
- entries.push([config.columnToTsName(field), asc]);
321
- columns.push(field);
322
- normalized.push({ direction: 'asc', column: field });
444
+ normalized.push(columnEntry(field, 'asc'));
323
445
  }
324
446
  } else {
325
447
  const tableColumns = getColumns(table.table as Table);
@@ -328,28 +450,113 @@ function parseOrderBy(
328
450
  [k: string]: 'asc' | 'desc' | undefined;
329
451
  },
330
452
  ).forEach(([name, direction]) => {
331
- if (direction) {
332
- const column = tableColumns[name];
333
- columns.push(column);
334
- entries.push([name, direction === 'asc' ? asc : desc]);
335
- normalized.push({ direction: direction, column });
453
+ if (!direction) {
454
+ return;
455
+ }
456
+
457
+ const column = tableColumns[name];
458
+
459
+ if (column) {
460
+ normalized.push(columnEntry(column, direction));
461
+ return;
336
462
  }
463
+
464
+ const expression = extras?.[name];
465
+
466
+ if (!expression) {
467
+ throw new PothosValidationError(
468
+ `Can't order by "${name}": ${table.name} has no such column, and the query passed to the connection declares no extra with that name`,
469
+ );
470
+ }
471
+
472
+ normalized.push({ direction, key: name, expression });
337
473
  });
338
474
  }
339
475
 
476
+ appendTieBreaker(normalized, config, table);
477
+
478
+ const directionFor = ({ direction }: OrderByEntry) =>
479
+ invert ? flipDirection(direction) : direction;
480
+
340
481
  return {
341
482
  normalized,
342
- columns,
343
- orderBy: Object.fromEntries(entries),
483
+ // only real columns need adding to the selection; extras are already selected
484
+ columns: normalized.flatMap(({ column }) => (column ? [column] : [])),
485
+ cursorFields: normalized.map(({ column, key }) => column ?? key),
486
+ // the object form can only name columns, so an expression forces the
487
+ // callback form -- which renders identically for the columns beside it
488
+ orderBy: normalized.some(({ expression }) => expression)
489
+ ? (t: Table) =>
490
+ normalized.map((entry) =>
491
+ (directionFor(entry) === 'asc' ? asc : desc)(orderTarget(entry, t)),
492
+ )
493
+ : Object.fromEntries(normalized.map((entry) => [entry.key, directionFor(entry)])),
494
+ };
495
+ }
496
+
497
+ // Rows past the cursor are those where the first ordering column that differs
498
+ // from the cursor differs in the direction being paged. Columns the cursor
499
+ // doesn't cover are left out: it was issued for a shorter ordering, and its
500
+ // prefix still describes a position.
501
+ function compareTo(entry: OrderByEntry, operator: 'gt' | 'lt' | 'eq', value: unknown) {
502
+ const isNullish = value === null || value === undefined;
503
+
504
+ if (entry.column) {
505
+ if (operator === 'eq' && isNullish) {
506
+ return { [entry.key]: { isNull: true } };
507
+ }
508
+
509
+ // never the `{ column: value }` shorthand: drizzle reads any object value
510
+ // as a nested filter, so a null throws and a Date silently drops the
511
+ // comparison. Naming the operator works for every value a cursor can hold
512
+ return { [entry.key]: { [operator]: value } };
513
+ }
514
+
515
+ if (operator === 'eq' && isNullish) {
516
+ return { RAW: (table: Table) => isNull(orderTarget(entry, table) as SQL) };
517
+ }
518
+
519
+ const operators = { gt, lt, eq };
520
+
521
+ // RAW hands the filter the table it is being built against, which is what an
522
+ // expression needs to resolve
523
+ return {
524
+ RAW: (table: Table) => operators[operator](orderTarget(entry, table) as SQL, value),
344
525
  };
345
526
  }
346
527
 
528
+ function keysetFilter(entries: OrderByEntry[], cursor: string, paging: 'after' | 'before') {
529
+ const parsedCursor = getCursorParser(entries.map(({ key }) => key))(cursor);
530
+ const covered = entries.filter(({ key }) => key in parsedCursor);
531
+
532
+ const parts = covered.map((entry, index) => {
533
+ const ascending = paging === 'after' ? entry.direction === 'asc' : entry.direction === 'desc';
534
+ const compare = compareTo(entry, ascending ? 'gt' : 'lt', parsedCursor[entry.key]);
535
+
536
+ if (index === 0) {
537
+ return compare;
538
+ }
539
+
540
+ return {
541
+ AND: [
542
+ ...covered
543
+ .slice(0, index)
544
+ .map((previous) => compareTo(previous, 'eq', parsedCursor[previous.key])),
545
+ compare,
546
+ ],
547
+ };
548
+ });
549
+
550
+ return parts.length > 1 ? { OR: parts } : parts[0];
551
+ }
552
+
347
553
  export function drizzleCursorConnectionQuery({
348
554
  args,
349
555
  ctx,
350
556
  maxSize = DEFAULT_MAX_SIZE,
351
557
  defaultSize = DEFAULT_SIZE,
352
558
  orderBy,
559
+ extras,
353
560
  where,
354
561
  config,
355
562
  table,
@@ -376,7 +583,7 @@ export function drizzleCursorConnectionQuery({
376
583
  const limit = Math.min(first ?? last ?? defaultSizeForConnection, maxSizeForConnection) + 1;
377
584
  const inverted = !first && !!last;
378
585
 
379
- const parsedOrderBy = parseOrderBy(config, table, orderBy, inverted);
586
+ const parsedOrderBy = parseOrderBy(config, table, orderBy, inverted, extras);
380
587
 
381
588
  const columns: Record<string, boolean> = {};
382
589
 
@@ -391,63 +598,15 @@ export function drizzleCursorConnectionQuery({
391
598
  }
392
599
 
393
600
  if (after) {
394
- const cursorParser = getCursorParser(parsedOrderBy.columns);
395
- const parsedCursor = cursorParser(after);
396
-
397
- const parts = parsedOrderBy.normalized.map(({ direction, column }, index) => {
398
- const columnName = config.columnToTsName(column);
399
- const compare =
400
- direction === 'asc'
401
- ? { [columnName]: { gt: parsedCursor[column.name] } }
402
- : { [columnName]: { lt: parsedCursor[column.name] } };
403
-
404
- if (index === 0) {
405
- return compare;
406
- }
407
-
408
- return {
409
- AND: [
410
- ...parsedOrderBy.normalized.slice(0, index).map(({ column: c }) => {
411
- return { [config.columnToTsName(c)]: parsedCursor[c.name] };
412
- }),
413
- compare,
414
- ],
415
- };
416
- });
417
-
418
- whereClauses.push(parts.length > 1 ? { OR: parts } : parts[0]);
601
+ whereClauses.push(keysetFilter(parsedOrderBy.normalized, after, 'after'));
419
602
  }
420
603
 
421
604
  if (before) {
422
- const cursorParser = getCursorParser(parsedOrderBy.columns);
423
- const parsedCursor = cursorParser(before);
424
-
425
- const parts = parsedOrderBy.normalized.map(({ direction, column }, index) => {
426
- const columnName = config.columnToTsName(column);
427
- const compare =
428
- direction === 'desc'
429
- ? { [columnName]: { gt: parsedCursor[column.name] } }
430
- : { [columnName]: { lt: parsedCursor[column.name] } };
431
-
432
- if (index === 0) {
433
- return compare;
434
- }
435
-
436
- return {
437
- AND: [
438
- ...parsedOrderBy.normalized.slice(0, index).map(({ column: c }) => {
439
- return { [config.columnToTsName(c)]: parsedCursor[c.name] };
440
- }),
441
- compare,
442
- ],
443
- };
444
- });
445
-
446
- whereClauses.push(parts.length > 1 ? { OR: parts } : parts[0]);
605
+ whereClauses.push(keysetFilter(parsedOrderBy.normalized, before, 'before'));
447
606
  }
448
607
 
449
608
  return omitUndefinedKeys({
450
- cursorColumns: parsedOrderBy.columns,
609
+ cursorFields: parsedOrderBy.cursorFields,
451
610
  columns,
452
611
  orderBy: parsedOrderBy.orderBy,
453
612
  limit,
@@ -527,21 +686,23 @@ export async function resolveDrizzleCursorConnection<T extends {}>(
527
686
  let query: DBQueryConfig<'many'> | undefined;
528
687
  let formatter: (node: Record<string, unknown>) => string;
529
688
  const results = await resolve((q = {}) => {
530
- const { cursorColumns, ...connectionQuery } = drizzleCursorConnectionQuery({
689
+ const { cursorFields, ...connectionQuery } = drizzleCursorConnectionQuery({
531
690
  ...options,
532
691
  config,
533
692
  orderBy:
534
693
  (typeof q.orderBy === 'function' ? q.orderBy(table.table as Table) : q.orderBy) ??
535
694
  config.getPrimaryKey(table.name),
695
+ extras: q.extras as DrizzleCursorConnectionQueryOptions['extras'],
536
696
  table,
537
697
  });
538
- formatter = getCursorFormatter(cursorColumns, config);
698
+ formatter = getCursorFormatter(cursorFields, config);
539
699
 
540
700
  query = queryFromInfo({
541
701
  context: options.ctx,
542
702
  info,
543
703
  select: omitUndefinedKeys({
544
704
  ...connectionQuery,
705
+ extras: q.extras,
545
706
  columns: {
546
707
  ...q.columns,
547
708
  ...connectionQuery.columns,