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
135 changes: 130 additions & 5 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,13 +376,47 @@ type FieldFilter<
AllowedKinds
>
: // primitive
PrimitiveFilter<
AddFuzzyFilterIfSupported<
Schema,
GetModelFieldType<Schema, Model, Field>,
ModelFieldIsOptional<Schema, Model, Field>,
WithAggregations,
AllowedKinds
AllowedKinds,
PrimitiveFilter<
GetModelFieldType<Schema, Model, Field>,
ModelFieldIsOptional<Schema, Model, Field>,
WithAggregations,
AllowedKinds
>
>;

/**
* Conditionally augments a primitive filter with the `fuzzy` operator when:
* 1. The field's type is `String`, AND
* 2. The `Fuzzy` filter kind is allowed for this field, AND
* 3. The schema's provider supports fuzzy search (postgres only).
*
* Returns `Base` unchanged when any condition fails — never `Base & {}`,
* since intersecting with `{}` would strip `null`/`undefined` from `Base`.
*/
type AddFuzzyFilterIfSupported<
Schema extends SchemaDef,
FieldType extends string,
AllowedKinds extends FilterKind,
Base,
> = FieldType extends 'String'
? 'Fuzzy' extends AllowedKinds
? ProviderSupportsFuzzy<Schema> extends true
? Base & {
/**
* Performs a fuzzy search on the string field. Only available when
* the schema's provider is `postgresql` (uses `pg_trgm`).
* See {@link FuzzyFilterPayload} for the full options reference.
*/
fuzzy?: FuzzyFilterPayload;
}
: Base
: Base
: Base;

type EnumFilter<
Schema extends SchemaDef,
T extends GetEnums<Schema>,
Expand Down Expand Up @@ -887,6 +921,92 @@ 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>];

/**
* Payload for the `fuzzy` string filter operator. Performs a fuzzy search using
* PostgreSQL `pg_trgm` (only available when the schema's provider is `postgresql`).
* 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.
*/
export type FuzzyFilterPayload = {
/**
* 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;
};

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).
*
* When multiple fields are provided, the row's relevance score is the
* greatest per-field similarity, i.e. `GREATEST(similarity(field1, search), similarity(field2, search), ...)`.
*/
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 +1357,10 @@ type SortAndTakeArgs<
/**
* Order by clauses
*/
orderBy?: OrArray<OrderBy<Schema, Model, true, false>>;
orderBy?: OrArray<
OrderBy<Schema, Model, true, false> &
(ProviderSupportsFuzzy<Schema> extends true ? FuzzyRelevanceOrderBy<Schema, Model> : {})
>;

/**
* Cursor for pagination
Expand Down Expand Up @@ -2465,6 +2588,8 @@ type ProviderSupportsDistinct<Schema extends SchemaDef> = Schema['provider']['ty
? true
: false;

type ProviderSupportsFuzzy<Schema extends SchemaDef> = Schema['provider']['type'] extends 'postgresql' ? true : false;

/**
* Extracts extended query args for a specific operation.
*/
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
}
Loading
Loading