Skip to content

Commit 3a3f704

Browse files
committed
feat: Add ilike and trigram similarity search to pagination
1 parent 9a0ae15 commit 3a3f704

7 files changed

Lines changed: 1336 additions & 37 deletions

File tree

packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
FieldEqualityCondition,
1313
isSingleValueFieldEqualityCondition,
1414
OrderByOrdering,
15+
SearchStrategy,
1516
} from './BasePostgresEntityDatabaseAdapter';
1617
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
1718
import { SQLFragment } from './SQLOperator';
@@ -76,6 +77,40 @@ export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
7677
orderByFragment?: SQLFragment;
7778
}
7879

80+
interface SearchSpecificationBase<
81+
TFields extends Record<string, any>,
82+
TSelectedFields extends keyof TFields,
83+
> {
84+
term: string;
85+
fields: TSelectedFields[];
86+
}
87+
88+
interface ILikeSearchSpecification<
89+
TFields extends Record<string, any>,
90+
TSelectedFields extends keyof TFields,
91+
> extends SearchSpecificationBase<TFields, TSelectedFields> {
92+
strategy: SearchStrategy.ILIKE;
93+
}
94+
95+
interface TrigramSearchSpecification<
96+
TFields extends Record<string, any>,
97+
TSelectedFields extends keyof TFields,
98+
> extends SearchSpecificationBase<TFields, TSelectedFields> {
99+
strategy: SearchStrategy.TRIGRAM;
100+
threshold: number;
101+
extraOrderByFields?: TSelectedFields[];
102+
}
103+
104+
/**
105+
* Search specification for SQL-based pagination.
106+
*/
107+
export type SearchSpecification<
108+
TFields extends Record<string, any>,
109+
TSelectedFields extends keyof TFields,
110+
> =
111+
| ILikeSearchSpecification<TFields, TSelectedFields>
112+
| TrigramSearchSpecification<TFields, TSelectedFields>;
113+
79114
/**
80115
* Base pagination arguments
81116
*/
@@ -105,6 +140,31 @@ interface EntityLoaderBasePaginationArgs<
105140
includeTotal?: boolean;
106141
}
107142

143+
/**
144+
* Base search pagination arguments
145+
*/
146+
interface EntityLoaderBaseSearchPaginationArgs<
147+
TFields extends Record<string, any>,
148+
TSelectedFields extends keyof TFields,
149+
> {
150+
/**
151+
* SQLFragment representing the WHERE clause to filter the entities being paginated.
152+
*/
153+
where?: SQLFragment;
154+
155+
/**
156+
* Whether to calculate and include the Connection totalCount field.
157+
*/
158+
includeTotal?: boolean;
159+
160+
/**
161+
* Search specification for filtering and ordering entities by text search relevance.
162+
* For TRIGRAM strategy: Results are ordered by exact match priority, then similarity score.
163+
* For ILIKE strategy: Results use standard field ordering.
164+
*/
165+
search: SearchSpecification<TFields, TSelectedFields>;
166+
}
167+
108168
/**
109169
* Forward pagination arguments
110170
*/
@@ -141,6 +201,44 @@ export interface EntityLoaderBackwardPaginationArgs<
141201
before?: string;
142202
}
143203

204+
/**
205+
* Forward search pagination arguments
206+
*/
207+
export interface EntityLoaderForwardSearchPaginationArgs<
208+
TFields extends Record<string, any>,
209+
TSelectedFields extends keyof TFields,
210+
> extends EntityLoaderBaseSearchPaginationArgs<TFields, TSelectedFields> {
211+
/**
212+
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
213+
*/
214+
first: number;
215+
216+
/**
217+
* The cursor to paginate after for forward pagination.
218+
* Note: With search relevance ordering, cursor pagination may not maintain exact relevance order across pages.
219+
*/
220+
after?: string;
221+
}
222+
223+
/**
224+
* Backward search pagination arguments
225+
*/
226+
export interface EntityLoaderBackwardSearchPaginationArgs<
227+
TFields extends Record<string, any>,
228+
TSelectedFields extends keyof TFields,
229+
> extends EntityLoaderBaseSearchPaginationArgs<TFields, TSelectedFields> {
230+
/**
231+
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
232+
*/
233+
last: number;
234+
235+
/**
236+
* The cursor to paginate before for backward pagination.
237+
* Note: With search relevance ordering, cursor pagination may not maintain exact relevance order across pages.
238+
*/
239+
before?: string;
240+
}
241+
144242
/**
145243
* Load page pagination arguments, which can be either forward or backward pagination arguments.
146244
*/
@@ -151,6 +249,16 @@ export type EntityLoaderLoadPageArgs<
151249
| EntityLoaderForwardPaginationArgs<TFields, TSelectedFields>
152250
| EntityLoaderBackwardPaginationArgs<TFields, TSelectedFields>;
153251

