Skip to content

Commit ec9eae3

Browse files
committed
feat: Add ilike and trigram similarity search to pagination
1 parent 2b437e1 commit ec9eae3

3 files changed

Lines changed: 308 additions & 4 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,39 @@ export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
7676
orderByFragment?: SQLFragment;
7777
}
7878

79+
/**
80+
* Search strategy for SQL-based pagination.
81+
*/
82+
export enum SearchStrategy {
83+
ILIKE = 'ilike',
84+
TRIGRAM = 'trigram',
85+
}
86+
87+
interface SearchSpecificationBase<TFields extends Record<string, any>> {
88+
term: string;
89+
fields: (keyof TFields)[];
90+
}
91+
92+
interface ILikeSearchSpecification<
93+
TFields extends Record<string, any>,
94+
> extends SearchSpecificationBase<TFields> {
95+
strategy: SearchStrategy.ILIKE;
96+
}
97+
98+
interface TrigramSearchSpecification<
99+
TFields extends Record<string, any>,
100+
> extends SearchSpecificationBase<TFields> {
101+
strategy: SearchStrategy.TRIGRAM;
102+
threshold: number;
103+
}
104+
105+
/**
106+
* Search specification for SQL-based pagination.
107+
*/
108+
export type SearchSpecification<TFields extends Record<string, any>> =
109+
| ILikeSearchSpecification<TFields>
110+
| TrigramSearchSpecification<TFields>;
111+
79112
/**
80113
* Base pagination arguments
81114
*/
@@ -104,6 +137,12 @@ interface EntityLoaderBasePaginationArgs<
104137
* The total count is available in the connection's totalCount field.
105138
*/
106139
includeTotal?: boolean;
140+
141+
/**
142+
* Search specification for filtering entities by text search.
143+
* Supports ILIKE pattern matching or PostgreSQL trigram similarity search.
144+
*/
145+
search?: SearchSpecification<TFields>;
107146
}
108147

