Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -585,6 +585,22 @@ export type StringFilter<
mode?: 'default' | 'insensitive';
}
: {}) &
('Fuzzy' extends AllowedKinds
? {
/**
* 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 @@ -893,6 +909,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?: {
/**
* String fields to compute relevance against (must be non-empty).
*/
fields: [StringFields<Schema, Model>, ...StringFields<Schema, Model>[]];
/**
* 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 @@ -1243,7 +1287,7 @@ type SortAndTakeArgs<
/**
* Order by clauses
*/
orderBy?: OrArray<OrderBy<Schema, Model, true, false>>;
orderBy?: OrArray<OrderBy<Schema, Model, true, false> & RelevanceOrderBy<Schema, Model>>;

/**
* 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 (
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 @@ -932,14 +938,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 @@ -1096,6 +1113,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;
}

// aggregations
if (['_count', '_avg', '_sum', '_min', '_max'].includes(field)) {
invariant(typeof value === 'object', `invalid orderBy value for field "${field}"`);
Expand Down Expand Up @@ -1600,5 +1641,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 @@ -391,4 +391,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 @@ -558,4 +558,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 @@ -543,5 +543,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 @@ -919,6 +919,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 @@ -1175,6 +1177,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();
}

return refineAtMostOneKey(z.strictObject(fields));
}

Expand Down
Loading