252+
/**
253+
* Load page with search pagination arguments, which can be either forward or backward search pagination arguments.
254+
*/
255+
export type EntityLoaderLoadPageWithSearchArgs<
256+
TFields extends Record<string, any>,
257+
TSelectedFields extends keyof TFields,
258+
> =
259+
| EntityLoaderForwardSearchPaginationArgs<TFields, TSelectedFields>
260+
| EntityLoaderBackwardSearchPaginationArgs<TFields, TSelectedFields>;
261+
154262
/**
155263
* Authorization-result-based knex entity loader for non-data-loader-based load methods.
156264
* All loads through this loader are results (or null for some loader methods), where an
@@ -318,6 +426,49 @@ export class AuthorizationResultBasedKnexEntityLoader<
318426
...(pageResult.totalCount !== undefined && { totalCount: pageResult.totalCount }),
319427
};
320428
}
429+
430+
/**
431+
* Load a page of entities with Relay-style cursor pagination and search.
432+
* Search determines the ordering - no explicit orderBy is allowed.
433+
* Only returns successfully authorized entities for cursor stability; failed authorization results are filtered out.
434+
*
435+
* @returns Connection with only successfully authorized entities
436+
*/
437+
async loadPageBySQLAndSearchAsync(
438+
args: EntityLoaderLoadPageWithSearchArgs<TFields, TSelectedFields>,
439+
): Promise<Connection<TEntity>> {
440+
const pageResult = await this.knexDataManager.loadPageBySQLFragmentAndSearchAsync(
441+
this.queryContext,
442+
args,
443+
);
444+
445+
const edgeResults = await Promise.all(
446+
pageResult.edges.map(async (edge) => {
447+
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
448+
edge.node,
449+
);
450+
if (!entityResult.ok) {
451+
return null;
452+
}
453+
return {
454+
...edge,
455+
node: entityResult.value,
456+
};
457+
}),
458+
);
459+
const edges = edgeResults.filter((edge) => edge !== null);
460+
const pageInfo: PageInfo = {
461+
...pageResult.pageInfo,
462+
startCursor: edges[0]?.cursor ?? null,
463+
endCursor: edges[edges.length - 1]?.cursor ?? null,
464+
};
465+
466+
return {
467+
edges,
468+
pageInfo,
469+
...(pageResult.totalCount !== undefined && { totalCount: pageResult.totalCount }),
470+
};
471+
}
321472
}
322473

323474
/**

packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,37 @@ export interface TableQuerySelectionModifiersWithOrderByFragment extends TableQu
145145
orderByFragment: SQLFragment | undefined;
146146
}
147147

148+
/**
149+
* Search strategy for SQL-based pagination.
150+
*/
151+
export enum SearchStrategy {
152+
ILIKE = 'ilike',
153+
TRIGRAM = 'trigram',
154+
}
155+
156+
interface PostgresSearchSpecificationBase<TFields extends Record<string, any>> {
157+
term: string;
158+
fields: (keyof TFields)[];
159+
}
160+
161+
interface PostgresILikeSearchSpecification<
162+
TFields extends Record<string, any>,
163+
> extends PostgresSearchSpecificationBase<TFields> {
164+
strategy: SearchStrategy.ILIKE;
165+
}
166+
167+
interface PostgresTrigramSearchSpecification<
168+
TFields extends Record<string, any>,
169+
> extends PostgresSearchSpecificationBase<TFields> {
170+
strategy: SearchStrategy.TRIGRAM;
171+
threshold: number;
172+
extraOrderByFields?: (keyof TFields)[];
173+
}
174+
175+
export type PostgresSearchSpecification<TFields extends Record<string, any>> =
176+
| PostgresILikeSearchSpecification<TFields>
177+
| PostgresTrigramSearchSpecification<TFields>;
178+
148179
export abstract class BasePostgresEntityDatabaseAdapter<
149180
TFields extends Record<string, any>,
150181
TIDField extends keyof TFields,

packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import {
1111
AuthorizationResultBasedKnexEntityLoader,
1212
EntityLoaderLoadPageArgs,
13+
EntityLoaderLoadPageWithSearchArgs,
1314
EntityLoaderQuerySelectionModifiers,
1415
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
1516
EntityLoaderQuerySelectionModifiersWithOrderByRaw,
@@ -241,6 +242,41 @@ export class EnforcingKnexEntityLoader<
241242
...(pageResult.totalCount !== undefined && { totalCount: pageResult.totalCount }),
242243
};
243244
}
245+
246+
/**
247+
* Load a page of entities with Relay-style cursor pagination and search.
248+
* Search determines the ordering - no explicit orderBy is allowed.
249+
*
250+
* @param args - Pagination and search arguments
251+
* @returns Connection with successfully authorized entities
252+
* @throws EntityNotAuthorizedError if viewer is not authorized to view any returned entity
253+
*/
254+
async loadPageBySQLAndSearchAsync(
255+
args: EntityLoaderLoadPageWithSearchArgs<TFields, TSelectedFields>,
256+
): Promise<Connection<TEntity>> {
257+
const pageResult = await this.knexDataManager.loadPageBySQLFragmentAndSearchAsync(
258+
this.queryContext,
259+
args,
260+
);
261+
262+
const edges = await Promise.all(
263+
pageResult.edges.map(async (edge) => {
264+
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
265+
edge.node,
266+
);
267+
return {
268+
...edge,
269+
node: entityResult.enforceValue(),
270+
};
271+
}),
272+
);
273+
274+
return {
275+
edges,
276+
pageInfo: pageResult.pageInfo,
277+
...(pageResult.totalCount !== undefined && { totalCount: pageResult.totalCount }),
278+
};
279+
}
244280
}
245281

246282
/**

packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,21 @@ export class PostgresEntityDatabaseAdapter<
110110
query: Knex.QueryBuilder,
111111
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
112112
): Knex.QueryBuilder {
113-
let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
114-
115113
const { orderByRaw } = querySelectionModifiers;
114+
115+
// orderByRaw takes precedence over orderBy - they are mutually exclusive
116116
if (orderByRaw !== undefined) {
117+
// Apply only orderByRaw (offset/limit still applied, but not orderBy)
118+
let ret = this.applyQueryModifiersToQuery(query, {
119+
...querySelectionModifiers,
120+
orderBy: undefined, // Explicitly exclude orderBy when orderByRaw is present
121+
});
117122
ret = ret.orderByRaw(orderByRaw);
123+
return ret;
124+
} else {
125+
// Apply regular orderBy (and offset/limit)
126+
return this.applyQueryModifiersToQuery(query, querySelectionModifiers);
118127
}
119-
120-
return ret;
121128
}
122129

123130
private applyQueryModifiersToQuery(
@@ -288,10 +295,18 @@ export class PostgresEntityDatabaseAdapter<
288295
query = query.from(tableName).whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings());
289296

290297
// Apply order by modifiers
291-
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
298+
// orderByFragment takes precedence over orderBy - they are mutually exclusive
292299
const { orderByFragment } = querySelectionModifiers;
293300
if (orderByFragment !== undefined) {
301+
// Apply only orderByFragment (offset/limit still applied, but not orderBy)
302+
query = this.applyQueryModifiersToQuery(query, {
303+
...querySelectionModifiers,
304+
orderBy: undefined, // Explicitly exclude orderBy when orderByFragment is present
305+
});
294306
query = query.orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings());
307+
} else {
308+
// Apply regular orderBy (and offset/limit)
309+
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
295310
}
296311

297312
const rows = await wrapNativePostgresCallAsync(() => query);

0 commit comments

Comments
 (0)