Skip to content
Merged
3 changes: 3 additions & 0 deletions packages/orm/src/client/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export const FILTER_PROPERTY_TO_KIND = {
array_starts_with: 'Json',
array_ends_with: 'Json',

// Fuzzy search operators
fuzzy: 'Fuzzy',

// List operators
has: 'List',
hasEvery: 'List',
Expand Down
87 changes: 86 additions & 1 deletion packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,52 @@ export type StringFilter<
mode?: 'default' | 'insensitive';
}
: {}) &
('Fuzzy' extends AllowedKinds
Comment thread
ymc9 marked this conversation as resolved.
Outdated
? {
/**
* Performs a fuzzy search on the string field using PostgreSQL `pg_trgm`.
* Not supported on MySQL or SQLite (throws `NotSupported` at runtime).
*
* Modes:
* - `'simple'` (default): trigram similarity on the whole value (operator `%`,
* function `similarity()`).
* - `'word'`: word similarity — checks if the search term is approximately
* contained as a word inside the value (operator `<%`,
* function `word_similarity()`).
* - `'strictWord'`: stricter variant of `'word'` (operator `<<%`,
* function `strict_word_similarity()`).
*
* When `threshold` is provided the function form is used
* (`similarity() > threshold`) instead of the operator form, so the
* `pg_trgm.*_threshold` session settings are bypassed.
*
* `unaccent` is opt-in (defaults to `false`) — set it to `true` to make the
* comparison accent-insensitive. Enabling it requires the `unaccent` extension
* to be installed on the database.
*/
fuzzy?: {
/**
* Search term to match against (must be a non-empty string).
*/
search: string;
/**
* Matching mode. Defaults to `'simple'`.
*/
mode?: 'simple' | 'word' | 'strictWord';
/**
* Optional similarity threshold in `[0, 1]`. When provided, the function
* form is used and matches require `similarity > threshold`.
*/
threshold?: number;
/**
* Whether to apply `unaccent()` to both sides. Defaults to `false`.
* Set to `true` to enable accent-insensitive matching (requires the
* `unaccent` extension on PostgreSQL).
*/
unaccent?: boolean;
};
}
: {}) &
(WithAggregations extends true
? {
/**
Expand Down Expand Up @@ -887,6 +933,45 @@ type TypedJsonFieldsFilter<
export type SortOrder = 'asc' | 'desc';
export type NullsOrder = 'first' | 'last';

type StringFields<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
[Key in NonRelationFields<Schema, Model>]: MapModelFieldType<Schema, Model, Key> extends string | null
? Key
: never;
}[NonRelationFields<Schema, Model>];

export type FuzzyRelevanceOrderBy<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
/**
* Sorts by fuzzy search relevance using PostgreSQL `pg_trgm` similarity functions.
* Not supported on MySQL or SQLite (throws `NotSupported` at runtime).
* Cannot be combined with cursor-based pagination.
*
* The `_fuzzyRelevance` name is intentionally distinct from `_searchRelevance`
* (reserved for future full-text-search relevance) so the two can coexist.
*/
_fuzzyRelevance?: {
/**
Comment thread
ymc9 marked this conversation as resolved.
* String fields to compute relevance against (must be non-empty).
*/
fields: [StringFields<Schema, Model>, ...StringFields<Schema, Model>[]];
Comment thread
ymc9 marked this conversation as resolved.
/**
* The search term to compute relevance for.
*/
search: string;
/**
* Fuzzy matching mode used to compute relevance.
*/
mode?: 'simple' | 'word' | 'strictWord';
/**
* Whether to remove accents before computing relevance.
*/
unaccent?: boolean;
/**
* Sort direction.
*/
sort: SortOrder;
};
};

export type OrderBy<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Expand Down Expand Up @@ -1237,7 +1322,7 @@ type SortAndTakeArgs<
/**
* Order by clauses
*/
orderBy?: OrArray<OrderBy<Schema, Model, true, false>>;
orderBy?: OrArray<OrderBy<Schema, Model, true, false> & FuzzyRelevanceOrderBy<Schema, Model>>;

/**
* Cursor for pagination
Expand Down
116 changes: 115 additions & 1 deletion packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
result = this.buildOrderBy(result, model, modelAlias, effectiveOrderBy, negateOrderBy, take);

if (args.cursor) {
if (
Comment thread
ymc9 marked this conversation as resolved.
effectiveOrderBy &&
enumerate(effectiveOrderBy).some((ob: any) => typeof ob === 'object' && '_fuzzyRelevance' in ob)
) {
throw createNotSupportedError(
'cursor pagination cannot be combined with "_fuzzyRelevance" ordering',
);
}
result = this.buildCursorFilter(
model,
result,
Expand Down Expand Up @@ -924,14 +932,18 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
if (payload && typeof payload === 'object') {
for (const [key, value] of Object.entries(payload)) {
if (key === 'mode' || consumedKeys.includes(key)) {
// already consumed
continue;
}

if (value === undefined) {
continue;
}

if (key === 'fuzzy') {
conditions.push(this.buildFuzzyFilter(fieldRef, this.normalizeFuzzyOptions(value)));
continue;
}

invariant(typeof value === 'string', `${key} value must be a string`);

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

// _fuzzyRelevance ordering
if (field === '_fuzzyRelevance') {
invariant(
typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value,
'invalid orderBy value for "_fuzzyRelevance"',
);
invariant(
Array.isArray(value.fields) && value.fields.length > 0,
'_fuzzyRelevance.fields must be a non-empty array',
);
invariant(
value.sort === 'asc' || value.sort === 'desc',
'invalid sort value for "_fuzzyRelevance"',
);
invariant(
typeof value.search === 'string' && value.search.length > 0,
'_fuzzyRelevance.search must be a non-empty string',
);
const mode = value.mode ?? 'simple';
invariant(
mode === 'simple' || mode === 'word' || mode === 'strictWord',
'_fuzzyRelevance.mode must be "simple", "word" or "strictWord"',
);
const unaccent = value.unaccent ?? false;
invariant(typeof unaccent === 'boolean', '_fuzzyRelevance.unaccent must be a boolean');
const fieldRefs = value.fields.map((f: string) => buildFieldRef(model, f, modelAlias));
result = this.buildFuzzyRelevanceOrderBy(
result,
fieldRefs,
value.search,
this.negateSort(value.sort, negated),
mode,
unaccent,
);
continue;
}

// aggregations
if (['_count', '_avg', '_sum', '_min', '_max'].includes(field)) {
invariant(typeof value === 'object', `invalid orderBy value for field "${field}"`);
Expand Down Expand Up @@ -1592,5 +1641,70 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
nulls: 'first' | 'last',
): SelectQueryBuilder<any, any, any>;

/**
* Builds a fuzzy search filter for a string field using PostgreSQL `pg_trgm`.
* The selected SQL form (operator vs. function, with/without `unaccent`) depends
* on the resolved options.
*/
abstract buildFuzzyFilter(fieldRef: Expression<any>, options: FuzzyFilterOptions): Expression<SqlBool>;

/**
* Builds an ORDER BY clause that sorts by fuzzy relevance to a search term.
*/
abstract buildFuzzyRelevanceOrderBy(
query: SelectQueryBuilder<any, any, any>,
fieldRefs: Expression<any>[],
search: string,
sort: SortOrder,
mode: FuzzyFilterOptions['mode'],
unaccent: boolean,
): SelectQueryBuilder<any, any, any>;

/**
* Validate the user-provided fuzzy filter payload and apply defaults so dialects
* always receive a fully-resolved {@link FuzzyFilterOptions} value.
*/
protected normalizeFuzzyOptions(value: unknown): FuzzyFilterOptions {
invariant(
value !== null && typeof value === 'object' && !Array.isArray(value),
'fuzzy filter must be an object with at least a "search" field',
);
const raw = value as Record<string, unknown>;
invariant(typeof raw['search'] === 'string' && raw['search'].length > 0, 'fuzzy.search must be a non-empty string');
const mode = raw['mode'] ?? 'simple';
invariant(
mode === 'simple' || mode === 'word' || mode === 'strictWord',
'fuzzy.mode must be "simple", "word" or "strictWord"',
);
const threshold = raw['threshold'];
if (threshold !== undefined) {
invariant(
typeof threshold === 'number' && threshold >= 0 && threshold <= 1,
'fuzzy.threshold must be a number between 0 and 1',
);
}
const unaccent = raw['unaccent'] ?? false;
invariant(typeof unaccent === 'boolean', 'fuzzy.unaccent must be a boolean');
return {
search: raw['search'],
mode: mode as FuzzyFilterOptions['mode'],
threshold: threshold as number | undefined,
unaccent,
};
}

// #endregion
}