109148
/**

packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { knex, Knex } from 'knex';
1010
import nullthrows from 'nullthrows';
1111
import { setTimeout } from 'timers/promises';
1212

13+
import { SearchStrategy } from '../AuthorizationResultBasedKnexEntityLoader';
1314
import { OrderByOrdering } from '../BasePostgresEntityDatabaseAdapter';
1415
import { raw, sql, SQLFragment, SQLFragmentHelpers } from '../SQLOperator';
1516
import { PostgresTestEntity } from '../__testfixtures__/PostgresTestEntity';
@@ -1913,5 +1914,182 @@ describe('postgres entity integration', () => {
19131914
expect(emptyBackwardPageWithTotal.pageInfo.startCursor).toBeNull();
19141915
expect(emptyBackwardPageWithTotal.pageInfo.endCursor).toBeNull();
19151916
});
1917+
1918+
it('supports search with ILIKE strategy', async () => {
1919+
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
1920+
1921+
await PostgresTestEntity.dropPostgresTableAsync(knexInstance);
1922+
await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance);
1923+
1924+
// Create test data with searchable names
1925+
const names = [
1926+
'Alice Johnson',
1927+
'Bob Smith',
1928+
'Charlie Brown',
1929+
'David Smith',
1930+
'Eve Johnson',
1931+
'Frank Miller',
1932+
];
1933+
for (let i = 0; i < names.length; i++) {
1934+
await PostgresTestEntity.creator(vc)
1935+
.setField('name', names[i]!)
1936+
.setField('hasACat', i % 2 === 0)
1937+
.createAsync();
1938+
}
1939+
1940+
// Search for names containing "Johnson"
1941+
const searchResults = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1942+
first: 10,
1943+
search: {
1944+
strategy: SearchStrategy.ILIKE,
1945+
term: 'Johnson',
1946+
fields: ['name'],
1947+
},
1948+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1949+
});
1950+
1951+
expect(searchResults.edges).toHaveLength(2);
1952+
expect(searchResults.edges[0]?.node.getField('name')).toBe('Alice Johnson');
1953+
expect(searchResults.edges[1]?.node.getField('name')).toBe('Eve Johnson');
1954+
1955+
// Search for names containing "Smith" with pagination
1956+
const smithPage1 = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1957+
first: 1,
1958+
search: {
1959+
strategy: SearchStrategy.ILIKE,
1960+
term: 'Smith',
1961+
fields: ['name'],
1962+
},
1963+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1964+
});
1965+
1966+
expect(smithPage1.edges).toHaveLength(1);
1967+
expect(smithPage1.edges[0]?.node.getField('name')).toBe('Bob Smith');
1968+
expect(smithPage1.pageInfo.hasNextPage).toBe(true);
1969+
1970+
// Get next page
1971+
const smithPage2 = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1972+
first: 1,
1973+
after: smithPage1.pageInfo.endCursor!,
1974+
search: {
1975+
strategy: SearchStrategy.ILIKE,
1976+
term: 'Smith',
1977+
fields: ['name'],
1978+
},
1979+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1980+
});
1981+
1982+
expect(smithPage2.edges).toHaveLength(1);
1983+
expect(smithPage2.edges[0]?.node.getField('name')).toBe('David Smith');
1984+
expect(smithPage2.pageInfo.hasNextPage).toBe(false);
1985+
1986+
// Test partial match (case insensitive)
1987+
const partialMatch = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1988+
first: 10,
1989+
search: {
1990+
strategy: SearchStrategy.ILIKE,
1991+
term: 'john',
1992+
fields: ['name'],
1993+
},
1994+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1995+
});
1996+
1997+
expect(partialMatch.edges).toHaveLength(2);
1998+
expect(partialMatch.edges[0]?.node.getField('name')).toBe('Alice Johnson');
1999+
expect(partialMatch.edges[1]?.node.getField('name')).toBe('Eve Johnson');
2000+
2001+
// Test search with WHERE clause
2002+
const combinedFilter = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
2003+
first: 10,
2004+
where: sql`has_a_cat = ${true}`,
2005+
search: {
2006+
strategy: SearchStrategy.ILIKE,
2007+
term: 'Johnson',
2008+
fields: ['name'],
2009+
},
2010+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
2011+
});
2012+
2013+
// Both Alice Johnson (index 0) and Eve Johnson (index 4) have cats
2014+
expect(combinedFilter.edges).toHaveLength(2);
2015+
expect(combinedFilter.edges[0]?.node.getField('name')).toBe('Alice Johnson');
2016+
expect(combinedFilter.edges[0]?.node.getField('hasACat')).toBe(true);
2017+
expect(combinedFilter.edges[1]?.node.getField('name')).toBe('Eve Johnson');
2018+
expect(combinedFilter.edges[1]?.node.getField('hasACat')).toBe(true);
2019+
2020+
// Test search with includeTotal
2021+
const withTotal = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
2022+
first: 1,
2023+
search: {
2024+
strategy: SearchStrategy.ILIKE,
2025+
term: 'Smith',
2026+
fields: ['name'],
2027+
},
2028+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
2029+
includeTotal: true,
2030+
});
2031+
2032+
expect(withTotal.edges).toHaveLength(1);
2033+
expect(withTotal.totalCount).toBe(2); // Bob Smith and David Smith
2034+
});
2035+
2036+
it('supports trigram similarity search', async () => {
2037+
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
2038+
2039+
await PostgresTestEntity.dropPostgresTableAsync(knexInstance);
2040+
await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance);
2041+
2042+
// Enable pg_trgm extension for trigram similarity
2043+
await knexInstance.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm');
2044+
2045+
// Create test data with similar names
2046+
const names = ['Johnson', 'Jonson', 'Johnsen', 'Smith', 'Smyth', 'Schmidt'];
2047+
for (let i = 0; i < names.length; i++) {
2048+
await PostgresTestEntity.creator(vc)
2049+
.setField('name', names[i]!)
2050+
.setField('hasACat', i < 3) // First 3 have cats
2051+
.createAsync();
2052+
}
2053+
2054+
// Search for similar names to "Johnson" using trigram
2055+
const trigramSearch = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
2056+
first: 10,
2057+
search: {
2058+
strategy: SearchStrategy.TRIGRAM,
2059+
term: 'Johnson',
2060+
fields: ['name'],
2061+
threshold: 0.3, // Similarity threshold
2062+
},
2063+
});
2064+
2065+
// Should find exact match and similar names, ordered by relevance
2066+
expect(trigramSearch.edges.length).toBeGreaterThan(0);
2067+
// Exact match should come first due to ILIKE matching
2068+
expect(trigramSearch.edges[0]?.node.getField('name')).toBe('Johnson');
2069+
2070+
// The similar names (Jonson, Johnsen) should also be included
2071+
const foundNames = trigramSearch.edges.map((e) => e.node.getField('name'));
2072+
expect(foundNames).toContain('Jonson');
2073+
expect(foundNames).toContain('Johnsen');
2074+
2075+
// Test combining with WHERE clause
2076+
const filteredTrigram = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
2077+
first: 10,
2078+
where: sql`has_a_cat = ${true}`,
2079+
search: {
2080+
strategy: SearchStrategy.TRIGRAM,
2081+
term: 'Johnson',
2082+
fields: ['name'],
2083+
threshold: 0.3,
2084+
},
2085+
});
2086+
2087+
// Only the Johnson-like names with cats
2088+
expect(filteredTrigram.edges.length).toBeGreaterThan(0);
2089+
expect(filteredTrigram.edges.length).toBeLessThanOrEqual(3);
2090+
filteredTrigram.edges.forEach((edge) => {
2091+
expect(edge.node.getField('hasACat')).toBe(true);
2092+
});
2093+
});
19162094
});
19172095
});

packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
} from '@expo/entity';
1111
import assert from 'assert';
1212

13+
import {
14+
SearchStrategy,
15+
type SearchSpecification,
16+
} from '../AuthorizationResultBasedKnexEntityLoader';
1317
import {
1418
BasePostgresEntityDatabaseAdapter,
1519
FieldEqualityCondition,
@@ -29,6 +33,7 @@ interface BasePaginationArgs<TFields extends Record<string, any>> {
2933
orderBy?: PostgresOrderByClause<TFields>[];
3034
cursorFields?: readonly (keyof TFields)[];
3135
includeTotal?: boolean;
36+
search?: SearchSpecification<TFields>;
3237
}
3338

3439
/**
@@ -211,7 +216,7 @@ export class EntityKnexDataManager<
211216
}
212217

213218
const isForward = 'first' in args;
214-
const { where, orderBy, cursorFields, includeTotal } = args;
219+
const { where, orderBy, cursorFields, includeTotal, search } = args;
215220

216221
let limit: number;
217222
let cursor: string | undefined;
@@ -235,9 +240,26 @@ export class EntityKnexDataManager<
235240
// Decode cursor
236241
const decodedCursor = cursor ? this.decodeCursor(cursor, finalCursorFields) : null;
237242

243+
// Build search conditions if search is provided
244+
let searchWhere: SQLFragment | undefined;
245+
let searchOrderByFragment: SQLFragment | undefined;
246+
if (search) {
247+
const searchConditions = this.buildSearchConditionAndOrderBy(search);
248+
searchWhere = searchConditions.searchWhere;
249+
searchOrderByFragment = searchConditions.searchOrderByFragment;
250+
}
251+
252+
// Combine WHERE conditions: base where + search where + cursor condition
253+
const combinedWhere = [where, searchWhere]
254+
.filter(Boolean)
255+
.reduce<SQLFragment | undefined>((acc, condition) => {
256+
if (!acc) return condition;
257+
return sql`(${acc}) AND (${condition})`;
258+
}, undefined);
259+
238260
// Build WHERE clause with cursor condition for keyset pagination
239261
const whereClause = this.buildWhereClause({
240-
...(where && { where }),
262+
...(combinedWhere && { where: combinedWhere }),
241263
cursorFields: finalCursorFields,
242264
decodedCursor,
243265
direction: isForward ? 'forward' : 'backward',
@@ -288,7 +310,10 @@ export class EntityKnexDataManager<
288310
queryContext,
289311
)(
290312
this.databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, whereClause, {
291-
orderBy: finalOrderByClauses,
313+
// Use orderByFragment if search provides one, otherwise use regular orderBy
314+
...(searchOrderByFragment
315+
? { orderByFragment: searchOrderByFragment }
316+
: { orderBy: finalOrderByClauses }),
292317
limit: limit + 1,
293318
}),
294319
),
@@ -298,7 +323,12 @@ export class EntityKnexDataManager<
298323
this.entityClassName,
299324
queryContext,
300325
() => 1,
301-
)(this.databaseAdapter.fetchCountBySQLFragmentAsync(queryContext, where ?? sql`1 = 1`)),
326+
)(
327+
this.databaseAdapter.fetchCountBySQLFragmentAsync(
328+
queryContext,
329+
combinedWhere ?? sql`1 = 1`,
330+
),
331+
),
302332
]);
303333
fieldObjects = fieldObjectsResult;
304334
totalCount = totalCountResult;
@@ -443,4 +473,61 @@ export class EntityKnexDataManager<
443473
)})
444474
`;
445475
}
476+
477+
private buildSearchConditionAndOrderBy(search: SearchSpecification<TFields>): {
478+
searchWhere: SQLFragment;
479+
searchOrderByFragment: SQLFragment | undefined;
480+
} {
481+
switch (search.strategy) {
482+
case SearchStrategy.ILIKE: {
483+
// Simple ILIKE search
484+
const conditions = search.fields.map((field) => {
485+
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
486+
return sql`${identifier(dbField)} ILIKE ${'%' + search.term + '%'}`;
487+
});
488+
return {
489+
searchWhere: conditions.length > 0 ? SQLFragment.join(conditions, ' OR ') : sql`1 = 0`,
490+
searchOrderByFragment: undefined,
491+
};
492+
}
493+
494+
case SearchStrategy.TRIGRAM: {
495+
// PostgreSQL trigram similarity
496+
const conditions = search.fields.map((field) => {
497+
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
498+
return sql`similarity(${identifier(dbField)}, ${search.term}) > ${search.threshold}`;
499+
});
500+
const ilikeConditions = search.fields.map((field) => {
501+
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
502+
return sql`${identifier(dbField)} ILIKE ${'%' + search.term + '%'}`;
503+
});
504+
// Combine exact matches (ILIKE) with similarity
505+
const allConditions = [...ilikeConditions, ...conditions];
506+
const idField = getDatabaseFieldForEntityField(
507+
this.entityConfiguration,
508+
this.entityConfiguration.idField,
509+
);
510+
return {
511+
searchWhere: SQLFragment.join(allConditions, ' OR '),
512+
// For trigram search, order by relevance
513+
// 1. Exact matches first (ILIKE)
514+
// 2. Then by similarity score
515+
// 3. Then by ID field
516+
searchOrderByFragment: sql`CASE WHEN ${SQLFragment.join(
517+
search.fields.map((field) => {
518+
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
519+
return sql`${identifier(dbField)} ILIKE ${'%' + search.term + '%'}`;
520+
}),
521+
' OR ',
522+
)} THEN 0 ELSE 1 END, GREATEST(${SQLFragment.join(
523+
search.fields.map((field) => {
524+
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
525+
return sql`similarity(${identifier(dbField)}, ${search.term})`;
526+
}),
527+
', ',
528+
)}) DESC, ${identifier(idField)} DESC`,
529+
};
530+
}
531+
}
532+
}
446533
}

0 commit comments

Comments
 (0)