Skip to content

Commit 679f91f

Browse files
docloulouymc9
andauthored
feat(orm): add fuzzy search and relevance ordering (PostgreSQL) (#2573)
Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com>
1 parent 22e0fd4 commit 679f91f

13 files changed

Lines changed: 1837 additions & 7 deletions

File tree

packages/orm/src/client/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export const FILTER_PROPERTY_TO_KIND = {
6868
array_starts_with: 'Json',
6969
array_ends_with: 'Json',
7070

71+
// Fuzzy search operators
72+
fuzzy: 'Fuzzy',
73+
7174
// List operators
7275
has: 'List',
7376
hasEvery: 'List',

packages/orm/src/client/crud-types.ts

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -376,13 +376,47 @@ type FieldFilter<
376376
AllowedKinds
377377
>
378378
: // primitive
379-
PrimitiveFilter<
379+
AddFuzzyFilterIfSupported<
380+
Schema,
380381
GetModelFieldType<Schema, Model, Field>,
381-
ModelFieldIsOptional<Schema, Model, Field>,
382-
WithAggregations,
383-
AllowedKinds
382+
AllowedKinds,
383+
PrimitiveFilter<
384+
GetModelFieldType<Schema, Model, Field>,
385+
ModelFieldIsOptional<Schema, Model, Field>,
386+
WithAggregations,
387+
AllowedKinds
388+
>
384389
>;
385390

391+
/**
392+
* Conditionally augments a primitive filter with the `fuzzy` operator when:
393+
* 1. The field's type is `String`, AND
394+
* 2. The `Fuzzy` filter kind is allowed for this field, AND
395+
* 3. The schema's provider supports fuzzy search (postgres only).
396+
*
397+
* Returns `Base` unchanged when any condition fails — never `Base & {}`,
398+
* since intersecting with `{}` would strip `null`/`undefined` from `Base`.
399+
*/
400+
type AddFuzzyFilterIfSupported<
401+
Schema extends SchemaDef,
402+
FieldType extends string,
403+
AllowedKinds extends FilterKind,
404+
Base,
405+
> = FieldType extends 'String'
406+
? 'Fuzzy' extends AllowedKinds
407+
? ProviderSupportsFuzzy<Schema> extends true
408+
? Base & {
409+
/**
410+
* Performs a fuzzy search on the string field. Only available when
411+
* the schema's provider is `postgresql` (uses `pg_trgm`).
412+
* See {@link FuzzyFilterPayload} for the full options reference.
413+
*/
414+
fuzzy?: FuzzyFilterPayload;
415+
}
416+
: Base
417+
: Base
418+
: Base;
419+
386420
type EnumFilter<
387421
Schema extends SchemaDef,
388422
T extends GetEnums<Schema>,
@@ -889,6 +923,92 @@ type TypedJsonFieldsFilter<
889923
export type SortOrder = 'asc' | 'desc';
890924
export type NullsOrder = 'first' | 'last';
891925

926+
type StringFields<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
927+
[Key in NonRelationFields<Schema, Model>]: MapModelFieldType<Schema, Model, Key> extends string | null
928+
? Key
929+
: never;
930+
}[NonRelationFields<Schema, Model>];
931+
932+
/**
933+
* Payload for the `fuzzy` string filter operator. Performs a fuzzy search using
934+
* PostgreSQL `pg_trgm` (only available when the schema's provider is `postgresql`).
935+
* Not supported on MySQL or SQLite (throws `NotSupported` at runtime).
936+
*
937+
* Modes:
938+
* - `'simple'` (default): trigram similarity on the whole value (operator `%`,
939+
* function `similarity()`).
940+
* - `'word'`: word similarity — checks if the search term is approximately
941+
* contained as a word inside the value (operator `<%`,
942+
* function `word_similarity()`).
943+
* - `'strictWord'`: stricter variant of `'word'` (operator `<<%`,
944+
* function `strict_word_similarity()`).
945+
*
946+
* When `threshold` is provided the function form is used
947+
* (`similarity() > threshold`) instead of the operator form, so the
948+
* `pg_trgm.*_threshold` session settings are bypassed.
949+
*
950+
* `unaccent` is opt-in (defaults to `false`) — set it to `true` to make the
951+
* comparison accent-insensitive. Enabling it requires the `unaccent` extension
952+
* to be installed on the database.
953+
*/
954+
export type FuzzyFilterPayload = {
955+
/**
956+
* Search term to match against (must be a non-empty string).
957+
*/
958+
search: string;
959+
/**
960+
* Matching mode. Defaults to `'simple'`.
961+
*/
962+
mode?: 'simple' | 'word' | 'strictWord';
963+
/**
964+
* Optional similarity threshold in `[0, 1]`. When provided, the function
965+
* form is used and matches require `similarity > threshold`.
966+
*/
967+
threshold?: number;
968+
/**
969+
* Whether to apply `unaccent()` to both sides. Defaults to `false`.
970+
* Set to `true` to enable accent-insensitive matching (requires the
971+
* `unaccent` extension on PostgreSQL).
972+
*/
973+
unaccent?: boolean;
974+
};
975+
976+
export type FuzzyRelevanceOrderBy<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
977+
/**
978+
* Sorts by fuzzy search relevance using PostgreSQL `pg_trgm` similarity functions.
979+
* Not supported on MySQL or SQLite (throws `NotSupported` at runtime).
980+
* Cannot be combined with cursor-based pagination.
981+
*
982+
* The `_fuzzyRelevance` name is intentionally distinct from `_searchRelevance`
983+
* (reserved for future full-text-search relevance) so the two can coexist.
984+
*/
985+
_fuzzyRelevance?: {
986+
/**
987+
* String fields to compute relevance against (must be non-empty).
988+
*
989+
* When multiple fields are provided, the row's relevance score is the
990+
* greatest per-field similarity, i.e. `GREATEST(similarity(field1, search), similarity(field2, search), ...)`.
991+
*/
992+
fields: [StringFields<Schema, Model>, ...StringFields<Schema, Model>[]];
993+
/**
994+
* The search term to compute relevance for.
995+
*/
996+
search: string;
997+
/**
998+
* Fuzzy matching mode used to compute relevance.
999+
*/
1000+
mode?: 'simple' | 'word' | 'strictWord';
1001+
/**
1002+
* Whether to remove accents before computing relevance.
1003+
*/
1004+
unaccent?: boolean;
1005+
/**
1006+
* Sort direction.
1007+
*/
1008+
sort: SortOrder;
1009+
};
1010+
};
1011+
8921012
export type OrderBy<
8931013
Schema extends SchemaDef,
8941014
Model extends GetModels<Schema>,
@@ -1239,7 +1359,10 @@ type SortAndTakeArgs<
12391359
/**
12401360
* Order by clauses
12411361
*/
1242-
orderBy?: OrArray<OrderBy<Schema, Model, true, false>>;
1362+
orderBy?: OrArray<
1363+
OrderBy<Schema, Model, true, false> &
1364+
(ProviderSupportsFuzzy<Schema> extends true ? FuzzyRelevanceOrderBy<Schema, Model> : {})
1365+
>;
12431366