/**
* Resolved options for a fuzzy filter passed to a dialect. `mode` and `unaccent`
* are always populated (defaults: `mode='simple'`, `unaccent=false`, applied by
* `normalizeFuzzyOptions`); `threshold` is optional and switches the SQL from
* operator form (`%`, `<%`, `<<%`) to function form (`similarity() > threshold`).
*/
export type FuzzyFilterOptions = {
search: string;
mode: 'simple' | 'word' | 'strictWord';
threshold?: number;
unaccent: boolean;
};
20 changes: 20 additions & 0 deletions packages/orm/src/client/crud/dialects/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { NullsOrder, SortOrder } from '../../crud-types';
import { createInvalidInputError, createNotSupportedError } from '../../errors';
import type { ClientOptions } from '../../options';
import { isTypeDef } from '../../query-utils';
import type { FuzzyFilterOptions } from './base-dialect';
import { LateralJoinDialectBase } from './lateral-join-dialect-base';

export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDialectBase<Schema> {
Expand Down Expand Up @@ -396,4 +397,23 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale
}

// #endregion

// #region fuzzy search

override buildFuzzyFilter(_fieldRef: Expression<any>, _options: FuzzyFilterOptions): Expression<SqlBool> {
throw createNotSupportedError('"fuzzy" filter is not supported by the "mysql" provider');
}

