@expo/entity-database-adapter-knex 0.55.0 → 0.58.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 (91) hide show
  1. package/build/src/AuthorizationResultBasedKnexEntityLoader.d.ts +278 -0
  2. package/build/src/AuthorizationResultBasedKnexEntityLoader.js +127 -0
  3. package/build/src/AuthorizationResultBasedKnexEntityLoader.js.map +1 -0
  4. package/build/src/BasePostgresEntityDatabaseAdapter.d.ts +150 -0
  5. package/build/src/BasePostgresEntityDatabaseAdapter.js +119 -0
  6. package/build/src/BasePostgresEntityDatabaseAdapter.js.map +1 -0
  7. package/build/src/BaseSQLQueryBuilder.d.ts +61 -0
  8. package/build/src/BaseSQLQueryBuilder.js +87 -0
  9. package/build/src/BaseSQLQueryBuilder.js.map +1 -0
  10. package/build/src/EnforcingKnexEntityLoader.d.ts +124 -0
  11. package/build/src/EnforcingKnexEntityLoader.js +166 -0
  12. package/build/src/EnforcingKnexEntityLoader.js.map +1 -0
  13. package/build/src/KnexEntityLoaderFactory.d.ts +25 -0
  14. package/build/src/KnexEntityLoaderFactory.js +39 -0
  15. package/build/src/KnexEntityLoaderFactory.js.map +1 -0
  16. package/build/src/PaginationStrategy.d.ts +30 -0
  17. package/build/src/PaginationStrategy.js +35 -0
  18. package/build/src/PaginationStrategy.js.map +1 -0
  19. package/build/src/PostgresEntity.d.ts +25 -0
  20. package/build/src/PostgresEntity.js +39 -0
  21. package/build/src/PostgresEntity.js.map +1 -0
  22. package/build/src/PostgresEntityDatabaseAdapter.d.ts +12 -5
  23. package/build/src/PostgresEntityDatabaseAdapter.js +33 -11
  24. package/build/src/PostgresEntityDatabaseAdapter.js.map +1 -1
  25. package/build/src/PostgresEntityDatabaseAdapterProvider.d.ts +9 -0
  26. package/build/src/PostgresEntityDatabaseAdapterProvider.js +5 -1
  27. package/build/src/PostgresEntityDatabaseAdapterProvider.js.map +1 -1
  28. package/build/src/ReadonlyPostgresEntity.d.ts +25 -0
  29. package/build/src/ReadonlyPostgresEntity.js +39 -0
  30. package/build/src/ReadonlyPostgresEntity.js.map +1 -0
  31. package/build/src/SQLOperator.d.ts +267 -0
  32. package/build/src/SQLOperator.js +474 -0
  33. package/build/src/SQLOperator.js.map +1 -0
  34. package/build/src/index.d.ts +15 -0
  35. package/build/src/index.js +15 -0
  36. package/build/src/index.js.map +1 -1
  37. package/build/src/internal/EntityKnexDataManager.d.ts +147 -0
  38. package/build/src/internal/EntityKnexDataManager.js +453 -0
  39. package/build/src/internal/EntityKnexDataManager.js.map +1 -0
  40. package/build/src/internal/getKnexDataManager.d.ts +3 -0
  41. package/build/src/internal/getKnexDataManager.js +19 -0
  42. package/build/src/internal/getKnexDataManager.js.map +1 -0
  43. package/build/src/internal/getKnexEntityLoaderFactory.d.ts +3 -0
  44. package/build/src/internal/getKnexEntityLoaderFactory.js +11 -0
  45. package/build/src/internal/getKnexEntityLoaderFactory.js.map +1 -0
  46. package/build/src/internal/utilityTypes.d.ts +5 -0
  47. package/build/src/internal/utilityTypes.js +5 -0
  48. package/build/src/internal/utilityTypes.js.map +1 -0
  49. package/build/src/internal/weakMaps.d.ts +9 -0
  50. package/build/src/internal/weakMaps.js +20 -0
  51. package/build/src/internal/weakMaps.js.map +1 -0
  52. package/build/src/knexLoader.d.ts +18 -0
  53. package/build/src/knexLoader.js +31 -0
  54. package/build/src/knexLoader.js.map +1 -0
  55. package/package.json +6 -5
  56. package/src/AuthorizationResultBasedKnexEntityLoader.ts +537 -0
  57. package/src/BasePostgresEntityDatabaseAdapter.ts +317 -0
  58. package/src/BaseSQLQueryBuilder.ts +114 -0
  59. package/src/EnforcingKnexEntityLoader.ts +271 -0
  60. package/src/KnexEntityLoaderFactory.ts +130 -0
  61. package/src/PaginationStrategy.ts +32 -0
  62. package/src/PostgresEntity.ts +118 -0
  63. package/src/PostgresEntityDatabaseAdapter.ts +81 -24
  64. package/src/PostgresEntityDatabaseAdapterProvider.ts +11 -1
  65. package/src/ReadonlyPostgresEntity.ts +115 -0
  66. package/src/SQLOperator.ts +630 -0
  67. package/src/__integration-tests__/EntityCreationUtils-test.ts +25 -31
  68. package/src/__integration-tests__/PostgresEntityIntegration-test.ts +3192 -330
  69. package/src/__integration-tests__/PostgresEntityQueryContextProvider-test.ts +7 -7
  70. package/src/__testfixtures__/PostgresTestEntity.ts +17 -3
  71. package/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts +1167 -0
  72. package/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts +160 -0
  73. package/src/__tests__/EnforcingKnexEntityLoader-test.ts +384 -0
  74. package/src/__tests__/EntityFields-test.ts +1 -1
  75. package/src/__tests__/PostgresEntity-test.ts +172 -0
  76. package/src/__tests__/ReadonlyEntity-test.ts +32 -0
  77. package/src/__tests__/SQLOperator-test.ts +871 -0
  78. package/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts +302 -0
  79. package/src/__tests__/fixtures/StubPostgresDatabaseAdapterProvider.ts +17 -0
  80. package/src/__tests__/fixtures/TestEntity.ts +131 -0
  81. package/src/__tests__/fixtures/TestPaginationEntity.ts +107 -0
  82. package/src/__tests__/fixtures/createUnitTestPostgresEntityCompanionProvider.ts +42 -0
  83. package/src/index.ts +15 -0
  84. package/src/internal/EntityKnexDataManager.ts +832 -0
  85. package/src/internal/__tests__/EntityKnexDataManager-test.ts +378 -0
  86. package/src/internal/__tests__/weakMaps-test.ts +25 -0
  87. package/src/internal/getKnexDataManager.ts +43 -0
  88. package/src/internal/getKnexEntityLoaderFactory.ts +60 -0
  89. package/src/internal/utilityTypes.ts +11 -0
  90. package/src/internal/weakMaps.ts +19 -0
  91. package/src/knexLoader.ts +110 -0
