@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.
- package/build/src/AuthorizationResultBasedKnexEntityLoader.d.ts +278 -0
- package/build/src/AuthorizationResultBasedKnexEntityLoader.js +127 -0
- package/build/src/AuthorizationResultBasedKnexEntityLoader.js.map +1 -0
- package/build/src/BasePostgresEntityDatabaseAdapter.d.ts +150 -0
- package/build/src/BasePostgresEntityDatabaseAdapter.js +119 -0
- package/build/src/BasePostgresEntityDatabaseAdapter.js.map +1 -0
- package/build/src/BaseSQLQueryBuilder.d.ts +61 -0
- package/build/src/BaseSQLQueryBuilder.js +87 -0
- package/build/src/BaseSQLQueryBuilder.js.map +1 -0
- package/build/src/EnforcingKnexEntityLoader.d.ts +124 -0
- package/build/src/EnforcingKnexEntityLoader.js +166 -0
- package/build/src/EnforcingKnexEntityLoader.js.map +1 -0
- package/build/src/KnexEntityLoaderFactory.d.ts +25 -0
- package/build/src/KnexEntityLoaderFactory.js +39 -0
- package/build/src/KnexEntityLoaderFactory.js.map +1 -0
- package/build/src/PaginationStrategy.d.ts +30 -0
- package/build/src/PaginationStrategy.js +35 -0
- package/build/src/PaginationStrategy.js.map +1 -0
- package/build/src/PostgresEntity.d.ts +25 -0
- package/build/src/PostgresEntity.js +39 -0
- package/build/src/PostgresEntity.js.map +1 -0
- package/build/src/PostgresEntityDatabaseAdapter.d.ts +12 -5
- package/build/src/PostgresEntityDatabaseAdapter.js +33 -11
- package/build/src/PostgresEntityDatabaseAdapter.js.map +1 -1
- package/build/src/PostgresEntityDatabaseAdapterProvider.d.ts +9 -0
- package/build/src/PostgresEntityDatabaseAdapterProvider.js +5 -1
- package/build/src/PostgresEntityDatabaseAdapterProvider.js.map +1 -1
- package/build/src/ReadonlyPostgresEntity.d.ts +25 -0
- package/build/src/ReadonlyPostgresEntity.js +39 -0
- package/build/src/ReadonlyPostgresEntity.js.map +1 -0
- package/build/src/SQLOperator.d.ts +267 -0
- package/build/src/SQLOperator.js +474 -0
- package/build/src/SQLOperator.js.map +1 -0
- package/build/src/index.d.ts +15 -0
- package/build/src/index.js +15 -0
- package/build/src/index.js.map +1 -1
- package/build/src/internal/EntityKnexDataManager.d.ts +147 -0
- package/build/src/internal/EntityKnexDataManager.js +453 -0
- package/build/src/internal/EntityKnexDataManager.js.map +1 -0
- package/build/src/internal/getKnexDataManager.d.ts +3 -0
- package/build/src/internal/getKnexDataManager.js +19 -0
- package/build/src/internal/getKnexDataManager.js.map +1 -0
- package/build/src/internal/getKnexEntityLoaderFactory.d.ts +3 -0
- package/build/src/internal/getKnexEntityLoaderFactory.js +11 -0
- package/build/src/internal/getKnexEntityLoaderFactory.js.map +1 -0
- package/build/src/internal/utilityTypes.d.ts +5 -0
- package/build/src/internal/utilityTypes.js +5 -0
- package/build/src/internal/utilityTypes.js.map +1 -0
- package/build/src/internal/weakMaps.d.ts +9 -0
- package/build/src/internal/weakMaps.js +20 -0
- package/build/src/internal/weakMaps.js.map +1 -0
- package/build/src/knexLoader.d.ts +18 -0
- package/build/src/knexLoader.js +31 -0
- package/build/src/knexLoader.js.map +1 -0
- package/package.json +6 -5
- package/src/AuthorizationResultBasedKnexEntityLoader.ts +537 -0
- package/src/BasePostgresEntityDatabaseAdapter.ts +317 -0
- package/src/BaseSQLQueryBuilder.ts +114 -0
- package/src/EnforcingKnexEntityLoader.ts +271 -0
- package/src/KnexEntityLoaderFactory.ts +130 -0
- package/src/PaginationStrategy.ts +32 -0
- package/src/PostgresEntity.ts +118 -0
- package/src/PostgresEntityDatabaseAdapter.ts +81 -24
- package/src/PostgresEntityDatabaseAdapterProvider.ts +11 -1
- package/src/ReadonlyPostgresEntity.ts +115 -0
- package/src/SQLOperator.ts +630 -0
- package/src/__integration-tests__/EntityCreationUtils-test.ts +25 -31
- package/src/__integration-tests__/PostgresEntityIntegration-test.ts +3192 -330
- package/src/__integration-tests__/PostgresEntityQueryContextProvider-test.ts +7 -7
- package/src/__testfixtures__/PostgresTestEntity.ts +17 -3
- package/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts +1167 -0
- package/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts +160 -0
- package/src/__tests__/EnforcingKnexEntityLoader-test.ts +384 -0
- package/src/__tests__/EntityFields-test.ts +1 -1
- package/src/__tests__/PostgresEntity-test.ts +172 -0
- package/src/__tests__/ReadonlyEntity-test.ts +32 -0
- package/src/__tests__/SQLOperator-test.ts +871 -0
- package/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts +302 -0
- package/src/__tests__/fixtures/StubPostgresDatabaseAdapterProvider.ts +17 -0
- package/src/__tests__/fixtures/TestEntity.ts +131 -0
- package/src/__tests__/fixtures/TestPaginationEntity.ts +107 -0
- package/src/__tests__/fixtures/createUnitTestPostgresEntityCompanionProvider.ts +42 -0
- package/src/index.ts +15 -0
- package/src/internal/EntityKnexDataManager.ts +832 -0
- package/src/internal/__tests__/EntityKnexDataManager-test.ts +378 -0
- package/src/internal/__tests__/weakMaps-test.ts +25 -0
- package/src/internal/getKnexDataManager.ts +43 -0
- package/src/internal/getKnexEntityLoaderFactory.ts +60 -0
- package/src/internal/utilityTypes.ts +11 -0
- package/src/internal/weakMaps.ts +19 -0
- 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
|
+
}
|