override buildFuzzyRelevanceOrderBy(
_query: SelectQueryBuilder<any, any, any>,
_fieldRefs: Expression<any>[],
_search: string,
_sort: SortOrder,
_mode: FuzzyFilterOptions['mode'],
_unaccent: boolean,
): SelectQueryBuilder<any, any, any> {
throw createNotSupportedError('"_fuzzyRelevance" ordering is not supported by the "mysql" provider');
}

// #endregion
}
74 changes: 74 additions & 0 deletions packages/orm/src/client/crud/dialects/postgresql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { NullsOrder, SortOrder } from '../../crud-types';
import { createInvalidInputError } from '../../errors';
import type { ClientOptions } from '../../options';
import { isEnum, isTypeDef } from '../../query-utils';
import type { FuzzyFilterOptions } from './base-dialect';
import { LateralJoinDialectBase } from './lateral-join-dialect-base';

export class PostgresCrudDialect<Schema extends SchemaDef> extends LateralJoinDialectBase<Schema> {
Expand Down Expand Up @@ -582,4 +583,77 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends LateralJoinDi
}

// #endregion

// #region search

/**
* Wraps an expression with `unaccent(lower(...))` or just `lower(...)` depending on
* whether the user opted into accent-insensitive matching. The lowering is always
* applied so trigram comparisons are case-insensitive on both sides.
*/
private normalizeForTrigram(expr: Expression<any>, applyUnaccent: boolean): Expression<any> {
return applyUnaccent ? sql`unaccent(lower(${expr}))` : sql`lower(${expr})`;
}

override buildFuzzyFilter(fieldRef: Expression<any>, options: FuzzyFilterOptions): Expression<SqlBool> {
const fieldExpr = this.normalizeForTrigram(fieldRef, options.unaccent);
const valueExpr = this.normalizeForTrigram(sql.val(options.search), options.unaccent);

if (options.threshold === undefined) {
// Operator form: relies on the session-level pg_trgm.*_threshold settings.
// 'simple' -> `%` (similarity()), symmetric.
// 'word' -> `<%` (word_similarity()): search-term <% document.
// 'strictWord' -> `<<%` (strict_word_similarity()): search-term <<% document.
switch (options.mode) {
case 'simple':
return sql<SqlBool>`${fieldExpr} % ${valueExpr}`;
case 'word':
return sql<SqlBool>`${valueExpr} <% ${fieldExpr}`;
case 'strictWord':
return sql<SqlBool>`${valueExpr} <<% ${fieldExpr}`;
}
}

// Function form: explicit `similarity(...) > threshold`. Bypasses session settings,
// letting the user pick a per-query threshold.
const threshold = sql.val(options.threshold);
switch (options.mode) {
case 'simple':
return sql<SqlBool>`similarity(${fieldExpr}, ${valueExpr}) > ${threshold}`;
case 'word':
return sql<SqlBool>`word_similarity(${valueExpr}, ${fieldExpr}) > ${threshold}`;
case 'strictWord':
return sql<SqlBool>`strict_word_similarity(${valueExpr}, ${fieldExpr}) > ${threshold}`;
}
}

override buildFuzzyRelevanceOrderBy(
query: SelectQueryBuilder<any, any, any>,
fieldRefs: Expression<any>[],
search: string,
sort: SortOrder,
mode: FuzzyFilterOptions['mode'],
unaccent: boolean,
): SelectQueryBuilder<any, any, any> {
const valueExpr = this.normalizeForTrigram(sql.val(search), unaccent);
const buildSimilarity = (fieldRef: Expression<any>) => {
const fieldExpr = this.normalizeForTrigram(fieldRef, unaccent);
switch (mode) {
case 'simple':
return sql`similarity(${fieldExpr}, ${valueExpr})`;
case 'word':
return sql`word_similarity(${valueExpr}, ${fieldExpr})`;
case 'strictWord':
return sql`strict_word_similarity(${valueExpr}, ${fieldExpr})`;
}
};

if (fieldRefs.length === 1) {
return query.orderBy(buildSimilarity(fieldRefs[0]!), sort);
}
const similarities = fieldRefs.map((ref) => buildSimilarity(ref));
return query.orderBy(sql`GREATEST(${sql.join(similarities)})`, sort);
}

// #endregion
}
Loading