@@ -0,0 +1,832 @@
1
+ import {
2
+ EntityQueryContext,
3
+ timeAndLogLoadEventAsync,
4
+ EntityMetricsLoadType,
5
+ IEntityMetricsAdapter,
6
+ EntityConfiguration,
7
+ getDatabaseFieldForEntityField,
8
+ EntityDatabaseAdapterPaginationCursorInvalidError,
9
+ } from '@expo/entity';
10
+ import assert from 'assert';
11
+
12
+ import {
13
+ BasePostgresEntityDatabaseAdapter,
14
+ FieldEqualityCondition,
15
+ NullsOrdering,
16
+ OrderByOrdering,
17
+ PostgresOrderByClause,
18
+ PostgresQuerySelectionModifiers,
19
+ } from '../BasePostgresEntityDatabaseAdapter';
20
+ import { PaginationStrategy } from '../PaginationStrategy';
21
+ import { SQLFragment, SQLFragmentHelpers, identifier, unsafeRaw, sql } from '../SQLOperator';
22
+ import { DistributiveOmit, NonNullableKeys } from './utilityTypes';
23
+
24
+ interface DataManagerStandardSpecification<TFields extends Record<string, any>> {
25
+ strategy: PaginationStrategy.STANDARD;
26
+ orderBy: readonly PostgresOrderByClause<TFields>[];
27
+ }
28
+
29
+ type DataManagerFieldNameConstructorFn<TFields extends Record<string, any>> = (
30
+ fieldName: keyof TFields,
31
+ ) => SQLFragment<TFields>;
32
+
33
+ type DataManagerSearchFieldSQLFragmentFnSpecification<TFields extends Record<string, any>> = {
34
+ fieldConstructor: (
35
+ getFragmentForFieldName: DataManagerFieldNameConstructorFn<TFields>,
36
+ ) => SQLFragment<TFields>;
37
+ };
38
+
39
+ function isDataManagerSearchFieldSQLFragmentFnSpecification<TFields extends Record<string, any>>(
40
+ obj:
41
+ | keyof TFields
42
+ | SQLFragment<TFields>
43
+ | DataManagerSearchFieldSQLFragmentFnSpecification<TFields>,
44
+ ): obj is DataManagerSearchFieldSQLFragmentFnSpecification<TFields> {
45
+ return typeof obj === 'object' && obj !== null && 'fieldConstructor' in obj;
46
+ }
47
+
48
+ type DataManagerSearchFieldSpecification<TFields extends Record<string, any>> =
49
+ | NonNullableKeys<TFields>
50
+ | DataManagerSearchFieldSQLFragmentFnSpecification<TFields>;
51
+
52
+ interface DataManagerSearchSpecificationBase<TFields extends Record<string, any>> {
53
+ term: string;
54
+ fields: readonly DataManagerSearchFieldSpecification<TFields>[];
55
+ }
56
+
57
+ interface DataManagerILikeSearchSpecification<
58
+ TFields extends Record<string, any>,
59
+ > extends DataManagerSearchSpecificationBase<TFields> {
60
+ strategy: PaginationStrategy.ILIKE_SEARCH;
61
+ }
62
+
63
+ interface DataManagerTrigramSearchSpecification<
64
+ TFields extends Record<string, any>,
65
+ > extends DataManagerSearchSpecificationBase<TFields> {
66
+ strategy: PaginationStrategy.TRIGRAM_SEARCH;
67
+ threshold: number;
68
+ extraOrderByFields?: readonly DataManagerSearchFieldSpecification<TFields>[];
69
+ }
70
+
71
+ type DataManagerSearchSpecification<TFields extends Record<string, any>> =
72
+ | DataManagerILikeSearchSpecification<TFields>
73
+ | DataManagerTrigramSearchSpecification<TFields>;
74
+
75
+ type DataManagerPaginationSpecification<TFields extends Record<string, any>> =
76
+ | DataManagerStandardSpecification<TFields>
77
+ | DataManagerSearchSpecification<TFields>;
78
+
79
+ interface BaseUnifiedPaginationArgs<TFields extends Record<string, any>> {
80
+ where?: SQLFragment<TFields>;
81
+ pagination: DataManagerPaginationSpecification<TFields>;
82
+ }
83
+
84
+ interface ForwardUnifiedPaginationArgs<
85
+ TFields extends Record<string, any>,
86
+ > extends BaseUnifiedPaginationArgs<TFields> {
87
+ first: number;
88
+ last?: never;
89
+ before?: never;
90
+ after?: string;
91
+ }
92
+
93
+ interface BackwardUnifiedPaginationArgs<
94
+ TFields extends Record<string, any>,
95
+ > extends BaseUnifiedPaginationArgs<TFields> {
96
+ first?: never;
97
+ last: number;
98
+ before?: string;
99
+ after?: never;
100
+ }
101
+
102
+ type LoadPageArgs<TFields extends Record<string, any>> =
103
+ | ForwardUnifiedPaginationArgs<TFields>
104
+ | BackwardUnifiedPaginationArgs<TFields>;
105
+
106
+ /**
107
+ * Edge in a connection
108
+ */
109
+ export interface Edge<TNode> {
110
+ cursor: string;
111
+ node: TNode;
112
+ }
113
+
114
+ /**
115
+ * Page information for pagination
116
+ */
117
+ export interface PageInfo {
118
+ hasNextPage: boolean;
119
+ hasPreviousPage: boolean;
120
+ startCursor: string | null;
121
+ endCursor: string | null;
122
+ }
123
+
124
+ /**
125
+ * Relay-style Connection type
126
+ */
127
+ export interface Connection<TNode> {
128
+ edges: readonly Edge<TNode>[];
129
+ pageInfo: PageInfo;
130
+ }
131
+
132
+ enum PaginationDirection {
133
+ FORWARD = 'forward',
134
+ BACKWARD = 'backward',
135
+ }
136
+
137
+ const CURSOR_ROW_TABLE_ALIAS = 'cursor_row';
138
+
139
+ interface PaginationProvider<TFields extends Record<string, any>, TIDField extends keyof TFields> {
140
+ whereClause: SQLFragment<TFields> | undefined;
141
+ buildOrderBy: (direction: PaginationDirection) => readonly PostgresOrderByClause<TFields>[];
142
+ buildCursorCondition: (
143
+ decodedCursorId: TFields[TIDField],
144
+ direction: PaginationDirection,
145
+ orderByClauses: readonly PostgresOrderByClause<TFields>[],
146
+ ) => SQLFragment<TFields>;
147
+ }
148
+
149
+ /**
150
+ * A knex data manager is responsible for handling non-dataloader-based
151
+ * database operations.
152
+ *
153
+ * @internal
154
+ */
155
+ export class EntityKnexDataManager<
156
+ TFields extends Record<string, any>,
157
+ TIDField extends keyof TFields,
158
+ > {
159
+ constructor(
160
+ private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
161
+ private readonly databaseAdapter: BasePostgresEntityDatabaseAdapter<TFields, TIDField>,
162
+ private readonly metricsAdapter: IEntityMetricsAdapter,
163
+ private readonly entityClassName: string,
164
+ ) {}
165
+
166
+ /**
167
+ * Loads many objects matching the conjunction of where clauses constructed from
168
+ * specified field equality operands.
169
+ *
170
+ * @param queryContext - query context in which to perform the load
171
+ * @param fieldEqualityOperands - list of field equality where clause operand specifications
172
+ * @param querySelectionModifiers - limit, offset, and orderBy for the query
173
+ * @returns array of objects matching the query
174
+ */
175
+ async loadManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
176
+ queryContext: EntityQueryContext,
177
+ fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
178
+ querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
179
+ ): Promise<readonly Readonly<TFields>[]> {
180
+ EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
181
+
182
+ return await timeAndLogLoadEventAsync(
183
+ this.metricsAdapter,
184
+ EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION,
185
+ this.entityClassName,
186
+ queryContext,
187
+ )(
188
+ this.databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
189
+ queryContext,
190
+ fieldEqualityOperands,
191
+ querySelectionModifiers,
192
+ ),
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Loads many objects matching the raw WHERE clause.
198
+ *
199
+ * @param queryContext - query context in which to perform the load
200
+ * @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
201
+ * @param bindings - array of positional bindings or object of named bindings
202
+ * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
203
+ * @returns array of objects matching the query
204
+ */
205
+ async loadManyByRawWhereClauseAsync(
206
+ queryContext: EntityQueryContext,
207
+ rawWhereClause: string,
208
+ bindings: readonly any[] | object,
209
+ querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
210
+ ): Promise<readonly Readonly<TFields>[]> {
211
+ EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
212
+
213
+ return await timeAndLogLoadEventAsync(
214
+ this.metricsAdapter,
215
+ EntityMetricsLoadType.LOAD_MANY_RAW,
216
+ this.entityClassName,
217
+ queryContext,
218
+ )(
219
+ this.databaseAdapter.fetchManyByRawWhereClauseAsync(
220
+ queryContext,
221
+ rawWhereClause,
222
+ bindings,
223
+ querySelectionModifiers,
224
+ ),
225
+ );
226
+ }
227
+
228
+ async loadManyBySQLFragmentAsync(
229
+ queryContext: EntityQueryContext,
230
+ sqlFragment: SQLFragment<TFields>,
231
+ querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
232
+ ): Promise<readonly Readonly<TFields>[]> {
233
+ EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
234
+
235
+ return await timeAndLogLoadEventAsync(
236
+ this.metricsAdapter,
237
+ EntityMetricsLoadType.LOAD_MANY_SQL,
238
+ this.entityClassName,
239
+ queryContext,
240
+ )(
241
+ this.databaseAdapter.fetchManyBySQLFragmentAsync(
242
+ queryContext,
243
+ sqlFragment,
244
+ querySelectionModifiers,
245
+ ),
246
+ );
247
+ }
248
+
249
+ /**
250
+ * Load a page of objects using cursor-based pagination with unified pagination specification.
251
+ *
252
+ * @remarks
253
+ *
254
+ * This method implements cursor-based pagination using the seek method for efficient pagination even on large datasets
255
+ * given appropriate indexes. Cursors are opaque and encode the necessary information to fetch the next page based on the
256
+ * specified pagination strategy (standard, ILIKE search, or trigram search). For this implementation in particular,
257
+ * the cursor encodes the ID of the last entity in the page to ensure correct pagination for all strategies, even in cases
258
+ * where multiple rows have the same value for all fields other than the ID. If the entity referenced by a cursor has been
259
+ * deleted, the load will return an empty page with `hasNextPage: false`.
260
+ *
261
+ * @param queryContext - query context in which to perform the load
262
+ * @param args - pagination arguments including pagination and first/after or last/before
263
+ * @returns connection with edges containing field objects and page info
264
+ */
265
+ async loadPageAsync(
266
+ queryContext: EntityQueryContext,
267
+ args: LoadPageArgs<TFields>,
268
+ ): Promise<Connection<Readonly<TFields>>> {
269
+ const { where, pagination } = args;
270
+
271
+ if (pagination.strategy === PaginationStrategy.STANDARD) {
272
+ // Standard pagination
273
+ EntityKnexDataManager.validateOrderByClauses(pagination.orderBy);
274
+ EntityKnexDataManager.validateOrderByClausesHaveConsistentDirection(pagination.orderBy);
275
+
276
+ const idField = this.entityConfiguration.idField;
277
+ const augmentedOrderByClauses = this.augmentOrderByIfNecessary(pagination.orderBy, idField);
278
+
279
+ const fieldsToUseInPostgresTupleCursor: readonly (keyof TFields | SQLFragment<TFields>)[] =
280
+ augmentedOrderByClauses.map((order) =>
281
+ 'fieldName' in order ? order.fieldName : order.fieldFragment,
282
+ );
283
+
284
+ // Create strategy for regular pagination
285
+ const strategy: PaginationProvider<TFields, TIDField> = {
286
+ whereClause: where,
287
+ buildOrderBy: (direction: PaginationDirection) => {
288
+ // For backward pagination, we flip the ORDER BY and NULLS direction to fetch records
289
+ // in reverse order. This allows us to use a simple "less than" cursor comparison
290
+ // instead of complex SQL. We'll reverse the results array later to restore
291
+ // the original requested order.
292
+ // Example: If user wants last 3 items ordered by name ASC, we:
293
+ // 1. Flip to name DESC to get the last items first
294
+ // 2. Apply cursor with < comparison
295
+ // 3. Reverse the final array to present items in name ASC order
296
+ return direction === PaginationDirection.FORWARD
297
+ ? augmentedOrderByClauses
298
+ : augmentedOrderByClauses.map(
299
+ (clause): PostgresOrderByClause<TFields> => ({
300
+ ...clause,
301
+ order:
302
+ clause.order === OrderByOrdering.ASCENDING
303
+ ? OrderByOrdering.DESCENDING
304
+ : OrderByOrdering.ASCENDING,
305
+ nulls: clause.nulls
306
+ ? EntityKnexDataManager.flipNullsOrderingSpread(clause.nulls)
307
+ : undefined,
308
+ }),
309
+ );
310
+ },
311
+ buildCursorCondition: (decodedCursorId, _direction, orderByClauses) => {
312
+ // all clauses are guaranteed to have the same order due to validation, so we can just look at the first one for effective ordering
313
+ const effectiveOrdering = orderByClauses[0]?.order ?? OrderByOrdering.ASCENDING;
314
+ return this.buildCursorCondition(
315
+ decodedCursorId,
316
+ fieldsToUseInPostgresTupleCursor,
317
+ effectiveOrdering,
318
+ );
319
+ },
320
+ };
321
+
322
+ return await this.loadPageInternalAsync(queryContext, args, strategy);
323
+ } else {
324
+ // Search pagination (ILIKE or TRIGRAM)
325
+ const search = pagination;
326
+
327
+ // Validate search parameters
328
+ assert(search.term.length > 0, 'Search term must be a non-empty string');
329
+ assert(search.fields.length > 0, 'Search fields must be a non-empty array');
330
+
331
+ const { searchWhere, searchOrderByClauses } = this.buildSearchConditionAndOrderBy(search);
332
+
333
+ // Combine WHERE conditions: base where + search where
334
+ const whereClause =
335
+ where && searchWhere ? SQLFragmentHelpers.and(where, searchWhere) : (where ?? searchWhere);
336
+
337
+ const fieldsToUseInPostgresTupleCursor =
338
+ search.strategy === PaginationStrategy.TRIGRAM_SEARCH
339
+ ? // For trigram search, cursor includes extra order by fields (if specified) + ID to ensure stable ordering that matches ORDER BY clause
340
+ [...(search.extraOrderByFields ?? []), this.entityConfiguration.idField]
341
+ : // For ILIKE search, cursor includes search fields + ID to ensure stable ordering that matches ORDER BY clause
342
+ [...search.fields, this.entityConfiguration.idField];
343
+
344
+ // Create strategy for search pagination
345
+ const strategy: PaginationProvider<TFields, TIDField> = {
346
+ whereClause,
347
+ buildOrderBy: (direction) => {
348
+ return direction === PaginationDirection.FORWARD
349
+ ? searchOrderByClauses
350
+ : searchOrderByClauses.map(
351
+ (clause): DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'> => ({
352
+ ...clause,
353
+ order:
354
+ clause.order === OrderByOrdering.ASCENDING
355
+ ? OrderByOrdering.DESCENDING
356
+ : OrderByOrdering.ASCENDING,
357
+ }),
358
+ );
359
+ },
360
+ buildCursorCondition: (decodedCursorId, direction) =>
361
+ search.strategy === PaginationStrategy.TRIGRAM_SEARCH
362
+ ? this.buildTrigramCursorCondition(search, decodedCursorId, direction)
363
+ : this.buildCursorCondition(
364
+ decodedCursorId,
365
+ fieldsToUseInPostgresTupleCursor,
366
+ // ILIKE search always orders ASC, so effective ordering matches direction
367
+ direction === PaginationDirection.FORWARD
368
+ ? OrderByOrdering.ASCENDING
369
+ : OrderByOrdering.DESCENDING,
370
+ ),
371
+ };
372
+
373
+ return await this.loadPageInternalAsync(queryContext, args, strategy);
374
+ }
375
+ }
376
+
377
+ getCursorForEntityID(entityID: TFields[TIDField]): string {
378
+ return this.encodeOpaqueCursor(entityID);
379
+ }
380
+
381
+ /**
382
+ * Internal method for loading a page with cursor-based pagination.
383
+ * Shared logic for both regular and search pagination.
384
+ */
385
+ private async loadPageInternalAsync(
386
+ queryContext: EntityQueryContext,
387
+ args: LoadPageArgs<TFields>,
388
+ paginationProvider: PaginationProvider<TFields, TIDField>,
389
+ ): Promise<Connection<Readonly<TFields>>> {
390
+ const idField = this.entityConfiguration.idField;
391
+
392
+ // Validate pagination arguments
393
+ const maxPageSize = this.databaseAdapter.paginationMaxPageSize;
394
+ const isForward = 'first' in args && args.first !== undefined;
395
+ if (isForward) {
396
+ assert(
397
+ Number.isInteger(args.first) && args.first > 0,
398
+ 'first must be an integer greater than 0',
399
+ );
400
+ if (maxPageSize !== undefined) {
401
+ assert(
402
+ args.first <= maxPageSize,
403
+ `first must not exceed maximum page size of ${maxPageSize}`,
404
+ );
405
+ }
406
+ } else {
407
+ assert(
408
+ Number.isInteger(args.last) && args.last > 0,
409
+ 'last must be an integer greater than 0',
410
+ );
411
+ if (maxPageSize !== undefined) {
412
+ assert(
413
+ args.last <= maxPageSize,
414
+ `last must not exceed maximum page size of ${maxPageSize}`,
415
+ );
416
+ }
417
+ }
418
+
419
+ const direction = isForward ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;
420
+ const limit = isForward ? args.first : args.last;
421
+ const cursor = isForward ? args.after : args.before;
422
+
423
+ // Decode cursor
424
+ const decodedExternalCursorEntityID = cursor ? this.decodeOpaqueCursor(cursor) : null;
425
+
426
+ // Get ordering from strategy
427
+ const orderByClauses = paginationProvider.buildOrderBy(direction);
428
+
429
+ // Build WHERE clause with cursor condition
430
+ const baseWhere = paginationProvider.whereClause;
431
+ const cursorCondition = decodedExternalCursorEntityID
432
+ ? paginationProvider.buildCursorCondition(
433
+ decodedExternalCursorEntityID,
434
+ direction,
435
+ orderByClauses,
436
+ )
437
+ : null;
438
+
439
+ const whereClause = this.combineWhereConditions(baseWhere, cursorCondition);
440
+
441
+ // Determine query modifiers
442
+ const queryModifiers: PostgresQuerySelectionModifiers<TFields> = {
443
+ ...(orderByClauses !== undefined && { orderBy: orderByClauses }),
444
+ limit: limit + 1, // Fetch data with limit + 1 to check for more pages
445
+ };
446
+
447
+ const fieldObjects = await timeAndLogLoadEventAsync(
448
+ this.metricsAdapter,
449
+ EntityMetricsLoadType.LOAD_PAGE,
450
+ this.entityClassName,
451
+ queryContext,
452
+ )(this.databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, whereClause, queryModifiers));
453
+
454
+ // Process results
455
+ const hasMore = fieldObjects.length > limit;
456
+ const pageFieldObjects = hasMore ? fieldObjects.slice(0, limit) : [...fieldObjects];
457
+
458
+ if (direction === PaginationDirection.BACKWARD) {
459
+ // Restore the original requested order by reversing the results.
460
+ // We fetched with flipped ORDER BY for efficient cursor comparison,
461
+ // so now we reverse to match the order the user expects.
462
+ pageFieldObjects.reverse();
463
+ }
464
+
465
+ // Build edges with cursors
466
+ const edges = pageFieldObjects.map((fieldObject) => ({
467
+ node: fieldObject,
468
+ cursor: this.encodeOpaqueCursor(fieldObject[idField]),
469
+ }));
470
+
471
+ const pageInfo: PageInfo = {
472
+ hasNextPage: direction === PaginationDirection.FORWARD ? hasMore : false,
473
+ hasPreviousPage: direction === PaginationDirection.BACKWARD ? hasMore : false,
474
+ startCursor: edges[0]?.cursor ?? null,
475
+ endCursor: edges[edges.length - 1]?.cursor ?? null,
476
+ };
477
+
478
+ return {
479
+ edges,
480
+ pageInfo,
481
+ };
482
+ }
483
+
484
+ private combineWhereConditions(
485
+ baseWhere: SQLFragment<TFields> | undefined,
486
+ cursorCondition: SQLFragment<TFields> | null,
487
+ ): SQLFragment<TFields> {
488
+ const conditions = [baseWhere, cursorCondition].filter((it) => !!it);
489
+ if (conditions.length === 0) {
490
+ return sql`1 = 1`;
491
+ }
492
+ if (conditions.length === 1) {
493
+ return conditions[0]!;
494
+ }
495
+ // Wrap baseWhere in parens if combining with cursor condition
496
+ // We know we have exactly 2 conditions at this point
497
+ const [first, second] = conditions;
498
+ return sql`(${first}) AND ${second}`;
499
+ }
500
+
501
+ private augmentOrderByIfNecessary(
502
+ orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
503
+ idField: TIDField,
504
+ ): readonly PostgresOrderByClause<TFields>[] {
505
+ const clauses = orderBy ?? [];
506
+
507
+ // Always ensure ID is included for stability and cursor correctness. Note that this may add a redundant order by
508
+ // if ID is already included as a fragment, but that is preferable to the risk of incorrect pagination behavior.
509
+ const hasId = clauses.some((spec) => 'fieldName' in spec && spec.fieldName === idField);
510
+ if (!hasId) {
511
+ const lastClauseOrder =
512
+ clauses.length > 0 ? clauses[clauses.length - 1]!.order : OrderByOrdering.ASCENDING;
513
+ return [...clauses, { fieldName: idField, order: lastClauseOrder }];
514
+ }
515
+ return clauses;
516
+ }
517
+
518
+ private static flipNullsOrderingSpread(
519
+ nulls: NullsOrdering | undefined,
520
+ ): NullsOrdering | undefined {
521
+ return nulls === NullsOrdering.FIRST ? NullsOrdering.LAST : NullsOrdering.FIRST;
522
+ }
523
+
524
+ private static validateOrderByClauses<TFields extends Record<string, any>>(
525
+ orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
526
+ ): void {
527
+ if (orderBy === undefined) {
528
+ return;
529
+ }
530
+ for (const clause of orderBy) {
531
+ if ('fieldFragment' in clause) {
532
+ const trimmedSql = clause.fieldFragment.sql.trimEnd();
533
+ assert(
534
+ !/\b(ASC|DESC)\s*$/i.test(trimmedSql),
535
+ 'fieldFragment must not contain ASC or DESC at the end. Use the order property to specify ordering direction.',
536
+ );
537
+ }
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Cursor-based pagination uses Postgres tuple comparison (e.g., (a, b) \> (x, y)) which
543
+ * applies a single comparison direction to all columns. Mixed ordering directions would
544
+ * produce incorrect pagination results.
545
+ */
546
+ private static validateOrderByClausesHaveConsistentDirection<TFields extends Record<string, any>>(
547
+ orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
548
+ ): void {
549
+ if (orderBy === undefined || orderBy.length <= 1) {
550
+ return;
551
+ }
552
+ const firstOrder = orderBy[0]!.order;
553
+ assert(
554
+ orderBy.every((clause) => clause.order === firstOrder),
555
+ 'All orderBy clauses must have the same ordering direction. Mixed ordering directions are not supported with cursor-based pagination.',
556
+ );
557
+ }
558
+
559
+ private encodeOpaqueCursor(idField: TFields[TIDField]): string {
560
+ return Buffer.from(JSON.stringify({ id: idField })).toString('base64url');
561
+ }
562
+
563
+ private decodeOpaqueCursor(cursor: string): TFields[TIDField] {
564
+ let parsedCursor: any;
565
+ try {
566
+ const decoded = Buffer.from(cursor, 'base64url').toString();
567
+ parsedCursor = JSON.parse(decoded);
568
+ } catch (e) {
569
+ throw new EntityDatabaseAdapterPaginationCursorInvalidError(
570
+ `Failed to decode cursor`,
571
+ e instanceof Error ? e : undefined,
572
+ );
573
+ }
574
+
575
+ if (!('id' in parsedCursor)) {
576
+ throw new EntityDatabaseAdapterPaginationCursorInvalidError(
577
+ `Cursor is missing required 'id' field. Parsed cursor: ${JSON.stringify(parsedCursor)}`,
578
+ );
579
+ }
580
+
581
+ return parsedCursor.id;
582
+ }
583
+
584
+ private resolveSearchFieldToSQLFragment(
585
+ field:
586
+ | keyof TFields
587
+ | SQLFragment<TFields>
588
+ | DataManagerSearchFieldSQLFragmentFnSpecification<TFields>,
589
+ tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
590
+ ): SQLFragment<TFields> {
591
+ if (field instanceof SQLFragment) {
592
+ return field;
593
+ }
594
+
595
+ if (isDataManagerSearchFieldSQLFragmentFnSpecification<TFields>(field)) {
596
+ return field.fieldConstructor((fieldName) => {
597
+ const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, fieldName);
598
+ return tableAlias
599
+ ? sql`${unsafeRaw(tableAlias)}.${identifier(dbField)}`
600
+ : sql`${identifier(dbField)}`;
601
+ });
602
+ }
603
+
604
+ const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
605
+ return tableAlias
606
+ ? sql`${unsafeRaw(tableAlias)}.${identifier(dbField)}`
607
+ : sql`${identifier(dbField)}`;
608
+ }
609
+
610
+ private buildCursorCondition(
611
+ decodedExternalCursorEntityID: TFields[TIDField],
612
+ fieldsToUseInPostgresTupleCursor: readonly (
613
+ | keyof TFields
614
+ | SQLFragment<TFields>
615
+ | DataManagerSearchFieldSQLFragmentFnSpecification<TFields>
616
+ )[],
617
+ effectiveOrdering: OrderByOrdering,
618
+ ): SQLFragment<TFields> {
619
+ // We build a tuple comparison for fieldsToUseInPostgresTupleCursor fields of the
620
+ // entity identified by the external cursor to ensure correct pagination behavior
621
+ // even in cases where multiple rows have the same value all fields other than id.
622
+ // If the cursor entity has been deleted, the subquery returns no rows and the
623
+ // comparison evaluates to NULL, filtering out all results (empty page).
624
+ const operator = effectiveOrdering === OrderByOrdering.ASCENDING ? '>' : '<';
625
+
626
+ const idField = getDatabaseFieldForEntityField(
627
+ this.entityConfiguration,
628
+ this.entityConfiguration.idField,
629
+ );
630
+ const tableName = this.entityConfiguration.tableName;
631
+
632
+ const postgresCursorFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) =>
633
+ this.resolveSearchFieldToSQLFragment(f),
634
+ );
635
+
636
+ // Build left side of comparison (current row's computed values)
637
+ const leftSide = SQLFragment.joinWithCommaSeparator(...postgresCursorFieldIdentifiers);
638
+
639
+ // Build right side using subquery to get computed values for cursor entity.
640
+ // For field names, qualify with the cursor row alias. For SQL fragments,
641
+ // use as-is since unqualified column names resolve to the only table in the subquery.
642
+ const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) =>
643
+ this.resolveSearchFieldToSQLFragment(f, CURSOR_ROW_TABLE_ALIAS),
644
+ );
645
+
646
+ // Build SELECT fields for subquery
647
+ const rightSideSubquery = sql`
648
+ SELECT ${SQLFragment.joinWithCommaSeparator(...postgresCursorRowFieldIdentifiers)}
649
+ FROM ${identifier(tableName)} AS ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}
650
+ WHERE ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
651
+ `;
652
+ return sql`(${leftSide}) ${unsafeRaw(operator)} (${rightSideSubquery})`;
653
+ }
654
+
655
+ private buildILikeConditions(
656
+ search: DataManagerSearchSpecification<TFields>,
657
+ tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
658
+ ): readonly SQLFragment<TFields>[] {
659
+ return search.fields.map((field) => {
660
+ const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias);
661
+ return sql`${fieldFragment} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`;
662
+ });
663
+ }
664
+
665
+ private buildTrigramSimilarityExpressions(
666
+ search: DataManagerSearchSpecification<TFields>,
667
+ tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
668
+ ): readonly SQLFragment<TFields>[] {
669
+ return search.fields.map((field) => {
670
+ const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias);
671
+ return sql`similarity(${fieldFragment}, ${search.term})`;
672
+ });
673
+ }
674
+
675
+ private buildTrigramExactMatchCaseExpression(
676
+ search: DataManagerSearchSpecification<TFields>,
677
+ tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
678
+ ): SQLFragment<TFields> {
679
+ const ilikeConditions = this.buildILikeConditions(search, tableAlias);
680
+ return sql`CASE WHEN ${SQLFragmentHelpers.or(...ilikeConditions)} THEN 1 ELSE 0 END`;
681
+ }
682
+
683
+ private buildTrigramSimilarityGreatestExpression(
684
+ search: DataManagerSearchSpecification<TFields>,
685
+ tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
686
+ ): SQLFragment<TFields> {
687
+ const similarityExprs = this.buildTrigramSimilarityExpressions(search, tableAlias);
688
+ return sql`GREATEST(${SQLFragment.joinWithCommaSeparator(...similarityExprs)})`;
689
+ }
690
+
691
+ private buildTrigramCursorCondition(
692
+ search: DataManagerTrigramSearchSpecification<TFields>,
693
+ decodedExternalCursorEntityID: TFields[TIDField],
694
+ direction: PaginationDirection,
695
+ ): SQLFragment<TFields> {
696
+ // For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor.
697
+ // If the cursor entity has been deleted, the subquery returns no rows and the
698
+ // comparison evaluates to NULL, filtering out all results (empty page).
699
+ const operator = direction === PaginationDirection.FORWARD ? '<' : '>';
700
+ const idField = getDatabaseFieldForEntityField(
701
+ this.entityConfiguration,
702
+ this.entityConfiguration.idField,
703
+ );
704
+
705
+ const exactMatchExpr = this.buildTrigramExactMatchCaseExpression(search);
706
+ const similarityExpr = this.buildTrigramSimilarityGreatestExpression(search);
707
+
708
+ // Build extra order by fields
709
+ const extraOrderByFields = search.extraOrderByFields;
710
+ const extraFields =
711
+ extraOrderByFields?.map((f) => this.resolveSearchFieldToSQLFragment(f)) ?? [];
712
+
713
+ // Build left side of comparison (current row's computed values)
714
+ const leftSide = SQLFragment.joinWithCommaSeparator(
715
+ exactMatchExpr,
716
+ similarityExpr,
717
+ ...extraFields,
718
+ sql`${identifier(idField)}`,
719
+ );
720
+
721
+ // Build right side using subquery to get computed values for cursor entity
722
+ // We need to rebuild the same expressions for the cursor row
723
+
724
+ const cursorExactMatchExpr = this.buildTrigramExactMatchCaseExpression(
725
+ search,
726
+ CURSOR_ROW_TABLE_ALIAS,
727
+ );
728
+ const cursorSimilarityExpr = this.buildTrigramSimilarityGreatestExpression(
729
+ search,
730
+ CURSOR_ROW_TABLE_ALIAS,
731
+ );
732
+
733
+ const cursorExtraFields =
734
+ extraOrderByFields?.map((f) =>
735
+ this.resolveSearchFieldToSQLFragment(f, CURSOR_ROW_TABLE_ALIAS),
736
+ ) ?? [];
737
+
738
+ // Build SELECT fields for subquery
739
+ const selectFields: SQLFragment<TFields>[] = [
740
+ cursorExactMatchExpr,
741
+ cursorSimilarityExpr,
742
+ ...cursorExtraFields,
743
+ sql`${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
744
+ ];
745
+
746
+ const rightSideSubquery = sql`
747
+ SELECT ${SQLFragment.joinWithCommaSeparator(...selectFields)}
748
+ FROM ${identifier(this.entityConfiguration.tableName)} AS ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}
749
+ WHERE ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
750
+ `;
751
+
752
+ return sql`(${leftSide}) ${unsafeRaw(operator)} (${rightSideSubquery})`;
753
+ }
754
+
755
+ private buildSearchConditionAndOrderBy(search: DataManagerSearchSpecification<TFields>): {
756
+ searchWhere: SQLFragment<TFields>;
757
+ searchOrderByClauses: readonly DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'>[];
758
+ } {
759
+ switch (search.strategy) {
760
+ case PaginationStrategy.ILIKE_SEARCH: {
761
+ const conditions = this.buildILikeConditions(search);
762
+
763
+ // Order by search fields + ID to match cursor fields
764
+ const searchOrderByClauses: PostgresOrderByClause<TFields>[] = [
765
+ ...search.fields.map(
766
+ (field): PostgresOrderByClause<TFields> => ({
767
+ fieldFragment: this.resolveSearchFieldToSQLFragment(field),
768
+ order: OrderByOrdering.ASCENDING,
769
+ }),
770
+ ),
771
+ {
772
+ fieldName: this.entityConfiguration.idField,
773
+ order: OrderByOrdering.ASCENDING,
774
+ },
775
+ ];
776
+
777
+ return {
778
+ searchWhere: conditions.length > 0 ? SQLFragmentHelpers.or(...conditions) : sql`1 = 0`,
779
+ searchOrderByClauses,
780
+ };
781
+ }
782
+
783
+ case PaginationStrategy.TRIGRAM_SEARCH: {
784
+ // PostgreSQL trigram similarity
785
+ const ilikeConditions = this.buildILikeConditions(search);
786
+ const similarityExprs = this.buildTrigramSimilarityExpressions(search);
787
+
788
+ assert(
789
+ search.threshold >= 0 && search.threshold <= 1,
790
+ `Trigram similarity threshold must be between 0 and 1, got ${search.threshold}`,
791
+ );
792
+
793
+ const conditions = similarityExprs.map((expr) => sql`${expr} > ${search.threshold}`);
794
+
795
+ // Combine exact matches (ILIKE) with similarity
796
+ const allConditions = [...ilikeConditions, ...conditions];
797
+
798
+ // Build ORDER BY clauses for trigram search:
799
+ // 1. Exact matches first (ILIKE)
800
+ // 2. Then by similarity score
801
+ // 3. Then by extra fields and ID field for stability
802
+ const searchOrderByClauses: DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'>[] = [
803
+ {
804
+ fieldFragment: this.buildTrigramExactMatchCaseExpression(search),
805
+ order: OrderByOrdering.DESCENDING,
806
+ },
807
+ {
808
+ fieldFragment: this.buildTrigramSimilarityGreatestExpression(search),
809
+ order: OrderByOrdering.DESCENDING,
810
+ },
811
+ ...(search.extraOrderByFields ?? []).map((field) => ({
812
+ fieldFragment: this.resolveSearchFieldToSQLFragment(field),
813
+ order: OrderByOrdering.DESCENDING,
814
+ })),
815
+ {
816
+ fieldName: this.entityConfiguration.idField,
817
+ order: OrderByOrdering.DESCENDING,
818
+ },
819
+ ];
820
+
821
+ return {
822
+ searchWhere: SQLFragmentHelpers.or(...allConditions),
823
+ searchOrderByClauses,
824
+ };
825
+ }
826
+ }
827
+ }
828
+
829
+ private static escapeILikePattern(term: string): string {
830
+ return term.replace(/[%_\\]/g, '\\$&');
831
+ }
832
+ }