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

// Fuzzy search operators
fuzzy: 'Fuzzy',
fuzzyContains: 'Fuzzy',

// List operators
has: 'List',
hasEvery: 'List',
Expand Down
46 changes: 45 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,22 @@ 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 trigram similarity.
* Uses pg_trgm with unaccent on PostgreSQL. Not supported on MySQL or SQLite.
*/
fuzzy?: string;

/**
* Performs a fuzzy substring search: checks if the search term is approximately
* contained within the field value. Uses pg_trgm word_similarity on PostgreSQL.
* Not supported on MySQL or SQLite.
*/
fuzzyContains?: string;
}
: {}) &
(WithAggregations extends true
? {
/**
Expand Down Expand Up @@ -887,6 +903,34 @@ 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 RelevanceOrderBy<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
/**
* Sorts by fuzzy search relevance using PostgreSQL `similarity()` from `pg_trgm`.
* Not supported on MySQL or SQLite (throws `NotSupported` at runtime).
* Cannot be combined with cursor-based pagination.
*/
_relevance?: {
/**
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;
/**
* Sort direction.
*/
sort: SortOrder;
};
};

export type OrderBy<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Expand Down Expand Up @@ -1237,7 +1281,7 @@ type SortAndTakeArgs<
/**
* Order by clauses
*/
orderBy?: OrArray<OrderBy<Schema, Model, true, false>>;
orderBy?: OrArray<OrderBy<Schema, Model, true, false> & RelevanceOrderBy<Schema, Model>>;
Comment thread
ymc9 marked this conversation as resolved.
Outdated

/**
* Cursor for pagination
Expand Down
64 changes: 63 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,12 @@ 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' && '_relevance' in ob)
) {
throw createNotSupportedError('cursor pagination cannot be combined with "_relevance" ordering');
}
result = this.buildCursorFilter(
model,
result,
Expand Down Expand Up @@ -924,14 +930,25 @@ 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') {
invariant(typeof value === 'string', 'fuzzy value must be a string');
conditions.push(this.buildFuzzyFilter(fieldRef, value));
continue;
}

if (key === 'fuzzyContains') {
invariant(typeof value === 'string', 'fuzzyContains value must be a string');
conditions.push(this.buildFuzzyContainsFilter(fieldRef, value));
continue;
}

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

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

// _relevance ordering
if (field === '_relevance') {
invariant(
typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value,
'invalid orderBy value for "_relevance"',
);
invariant(
Array.isArray(value.fields) && value.fields.length > 0,
'_relevance.fields must be a non-empty array',
);
invariant(
value.sort === 'asc' || value.sort === 'desc',
'invalid sort value for "_relevance"',
);
const fieldRefs = value.fields.map((f: string) => buildFieldRef(model, f, modelAlias));
result = this.buildRelevanceOrderBy(
result,
fieldRefs,
value.search,
this.negateSort(value.sort, negated),
);
continue;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// 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 +1633,26 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
nulls: 'first' | 'last',
): SelectQueryBuilder<any, any, any>;

/**
* Builds a fuzzy search filter for a string field using trigram similarity.
*/
abstract buildFuzzyFilter(fieldRef: Expression<any>, value: string): Expression<SqlBool>;

/**
* Builds a fuzzy substring search filter: checks if the search term is
* approximately contained within the field value using word similarity.
*/
abstract buildFuzzyContainsFilter(fieldRef: Expression<any>, value: string): Expression<SqlBool>;

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

// #endregion
}
21 changes: 21 additions & 0 deletions packages/orm/src/client/crud/dialects/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,4 +396,25 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale
}

// #endregion

// #region fuzzy search

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

override buildFuzzyContainsFilter(_fieldRef: Expression<any>, _value: string): Expression<SqlBool> {
throw createNotSupportedError('"fuzzyContains" filter is not supported by the "mysql" provider');
}

override buildRelevanceOrderBy(
_query: SelectQueryBuilder<any, any, any>,
_fieldRefs: Expression<any>[],
_search: string,
_sort: SortOrder,
): SelectQueryBuilder<any, any, any> {
throw createNotSupportedError('"_relevance" ordering is not supported by the "mysql" provider');
}

// #endregion
}
30 changes: 30 additions & 0 deletions packages/orm/src/client/crud/dialects/postgresql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,4 +582,34 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends LateralJoinDi
}

// #endregion

// #region search

override buildFuzzyFilter(fieldRef: Expression<any>, value: string): Expression<SqlBool> {
return sql<SqlBool>`unaccent(lower(${fieldRef})) % unaccent(lower(${sql.val(value)}))`;
}

override buildFuzzyContainsFilter(fieldRef: Expression<any>, value: string): Expression<SqlBool> {
return sql<SqlBool>`unaccent(lower(${sql.val(value)})) <% unaccent(lower(${fieldRef}))`;
}

override buildRelevanceOrderBy(
query: SelectQueryBuilder<any, any, any>,
fieldRefs: Expression<any>[],
search: string,
sort: SortOrder,
): SelectQueryBuilder<any, any, any> {
if (fieldRefs.length === 1) {
return query.orderBy(
sql`similarity(unaccent(lower(${fieldRefs[0]})), unaccent(lower(${sql.val(search)})))`,
sort,
);
}
const similarities = fieldRefs.map(
(ref) => sql`similarity(unaccent(lower(${ref})), unaccent(lower(${sql.val(search)})))`,
);
return query.orderBy(sql`GREATEST(${sql.join(similarities)})`, sort);
}

// #endregion
}
17 changes: 17 additions & 0 deletions packages/orm/src/client/crud/dialects/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,5 +547,22 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
return ob;
});
}

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

override buildFuzzyContainsFilter(_fieldRef: Expression<any>, _value: string): Expression<SqlBool> {
throw createNotSupportedError('"fuzzyContains" filter is not supported by the "sqlite" provider');
}

override buildRelevanceOrderBy(
_query: SelectQueryBuilder<any, any, any>,
_fieldRefs: Expression<any>[],
_search: string,
_sort: SortOrder,
): SelectQueryBuilder<any, any, any> {
throw createNotSupportedError('"_relevance" ordering is not supported by the "sqlite" provider');
}
// #endregion
}
16 changes: 16 additions & 0 deletions packages/orm/src/client/zod/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,8 @@ export class ZodSchemaFactory<
startsWith: z.string().optional(),
endsWith: z.string().optional(),
contains: z.string().optional(),
fuzzy: z.string().optional(),
fuzzyContains: z.string().optional(),
...(this.providerSupportsCaseSensitivity
? {
mode: this.makeStringModeSchema().optional(),
Expand Down Expand Up @@ -1299,6 +1301,20 @@ export class ZodSchemaFactory<
}
}

// _relevance ordering for fuzzy search (string fields only)
const stringFieldNames = this.getModelFields(model)
.filter(([, def]) => !def.relation && def.type === 'String')
.map(([name]) => name);
if (stringFieldNames.length > 0) {
fields['_relevance'] = z
.strictObject({
fields: z.array(z.enum(stringFieldNames as [string, ...string[]])).min(1),
search: z.string(),
sort,
})
.optional();
}

const schema = refineAtMostOneKey(z.strictObject(fields));

let schemaId = `${model}OrderBy`;
Expand Down
Loading
Loading