12441367
/**
12451368
* Cursor for pagination
@@ -2528,6 +2651,8 @@ type ProviderSupportsDistinct<Schema extends SchemaDef> = Schema['provider']['ty
25282651
? true
25292652
: false;
25302653

2654+
type ProviderSupportsFuzzy<Schema extends SchemaDef> = Schema['provider']['type'] extends 'postgresql' ? true : false;
2655+
25312656
/**
25322657
* Extracts extended query args for a specific operation.
25332658
*/

packages/orm/src/client/crud/dialects/base-dialect.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,14 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
166166
result = this.buildOrderBy(result, model, modelAlias, effectiveOrderBy, negateOrderBy, take);
167167

168168
if (args.cursor) {
169+
if (
170+
effectiveOrderBy &&
171+
enumerate(effectiveOrderBy).some((ob: any) => typeof ob === 'object' && '_fuzzyRelevance' in ob)
172+
) {
173+
throw createNotSupportedError(
174+
'cursor pagination cannot be combined with "_fuzzyRelevance" ordering',
175+
);
176+
}
169177
result = this.buildCursorFilter(
170178
model,
171179
result,
@@ -924,14 +932,18 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
924932
if (payload && typeof payload === 'object') {
925933
for (const [key, value] of Object.entries(payload)) {
926934
if (key === 'mode' || consumedKeys.includes(key)) {
927-
// already consumed
928935
continue;
929936
}
930937

931938
if (value === undefined) {
932939
continue;
933940
}
934941

942+
if (key === 'fuzzy') {
943+
conditions.push(this.buildFuzzyFilter(fieldRef, this.normalizeFuzzyOptions(value)));
944+
continue;
945+
}
946+
935947
invariant(typeof value === 'string', `${key} value must be a string`);
936948

937949
const escapedValue = this.escapeLikePattern(value);
@@ -1088,6 +1100,43 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
10881100
continue;
10891101
}
10901102

1103+
// _fuzzyRelevance ordering
1104+
if (field === '_fuzzyRelevance') {
1105+
invariant(
1106+
typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value,
1107+
'invalid orderBy value for "_fuzzyRelevance"',
1108+
);
1109+
invariant(
1110+
Array.isArray(value.fields) && value.fields.length > 0,
1111+
'_fuzzyRelevance.fields must be a non-empty array',
1112+
);
1113+
invariant(
1114+
value.sort === 'asc' || value.sort === 'desc',
1115+
'invalid sort value for "_fuzzyRelevance"',
1116+
);
1117+
invariant(
1118+
typeof value.search === 'string' && value.search.length > 0,
1119+
'_fuzzyRelevance.search must be a non-empty string',
1120+
);
1121+
const mode = value.mode ?? 'simple';
1122+
invariant(
1123+
mode === 'simple' || mode === 'word' || mode === 'strictWord',
1124+
'_fuzzyRelevance.mode must be "simple", "word" or "strictWord"',
1125+
);
1126+
const unaccent = value.unaccent ?? false;
1127+
invariant(typeof unaccent === 'boolean', '_fuzzyRelevance.unaccent must be a boolean');
1128+
const fieldRefs = value.fields.map((f: string) => buildFieldRef(model, f, modelAlias));
1129+
result = this.buildFuzzyRelevanceOrderBy(
1130+
result,
1131+
fieldRefs,
1132+
value.search,
1133+
this.negateSort(value.sort, negated),
1134+
mode,
1135+
unaccent,
1136+
);
1137+
continue;
1138+
}
1139+
10911140
// aggregations
10921141
if (['_count', '_avg', '_sum', '_min', '_max'].includes(field)) {
10931142
invariant(typeof value === 'object', `invalid orderBy value for field "${field}"`);
@@ -1592,5 +1641,70 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
15921641
nulls: 'first' | 'last',
15931642
): SelectQueryBuilder<any, any, any>;
15941643

1644+
/**
1645+
* Builds a fuzzy search filter for a string field using PostgreSQL `pg_trgm`.
1646+
* The selected SQL form (operator vs. function, with/without `unaccent`) depends
1647+
* on the resolved options.
1648+
*/
1649+
abstract buildFuzzyFilter(fieldRef: Expression<any>, options: FuzzyFilterOptions): Expression<SqlBool>;
1650+
1651+
/**
1652+
* Builds an ORDER BY clause that sorts by fuzzy relevance to a search term.
1653+
*/
1654+
abstract buildFuzzyRelevanceOrderBy(
1655+
query: SelectQueryBuilder<any, any, any>,
1656+
fieldRefs: Expression<any>[],
1657+
search: string,
1658+
sort: SortOrder,
1659+
mode: FuzzyFilterOptions['mode'],
1660+
unaccent: boolean,
1661+
): SelectQueryBuilder<any, any, any>;
1662+
1663+
/**
1664+
* Validate the user-provided fuzzy filter payload and apply defaults so dialects
1665+
* always receive a fully-resolved {@link FuzzyFilterOptions} value.
1666+
*/
1667+
protected normalizeFuzzyOptions(value: unknown): FuzzyFilterOptions {
1668+
invariant(
1669+
value !== null && typeof value === 'object' && !Array.isArray(value),
1670+
'fuzzy filter must be an object with at least a "search" field',
1671+
);
1672+
const raw = value as Record<string, unknown>;
1673+
invariant(typeof raw['search'] === 'string' && raw['search'].length > 0, 'fuzzy.search must be a non-empty string');
1674+
const mode = raw['mode'] ?? 'simple';
1675+
invariant(
1676+
mode === 'simple' || mode === 'word' || mode === 'strictWord',
1677+
'fuzzy.mode must be "simple", "word" or "strictWord"',
1678+
);
1679+
const threshold = raw['threshold'];
1680+
if (threshold !== undefined) {
1681+
invariant(
1682+
typeof threshold === 'number' && threshold >= 0 && threshold <= 1,
1683+
'fuzzy.threshold must be a number between 0 and 1',
1684+
);
1685+
}
1686+
const unaccent = raw['unaccent'] ?? false;
1687+
invariant(typeof unaccent === 'boolean', 'fuzzy.unaccent must be a boolean');
1688+
return {
1689+
search: raw['search'],
1690+
mode: mode as FuzzyFilterOptions['mode'],
1691+
threshold: threshold as number | undefined,
1692+
unaccent,
1693+
};
1694+
}
1695+
15951696
// #endregion
15961697
}
1698+
1699+
/**
1700+
* Resolved options for a fuzzy filter passed to a dialect. `mode` and `unaccent`
1701+
* are always populated (defaults: `mode='simple'`, `unaccent=false`, applied by
1702+
* `normalizeFuzzyOptions`); `threshold` is optional and switches the SQL from
1703+
* operator form (`%`, `<%`, `<<%`) to function form (`similarity() > threshold`).
1704+
*/
1705+
export type FuzzyFilterOptions = {
1706+
search: string;
1707+
mode: 'simple' | 'word' | 'strictWord';
1708+
threshold?: number;
1709+
unaccent: boolean;
1710+
};

packages/orm/src/client/crud/dialects/mysql.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { NullsOrder, SortOrder } from '../../crud-types';
1616
import { createInvalidInputError, createNotSupportedError } from '../../errors';
1717
import type { ClientOptions } from '../../options';
1818
import { isTypeDef } from '../../query-utils';
19+
import type { FuzzyFilterOptions } from './base-dialect';
1920
import { LateralJoinDialectBase } from './lateral-join-dialect-base';
2021

2122
export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDialectBase<Schema> {
@@ -396,4 +397,23 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale
396397
}
397398

398399
// #endregion
400+
401+
// #region fuzzy search
402+
403+
override buildFuzzyFilter(_fieldRef: Expression<any>, _options: FuzzyFilterOptions): Expression<SqlBool> {
404+
throw createNotSupportedError('"fuzzy" filter is not supported by the "mysql" provider');
405+
}
406+
407+
override buildFuzzyRelevanceOrderBy(
408+
_query: SelectQueryBuilder<any, any, any>,
409+
_fieldRefs: Expression<any>[],
410+
_search: string,
411+
_sort: SortOrder,
412+
_mode: FuzzyFilterOptions['mode'],
413+
_unaccent: boolean,
414+
): SelectQueryBuilder<any, any, any> {
415+
throw createNotSupportedError('"_fuzzyRelevance" ordering is not supported by the "mysql" provider');
416+
}
417+
418+
// #endregion
399419
}

0 commit comments

Comments
 (0)