From 62fc9d71d859e4abd724821508e86635221bbed3 Mon Sep 17 00:00:00 2001 From: Lucas Z Date: Fri, 10 Apr 2026 12:34:09 +0200 Subject: [PATCH 1/8] feat(orm): add fuzzy search and relevance ordering support (Postgress only) - Introduced fuzzy search operators (`fuzzy`, `fuzzyContains`) in the ORM. - Added `RelevanceOrderBy` type for sorting based on fuzzy search relevance. - Implemented fuzzy search filters in PostgreSQL dialect. - Added error handling for unsupported fuzzy search features in MySQL and SQLite dialects. - Updated Zod schema factory to include fuzzy search fields. - Created a new `Flavor` model in the schema for testing purposes. --- packages/orm/src/client/constants.ts | 4 + packages/orm/src/client/crud-types.ts | 39 +- .../src/client/crud/dialects/base-dialect.ts | 58 ++- .../orm/src/client/crud/dialects/mysql.ts | 21 + .../src/client/crud/dialects/postgresql.ts | 30 ++ .../orm/src/client/crud/dialects/sqlite.ts | 17 + packages/orm/src/client/zod/factory.ts | 16 + tests/e2e/orm/client-api/fuzzy-search.test.ts | 439 ++++++++++++++++++ tests/e2e/orm/schemas/basic/input.ts | 21 + tests/e2e/orm/schemas/basic/models.ts | 1 + tests/e2e/orm/schemas/basic/schema.ts | 25 + tests/e2e/orm/schemas/basic/schema.zmodel | 6 + 12 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/orm/client-api/fuzzy-search.test.ts diff --git a/packages/orm/src/client/constants.ts b/packages/orm/src/client/constants.ts index a945b7da2..d4826a77a 100644 --- a/packages/orm/src/client/constants.ts +++ b/packages/orm/src/client/constants.ts @@ -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', diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index d29822209..a102a701e 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -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 ? { /** @@ -893,6 +909,27 @@ type TypedJsonFieldsFilter< export type SortOrder = 'asc' | 'desc'; export type NullsOrder = 'first' | 'last'; +export type RelevanceOrderBy> = { + /** + * Sorts by the relevance of a fuzzy/full-text search. Uses similarity() on PostgreSQL, + * MATCH AGAINST scoring on MySQL. + */ + _relevance?: { + /** + * Fields to compute relevance against. + */ + fields: NonRelationFields[]; + /** + * The search term to compute relevance for. + */ + search: string; + /** + * Sort direction. + */ + sort: SortOrder; + }; +}; + export type OrderBy< Schema extends SchemaDef, Model extends GetModels, @@ -1243,7 +1280,7 @@ type SortAndTakeArgs< /** * Order by clauses */ - orderBy?: OrArray>; + orderBy?: OrArray & RelevanceOrderBy>; /** * Cursor for pagination diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index b525ac486..96aa18b58 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -932,7 +932,6 @@ export abstract class BaseCrudDialect { if (payload && typeof payload === 'object') { for (const [key, value] of Object.entries(payload)) { if (key === 'mode' || consumedKeys.includes(key)) { - // already consumed continue; } @@ -940,6 +939,18 @@ export abstract class BaseCrudDialect { 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); @@ -1096,6 +1107,30 @@ export abstract class BaseCrudDialect { 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}"`); @@ -1600,5 +1635,26 @@ export abstract class BaseCrudDialect { nulls: 'first' | 'last', ): SelectQueryBuilder; + /** + * Builds a fuzzy search filter for a string field using trigram similarity. + */ + abstract buildFuzzyFilter(fieldRef: Expression, value: string): Expression; + + /** + * 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, value: string): Expression; + + /** + * Builds an ORDER BY clause that sorts by fuzzy relevance to a search term. + */ + abstract buildRelevanceOrderBy( + query: SelectQueryBuilder, + fieldRefs: Expression[], + search: string, + sort: SortOrder, + ): SelectQueryBuilder; + // #endregion } diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index dff577204..b50c71c64 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -391,4 +391,25 @@ export class MySqlCrudDialect extends LateralJoinDiale } // #endregion + + // #region fuzzy search + + override buildFuzzyFilter(_fieldRef: Expression, _value: string): Expression { + throw createNotSupportedError('"fuzzy" filter is not supported by the "mysql" provider'); + } + + override buildFuzzyContainsFilter(_fieldRef: Expression, _value: string): Expression { + throw createNotSupportedError('"fuzzyContains" filter is not supported by the "mysql" provider'); + } + + override buildRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _sort: SortOrder, + ): SelectQueryBuilder { + throw createNotSupportedError('"_relevance" ordering is not supported by the "mysql" provider'); + } + + // #endregion } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index c5a0ea485..0daa1d706 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -558,4 +558,34 @@ export class PostgresCrudDialect extends LateralJoinDi } // #endregion + + // #region search + + override buildFuzzyFilter(fieldRef: Expression, value: string): Expression { + return sql`unaccent(lower(${fieldRef})) % unaccent(lower(${sql.val(value)}))`; + } + + override buildFuzzyContainsFilter(fieldRef: Expression, value: string): Expression { + return sql`unaccent(lower(${sql.val(value)})) <% unaccent(lower(${fieldRef}))`; + } + + override buildRelevanceOrderBy( + query: SelectQueryBuilder, + fieldRefs: Expression[], + search: string, + sort: SortOrder, + ): SelectQueryBuilder { + 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 } diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 28935c7c7..b6e64ed00 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -543,5 +543,22 @@ export class SqliteCrudDialect extends BaseCrudDialect return ob; }); } + + override buildFuzzyFilter(_fieldRef: Expression, _value: string): Expression { + throw createNotSupportedError('"fuzzy" filter is not supported by the "sqlite" provider'); + } + + override buildFuzzyContainsFilter(_fieldRef: Expression, _value: string): Expression { + throw createNotSupportedError('"fuzzyContains" filter is not supported by the "sqlite" provider'); + } + + override buildRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _sort: SortOrder, + ): SelectQueryBuilder { + throw createNotSupportedError('"_relevance" ordering is not supported by the "sqlite" provider'); + } // #endregion } diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 0f8b22701..b59662c17 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -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(), @@ -1175,6 +1177,20 @@ export class ZodSchemaFactory< } } + // _relevance ordering for fuzzy/full-text search + const scalarFieldNames = this.getModelFields(model) + .filter(([, def]) => !def.relation) + .map(([name]) => name); + if (scalarFieldNames.length > 0) { + fields['_relevance'] = z + .strictObject({ + fields: z.array(z.enum(scalarFieldNames as [string, ...string[]])).min(1), + search: z.string(), + sort, + }) + .optional(); + } + return refineAtMostOneKey(z.strictObject(fields)); } diff --git a/tests/e2e/orm/client-api/fuzzy-search.test.ts b/tests/e2e/orm/client-api/fuzzy-search.test.ts new file mode 100644 index 000000000..cbfd614bd --- /dev/null +++ b/tests/e2e/orm/client-api/fuzzy-search.test.ts @@ -0,0 +1,439 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ClientContract } from '@zenstackhq/orm'; +import { createTestClient, getTestDbProvider } from '@zenstackhq/testtools'; +import { schema } from '../schemas/basic'; + +type Schema = typeof schema; +const provider = getTestDbProvider(); + +describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { + let client: ClientContract; + + beforeEach(async () => { + client = await createTestClient(schema); + + await client.$executeRaw`CREATE EXTENSION IF NOT EXISTS unaccent`; + await client.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_trgm`; + + await client.flavor.createMany({ + data: [ + { name: 'Apple', description: 'A sweet red fruit' }, + { name: 'Apricot', description: 'Small orange fruit' }, + { name: 'Banana', description: 'Yellow tropical fruit' }, + { name: 'Strawberry', description: 'Red berry with seeds' }, + { name: 'Crème brûlée', description: 'French custard dessert' }, + { name: 'Crème fraîche', description: 'Thick French cream' }, + { name: 'Café au lait', description: 'Coffee with milk' }, + { name: 'Éclair au chocolat', description: 'French pastry with chocolate' }, + { name: 'Pâté à choux', description: 'Light pastry dough' }, + { name: null, description: 'No name item' }, + ], + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + // --------------------------------------------------------------- + // A. Fuzzy search — basic English words + // --------------------------------------------------------------- + + it('finds Apple despite missing letter (Aple)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'Aple' } }, + }); + expect(results.some((r) => r.name === 'Apple')).toBe(true); + }); + + it('finds Apple with transposed letters (Appel)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'Appel' } }, + }); + expect(results.some((r) => r.name === 'Apple')).toBe(true); + }); + + it('finds Strawberry despite missing letter (Strawbery)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'Strawbery' } }, + }); + expect(results.some((r) => r.name === 'Strawberry')).toBe(true); + }); + + it('finds Banana with truncation (Banan)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'Banan' } }, + }); + expect(results.some((r) => r.name === 'Banana')).toBe(true); + }); + + it('returns nothing for totally unrelated term', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'xyz123' } }, + }); + expect(results).toHaveLength(0); + }); + + // --------------------------------------------------------------- + // B. Fuzzy search — French words with accents + // --------------------------------------------------------------- + + it('finds accented names when searching without accents (creme)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'creme' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + expect(names).toContain('Crème fraîche'); + }); + + it('finds accented names when searching with exact accents (Crème)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'Crème' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + }); + + it('finds Café au lait without accent (cafe)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'cafe' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Café au lait'); + }); + + it('finds Éclair au chocolat with exact accent', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'Éclair' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + }); + + it('finds Éclair au chocolat without accent (eclair)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'eclair' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + }); + + it('finds Pâté à choux with exact accent', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'Pâté' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Pâté à choux'); + }); + + it('finds Pâté à choux without accent (pate)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'pate' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Pâté à choux'); + }); + + // --------------------------------------------------------------- + // C. Fuzzy on nullable field + // --------------------------------------------------------------- + + it('does not return items with null name', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'Apple' } }, + }); + expect(results.every((r) => r.name !== null)).toBe(true); + }); + + it('fuzzy on description works for items with null name', async () => { + const results = await client.flavor.findMany({ + where: { description: { fuzzy: 'item' } }, + }); + expect(results.some((r) => r.name === null)).toBe(true); + }); + + // --------------------------------------------------------------- + // D. Fuzzy combined with other filters + // --------------------------------------------------------------- + + it('fuzzy combined with contains on another field', async () => { + const results = await client.flavor.findMany({ + where: { + name: { fuzzy: 'creme' }, + description: { contains: 'custard' }, + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe('Crème brûlée'); + }); + + it('fuzzy combined with contains on the same field', async () => { + const results = await client.flavor.findMany({ + where: { + name: { fuzzy: 'creme', contains: 'brûlée' }, + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe('Crème brûlée'); + }); + + it('fuzzy combined with AND and startsWith', async () => { + const results = await client.flavor.findMany({ + where: { + AND: [{ name: { fuzzy: 'creme' } }, { description: { startsWith: 'Thick' } }], + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe('Crème fraîche'); + }); + + // --------------------------------------------------------------- + // E. Fuzzy in logical compositions + // --------------------------------------------------------------- + + it('OR with two fuzzy terms', async () => { + const results = await client.flavor.findMany({ + where: { + OR: [{ name: { fuzzy: 'apple' } }, { name: { fuzzy: 'banana' } }], + }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Apple'); + expect(names).toContain('Banana'); + }); + + it('NOT excludes matching items', async () => { + const all = await client.flavor.findMany({ + where: { name: { not: null } }, + }); + const results = await client.flavor.findMany({ + where: { + NOT: { name: { fuzzy: 'apple' } }, + name: { not: null }, + }, + }); + expect(results.length).toBeLessThan(all.length); + expect(results.every((r) => r.name !== 'Apple')).toBe(true); + }); + + // --------------------------------------------------------------- + // F. orderBy _relevance — single field + // --------------------------------------------------------------- + + it('orders by relevance with best match first', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: 'Apple' } }, + orderBy: { + _relevance: { fields: ['name'], search: 'Apple', sort: 'desc' }, + }, + }); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]!.name).toBe('Apple'); + }); + + it('orders by relevance for accented search', async () => { + const results = await client.flavor.findMany({ + where: { + OR: [{ name: { fuzzy: 'creme' } }, { name: { fuzzy: 'cafe' } }], + }, + orderBy: { + _relevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + }, + }); + expect(results.length).toBeGreaterThanOrEqual(2); + const firstTwo = results.slice(0, 2).map((r) => r.name); + expect(firstTwo.some((n) => n?.startsWith('Crème'))).toBe(true); + }); + + // --------------------------------------------------------------- + // G. orderBy _relevance — multiple fields + // --------------------------------------------------------------- + + it('orders by relevance across multiple fields', async () => { + const results = await client.flavor.findMany({ + where: { + OR: [ + { name: { fuzzy: 'chocolate' } }, + { description: { fuzzy: 'chocolate' } }, + ], + }, + orderBy: { + _relevance: { + fields: ['name', 'description'], + search: 'chocolate', + sort: 'desc', + }, + }, + }); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]!.name).toBe('Éclair au chocolat'); + }); + + // --------------------------------------------------------------- + // H. orderBy _relevance with skip/take + // --------------------------------------------------------------- + + it('supports pagination with relevance ordering', async () => { + const allResults = await client.flavor.findMany({ + where: { + OR: [{ name: { fuzzy: 'creme' } }, { name: { fuzzy: 'cafe' } }], + }, + orderBy: { + _relevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + }, + }); + + const paged = await client.flavor.findMany({ + where: { + OR: [{ name: { fuzzy: 'creme' } }, { name: { fuzzy: 'cafe' } }], + }, + orderBy: { + _relevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + }, + skip: 1, + take: 1, + }); + + expect(paged).toHaveLength(1); + expect(allResults.length).toBeGreaterThan(1); + expect(paged[0]!.id).toBe(allResults[1]!.id); + }); + + // --------------------------------------------------------------- + // I. fuzzyContains — approximate substring matching + // --------------------------------------------------------------- + + it('fuzzyContains finds short term within longer name', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzyContains: 'choco' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + }); + + it('fuzzyContains finds term within description', async () => { + const results = await client.flavor.findMany({ + where: { description: { fuzzyContains: 'pastryy' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + expect(names).toContain('Pâté à choux'); + }); + + it('fuzzyContains is accent-insensitive', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzyContains: 'brulee' } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + }); + + it('fuzzyContains combined with fuzzy on another field', async () => { + const results = await client.flavor.findMany({ + where: { + name: { fuzzyContains: 'eclair' }, + description: { fuzzy: 'chocolate' }, + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe('Éclair au chocolat'); + }); + + it('fuzzyContains returns nothing for unrelated term', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzyContains: 'zzzzz' } }, + }); + expect(results).toHaveLength(0); + }); + + // --------------------------------------------------------------- + // J. Mutations with fuzzy filter + // --------------------------------------------------------------- + + it('updateMany with fuzzy filter', async () => { + const { count } = await client.flavor.updateMany({ + where: { name: { fuzzy: 'creme' } }, + data: { description: 'Updated via fuzzy' }, + }); + expect(count).toBeGreaterThanOrEqual(2); + + const updated = await client.flavor.findMany({ + where: { description: { equals: 'Updated via fuzzy' } }, + }); + const names = updated.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + expect(names).toContain('Crème fraîche'); + }); + + it('updateMany with fuzzyContains filter', async () => { + const { count } = await client.flavor.updateMany({ + where: { name: { fuzzyContains: 'choco' } }, + data: { description: 'Has chocolate' }, + }); + expect(count).toBeGreaterThanOrEqual(1); + + const updated = await client.flavor.findMany({ + where: { description: { equals: 'Has chocolate' } }, + }); + expect(updated.some((r) => r.name === 'Éclair au chocolat')).toBe(true); + }); + + it('deleteMany with fuzzy filter', async () => { + const beforeCount = await client.flavor.count(); + const { count } = await client.flavor.deleteMany({ + where: { name: { fuzzy: 'apple' } }, + }); + expect(count).toBeGreaterThanOrEqual(1); + + const afterCount = await client.flavor.count(); + expect(afterCount).toBe(beforeCount - count); + + const remaining = await client.flavor.findMany({ + where: { name: { equals: 'Apple' } }, + }); + expect(remaining).toHaveLength(0); + }); + + it('deleteMany with fuzzyContains filter', async () => { + const { count } = await client.flavor.deleteMany({ + where: { description: { fuzzyContains: 'pastry' } }, + }); + expect(count).toBeGreaterThanOrEqual(1); + + const remaining = await client.flavor.findMany({ + where: { name: { equals: 'Éclair au chocolat' } }, + }); + expect(remaining).toHaveLength(0); + }); + + // --------------------------------------------------------------- + // K. GroupBy with fuzzy filter + // --------------------------------------------------------------- + + it('groupBy with fuzzy where filter', async () => { + const groups = await client.flavor.groupBy({ + by: ['description'], + where: { name: { fuzzy: 'creme' } }, + _count: true, + }); + expect(groups.length).toBeGreaterThanOrEqual(2); + const descriptions = groups.map((g: any) => g.description); + expect(descriptions).toContain('French custard dessert'); + expect(descriptions).toContain('Thick French cream'); + }); + + it('count with fuzzy filter', async () => { + const count = await client.flavor.count({ + where: { name: { fuzzy: 'creme' } }, + }); + expect(count).toBeGreaterThanOrEqual(2); + }); + + it('count with fuzzyContains filter', async () => { + const count = await client.flavor.count({ + where: { description: { fuzzyContains: 'pastry' } }, + }); + expect(count).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/tests/e2e/orm/schemas/basic/input.ts b/tests/e2e/orm/schemas/basic/input.ts index 90babcce0..e5872e426 100644 --- a/tests/e2e/orm/schemas/basic/input.ts +++ b/tests/e2e/orm/schemas/basic/input.ts @@ -113,3 +113,24 @@ export type PlainSelect = $SelectInput<$Schema, "Plain">; export type PlainInclude = $IncludeInput<$Schema, "Plain">; export type PlainOmit = $OmitInput<$Schema, "Plain">; export type PlainGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Plain", Args, Options>; +export type FlavorFindManyArgs = $FindManyArgs<$Schema, "Flavor">; +export type FlavorFindUniqueArgs = $FindUniqueArgs<$Schema, "Flavor">; +export type FlavorFindFirstArgs = $FindFirstArgs<$Schema, "Flavor">; +export type FlavorExistsArgs = $ExistsArgs<$Schema, "Flavor">; +export type FlavorCreateArgs = $CreateArgs<$Schema, "Flavor">; +export type FlavorCreateManyArgs = $CreateManyArgs<$Schema, "Flavor">; +export type FlavorCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Flavor">; +export type FlavorUpdateArgs = $UpdateArgs<$Schema, "Flavor">; +export type FlavorUpdateManyArgs = $UpdateManyArgs<$Schema, "Flavor">; +export type FlavorUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Flavor">; +export type FlavorUpsertArgs = $UpsertArgs<$Schema, "Flavor">; +export type FlavorDeleteArgs = $DeleteArgs<$Schema, "Flavor">; +export type FlavorDeleteManyArgs = $DeleteManyArgs<$Schema, "Flavor">; +export type FlavorCountArgs = $CountArgs<$Schema, "Flavor">; +export type FlavorAggregateArgs = $AggregateArgs<$Schema, "Flavor">; +export type FlavorGroupByArgs = $GroupByArgs<$Schema, "Flavor">; +export type FlavorWhereInput = $WhereInput<$Schema, "Flavor">; +export type FlavorSelect = $SelectInput<$Schema, "Flavor">; +export type FlavorInclude = $IncludeInput<$Schema, "Flavor">; +export type FlavorOmit = $OmitInput<$Schema, "Flavor">; +export type FlavorGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Flavor", Args, Options>; diff --git a/tests/e2e/orm/schemas/basic/models.ts b/tests/e2e/orm/schemas/basic/models.ts index 39bd52fdf..08e87f6ed 100644 --- a/tests/e2e/orm/schemas/basic/models.ts +++ b/tests/e2e/orm/schemas/basic/models.ts @@ -12,6 +12,7 @@ export type Post = $ModelResult<$Schema, "Post">; export type Comment = $ModelResult<$Schema, "Comment">; export type Profile = $ModelResult<$Schema, "Profile">; export type Plain = $ModelResult<$Schema, "Plain">; +export type Flavor = $ModelResult<$Schema, "Flavor">; export type CommonFields = $TypeDefResult<$Schema, "CommonFields">; export const Role = $schema.enums.Role.values; export type Role = (typeof Role)[keyof typeof Role]; diff --git a/tests/e2e/orm/schemas/basic/schema.ts b/tests/e2e/orm/schemas/basic/schema.ts index 39f85eef7..1de0f8c12 100644 --- a/tests/e2e/orm/schemas/basic/schema.ts +++ b/tests/e2e/orm/schemas/basic/schema.ts @@ -271,6 +271,31 @@ export class SchemaType implements SchemaDef { uniqueFields: { id: { type: "Int" } } + }, + Flavor: { + name: "Flavor", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + name: { + name: "name", + type: "String", + optional: true + }, + description: { + name: "description", + type: "String" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } } } as const; typeDefs = { diff --git a/tests/e2e/orm/schemas/basic/schema.zmodel b/tests/e2e/orm/schemas/basic/schema.zmodel index 9b2898bb1..2c796727e 100644 --- a/tests/e2e/orm/schemas/basic/schema.zmodel +++ b/tests/e2e/orm/schemas/basic/schema.zmodel @@ -69,3 +69,9 @@ model Plain { id Int @id @default(autoincrement()) value Int } + +model Flavor { + id Int @id @default(autoincrement()) + name String? + description String +} From de08aa444a5eb7b5569597ab8338b18f8bdee9e7 Mon Sep 17 00:00:00 2001 From: Lucas Z Date: Fri, 10 Apr 2026 13:00:43 +0200 Subject: [PATCH 2/8] fix(orm): renforce la validation du fuzzy search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _relevance.fields restreint aux champs String dans le schéma Zod - Rejet du cursor pagination combiné avec _relevance ordering - Type RelevanceOrderBy restreint aux StringFields avec tuple non-vide - JSDoc mis à jour pour refléter le support PostgreSQL uniquement --- packages/orm/src/client/crud-types.ts | 15 +++++++++++---- .../orm/src/client/crud/dialects/base-dialect.ts | 6 ++++++ packages/orm/src/client/zod/factory.ts | 10 +++++----- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index a102a701e..75f64d6e7 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -909,16 +909,23 @@ type TypedJsonFieldsFilter< export type SortOrder = 'asc' | 'desc'; export type NullsOrder = 'first' | 'last'; +type StringFields> = { + [Key in NonRelationFields]: MapModelFieldType extends string | null + ? Key + : never; +}[NonRelationFields]; + export type RelevanceOrderBy> = { /** - * Sorts by the relevance of a fuzzy/full-text search. Uses similarity() on PostgreSQL, - * MATCH AGAINST scoring on MySQL. + * 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?: { /** - * Fields to compute relevance against. + * String fields to compute relevance against (must be non-empty). */ - fields: NonRelationFields[]; + fields: [StringFields, ...StringFields[]]; /** * The search term to compute relevance for. */ diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 96aa18b58..f9b61be45 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -166,6 +166,12 @@ export abstract class BaseCrudDialect { 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, diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index b59662c17..87a12631e 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -1177,14 +1177,14 @@ export class ZodSchemaFactory< } } - // _relevance ordering for fuzzy/full-text search - const scalarFieldNames = this.getModelFields(model) - .filter(([, def]) => !def.relation) + // _relevance ordering for fuzzy search (string fields only) + const stringFieldNames = this.getModelFields(model) + .filter(([, def]) => !def.relation && def.type === 'String') .map(([name]) => name); - if (scalarFieldNames.length > 0) { + if (stringFieldNames.length > 0) { fields['_relevance'] = z .strictObject({ - fields: z.array(z.enum(scalarFieldNames as [string, ...string[]])).min(1), + fields: z.array(z.enum(stringFieldNames as [string, ...string[]])).min(1), search: z.string(), sort, }) From 75e3d4dfcc0700528ef3237045a8ef63ef5d9826 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:31:34 -0700 Subject: [PATCH 3/8] chore: update test baseline file --- .../test/openapi/baseline/rpc.baseline.yaml | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) diff --git a/packages/server/test/openapi/baseline/rpc.baseline.yaml b/packages/server/test/openapi/baseline/rpc.baseline.yaml index 9c850e1c3..854ca8306 100644 --- a/packages/server/test/openapi/baseline/rpc.baseline.yaml +++ b/packages/server/test/openapi/baseline/rpc.baseline.yaml @@ -4323,6 +4323,10 @@ components: type: string contains: type: string + fuzzy: + type: string + fuzzyContains: + type: string mode: anyOf: - type: string @@ -4588,6 +4592,10 @@ components: type: string contains: type: string + fuzzy: + type: string + fuzzyContains: + type: string mode: anyOf: - type: string @@ -5130,6 +5138,29 @@ components: const: asc - type: string const: desc + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - content + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false _count: anyOf: - type: string @@ -5141,6 +5172,30 @@ components: additionalProperties: false setting: $ref: "#/components/schemas/SettingOrderByWithRelationInput" + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - title + - authorId + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false _count: anyOf: - type: string @@ -5202,6 +5257,30 @@ components: - sort - nulls additionalProperties: false + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - myId + - email + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false additionalProperties: false UserWhereUniqueInputWithoutRelation: type: object @@ -6373,6 +6452,30 @@ components: $ref: "#/components/schemas/UserOrderByWithRelationInput" _max: $ref: "#/components/schemas/UserOrderByWithRelationInput" + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - myId + - email + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false additionalProperties: false StringFilterAgg: anyOf: @@ -6477,6 +6580,10 @@ components: type: string contains: type: string + fuzzy: + type: string + fuzzyContains: + type: string mode: anyOf: - type: string @@ -6828,6 +6935,30 @@ components: const: asc - type: string const: desc + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - gender + - userId + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false additionalProperties: false ProfileWhereUniqueInputWithoutRelation: type: object @@ -7427,6 +7558,30 @@ components: $ref: "#/components/schemas/ProfileOrderByWithRelationInput" _max: $ref: "#/components/schemas/ProfileOrderByWithRelationInput" + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - gender + - userId + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false additionalProperties: false IntFilterAgg: anyOf: @@ -8115,6 +8270,29 @@ components: const: asc - type: string const: desc + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - content + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false _count: anyOf: - type: string @@ -8126,6 +8304,30 @@ components: additionalProperties: false setting: $ref: "#/components/schemas/SettingOrderByWithRelationInput" + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - title + - authorId + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false additionalProperties: false PostFindFirstArgs: type: object @@ -9483,6 +9685,30 @@ components: $ref: "#/components/schemas/PostOrderByWithRelationInput" _max: $ref: "#/components/schemas/PostOrderByWithRelationInput" + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - title + - authorId + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false additionalProperties: false StringFilterOptionalAgg: anyOf: @@ -9591,6 +9817,10 @@ components: type: string contains: type: string + fuzzy: + type: string + fuzzyContains: + type: string mode: anyOf: - type: string @@ -9995,6 +10225,29 @@ components: const: asc - type: string const: desc + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - content + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false additionalProperties: false CommentFindFirstArgs: type: object @@ -10582,6 +10835,29 @@ components: $ref: "#/components/schemas/CommentOrderByWithRelationInput" _max: $ref: "#/components/schemas/CommentOrderByWithRelationInput" + _relevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - content + search: + type: string + sort: + anyOf: + - type: string + const: asc + - type: string + const: desc + required: + - fields + - search + - sort + additionalProperties: false additionalProperties: false CommentWhereInputWithoutRelationWithAggregation: type: object From 74905adf1595ebae9a7b7d1fd0e989583d4fbecd Mon Sep 17 00:00:00 2001 From: docloulou Date: Thu, 30 Apr 2026 11:04:06 +0200 Subject: [PATCH 4/8] refactor(fuzzy-search): enhance fuzzy search functionality with structured options - Refactored fuzzy search parameters to use an object structure, allowing for search term, mode, threshold, and accent sensitivity options. - Updated relevant types and methods across PostgreSQL, MySQL, SQLite, and Zod schema to support the new fuzzy search format. - Adjusted tests to reflect the new fuzzy search implementation and ensure compatibility with existing functionality. --- packages/orm/src/client/constants.ts | 1 - packages/orm/src/client/crud-types.ts | 59 ++- .../src/client/crud/dialects/base-dialect.ts | 87 +++- .../orm/src/client/crud/dialects/mysql.ts | 11 +- .../src/client/crud/dialects/postgresql.ts | 46 +- .../orm/src/client/crud/dialects/sqlite.ts | 12 +- packages/orm/src/client/zod/factory.ts | 17 +- .../test/openapi/baseline/rpc.baseline.yaml | 138 ++++- tests/e2e/orm/client-api/fuzzy-search.test.ts | 490 +++++++++++++++--- 9 files changed, 711 insertions(+), 150 deletions(-) diff --git a/packages/orm/src/client/constants.ts b/packages/orm/src/client/constants.ts index d4826a77a..0b15e8c74 100644 --- a/packages/orm/src/client/constants.ts +++ b/packages/orm/src/client/constants.ts @@ -70,7 +70,6 @@ export const FILTER_PROPERTY_TO_KIND = { // Fuzzy search operators fuzzy: 'Fuzzy', - fuzzyContains: 'Fuzzy', // List operators has: 'List', diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 94c454763..5ea2e51a8 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -582,17 +582,47 @@ export type StringFilter< ('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. + * 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?: 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; + 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 @@ -909,13 +939,16 @@ type StringFields> = { : never; }[NonRelationFields]; -export type RelevanceOrderBy> = { +export type FuzzyRelevanceOrderBy> = { /** * 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. + * + * The `_fuzzyRelevance` name is intentionally distinct from `_searchRelevance` + * (reserved for future full-text-search relevance) so the two can coexist. */ - _relevance?: { + _fuzzyRelevance?: { /** * String fields to compute relevance against (must be non-empty). */ @@ -1281,7 +1314,7 @@ type SortAndTakeArgs< /** * Order by clauses */ - orderBy?: OrArray & RelevanceOrderBy>; + orderBy?: OrArray & FuzzyRelevanceOrderBy>; /** * Cursor for pagination diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index e6b4e9367..eeb50c417 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -168,9 +168,11 @@ export abstract class BaseCrudDialect { if (args.cursor) { if ( effectiveOrderBy && - enumerate(effectiveOrderBy).some((ob: any) => typeof ob === 'object' && '_relevance' in ob) + enumerate(effectiveOrderBy).some((ob: any) => typeof ob === 'object' && '_fuzzyRelevance' in ob) ) { - throw createNotSupportedError('cursor pagination cannot be combined with "_relevance" ordering'); + throw createNotSupportedError( + 'cursor pagination cannot be combined with "_fuzzyRelevance" ordering', + ); } result = this.buildCursorFilter( model, @@ -938,14 +940,7 @@ export abstract class BaseCrudDialect { } 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)); + conditions.push(this.buildFuzzyFilter(fieldRef, this.normalizeFuzzyOptions(value))); continue; } @@ -1105,22 +1100,22 @@ export abstract class BaseCrudDialect { continue; } - // _relevance ordering - if (field === '_relevance') { + // _fuzzyRelevance ordering + if (field === '_fuzzyRelevance') { invariant( typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value, - 'invalid orderBy value for "_relevance"', + 'invalid orderBy value for "_fuzzyRelevance"', ); invariant( Array.isArray(value.fields) && value.fields.length > 0, - '_relevance.fields must be a non-empty array', + '_fuzzyRelevance.fields must be a non-empty array', ); invariant( value.sort === 'asc' || value.sort === 'desc', - 'invalid sort value for "_relevance"', + 'invalid sort value for "_fuzzyRelevance"', ); const fieldRefs = value.fields.map((f: string) => buildFieldRef(model, f, modelAlias)); - result = this.buildRelevanceOrderBy( + result = this.buildFuzzyRelevanceOrderBy( result, fieldRefs, value.search, @@ -1634,25 +1629,67 @@ export abstract class BaseCrudDialect { ): SelectQueryBuilder; /** - * Builds a fuzzy search filter for a string field using trigram similarity. + * 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, value: string): Expression; - - /** - * 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, value: string): Expression; + abstract buildFuzzyFilter(fieldRef: Expression, options: FuzzyFilterOptions): Expression; /** * Builds an ORDER BY clause that sorts by fuzzy relevance to a search term. */ - abstract buildRelevanceOrderBy( + abstract buildFuzzyRelevanceOrderBy( query: SelectQueryBuilder, fieldRefs: Expression[], search: string, sort: SortOrder, ): SelectQueryBuilder; + /** + * 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; + 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; +}; diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index a34a27cf8..59ab3d94b 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -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 extends LateralJoinDialectBase { @@ -399,21 +400,17 @@ export class MySqlCrudDialect extends LateralJoinDiale // #region fuzzy search - override buildFuzzyFilter(_fieldRef: Expression, _value: string): Expression { + override buildFuzzyFilter(_fieldRef: Expression, _options: FuzzyFilterOptions): Expression { throw createNotSupportedError('"fuzzy" filter is not supported by the "mysql" provider'); } - override buildFuzzyContainsFilter(_fieldRef: Expression, _value: string): Expression { - throw createNotSupportedError('"fuzzyContains" filter is not supported by the "mysql" provider'); - } - - override buildRelevanceOrderBy( + override buildFuzzyRelevanceOrderBy( _query: SelectQueryBuilder, _fieldRefs: Expression[], _search: string, _sort: SortOrder, ): SelectQueryBuilder { - throw createNotSupportedError('"_relevance" ordering is not supported by the "mysql" provider'); + throw createNotSupportedError('"_fuzzyRelevance" ordering is not supported by the "mysql" provider'); } // #endregion diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index 3fd48880d..dd8d0e172 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -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 extends LateralJoinDialectBase { @@ -585,15 +586,48 @@ export class PostgresCrudDialect extends LateralJoinDi // #region search - override buildFuzzyFilter(fieldRef: Expression, value: string): Expression { - return sql`unaccent(lower(${fieldRef})) % unaccent(lower(${sql.val(value)}))`; - } + /** + * 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, applyUnaccent: boolean): Expression { + return applyUnaccent ? sql`unaccent(lower(${expr}))` : sql`lower(${expr})`; + } + + override buildFuzzyFilter(fieldRef: Expression, options: FuzzyFilterOptions): Expression { + 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`${fieldExpr} % ${valueExpr}`; + case 'word': + return sql`${valueExpr} <% ${fieldExpr}`; + case 'strictWord': + return sql`${valueExpr} <<% ${fieldExpr}`; + } + } - override buildFuzzyContainsFilter(fieldRef: Expression, value: string): Expression { - return sql`unaccent(lower(${sql.val(value)})) <% unaccent(lower(${fieldRef}))`; + // 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`similarity(${fieldExpr}, ${valueExpr}) > ${threshold}`; + case 'word': + return sql`word_similarity(${valueExpr}, ${fieldExpr}) > ${threshold}`; + case 'strictWord': + return sql`strict_word_similarity(${valueExpr}, ${fieldExpr}) > ${threshold}`; + } } - override buildRelevanceOrderBy( + override buildFuzzyRelevanceOrderBy( query: SelectQueryBuilder, fieldRefs: Expression[], search: string, diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 0078c0213..8b6f7e6cd 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -26,7 +26,7 @@ import { requireModel, tmpAlias, } from '../../query-utils'; -import { BaseCrudDialect } from './base-dialect'; +import { BaseCrudDialect, type FuzzyFilterOptions } from './base-dialect'; export class SqliteCrudDialect extends BaseCrudDialect { override get provider() { @@ -548,21 +548,17 @@ export class SqliteCrudDialect extends BaseCrudDialect }); } - override buildFuzzyFilter(_fieldRef: Expression, _value: string): Expression { + override buildFuzzyFilter(_fieldRef: Expression, _options: FuzzyFilterOptions): Expression { throw createNotSupportedError('"fuzzy" filter is not supported by the "sqlite" provider'); } - override buildFuzzyContainsFilter(_fieldRef: Expression, _value: string): Expression { - throw createNotSupportedError('"fuzzyContains" filter is not supported by the "sqlite" provider'); - } - - override buildRelevanceOrderBy( + override buildFuzzyRelevanceOrderBy( _query: SelectQueryBuilder, _fieldRefs: Expression[], _search: string, _sort: SortOrder, ): SelectQueryBuilder { - throw createNotSupportedError('"_relevance" ordering is not supported by the "sqlite" provider'); + throw createNotSupportedError('"_fuzzyRelevance" ordering is not supported by the "sqlite" provider'); } // #endregion } diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 9d2df21f7..c0792a9fe 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -1026,8 +1026,7 @@ export class ZodSchemaFactory< startsWith: z.string().optional(), endsWith: z.string().optional(), contains: z.string().optional(), - fuzzy: z.string().optional(), - fuzzyContains: z.string().optional(), + fuzzy: this.makeFuzzyFilterSchema().optional(), ...(this.providerSupportsCaseSensitivity ? { mode: this.makeStringModeSchema().optional(), @@ -1055,6 +1054,15 @@ export class ZodSchemaFactory< return z.union([z.literal('default'), z.literal('insensitive')]); } + private makeFuzzyFilterSchema() { + return z.strictObject({ + search: z.string().min(1), + mode: z.union([z.literal('simple'), z.literal('word'), z.literal('strictWord')]).default('simple'), + threshold: z.number().min(0).max(1).optional(), + unaccent: z.boolean().default(false), + }); + } + @cache() private makeSelectSchema(model: string, options?: CreateSchemaOptions) { const fields: Record = {}; @@ -1301,12 +1309,13 @@ export class ZodSchemaFactory< } } - // _relevance ordering for fuzzy search (string fields only) + // _fuzzyRelevance ordering for fuzzy search (string fields only). + // Distinct from a future `_searchRelevance` for full-text search. const stringFieldNames = this.getModelFields(model) .filter(([, def]) => !def.relation && def.type === 'String') .map(([name]) => name); if (stringFieldNames.length > 0) { - fields['_relevance'] = z + fields['_fuzzyRelevance'] = z .strictObject({ fields: z.array(z.enum(stringFieldNames as [string, ...string[]])).min(1), search: z.string(), diff --git a/packages/server/test/openapi/baseline/rpc.baseline.yaml b/packages/server/test/openapi/baseline/rpc.baseline.yaml index 854ca8306..eb1eba4d0 100644 --- a/packages/server/test/openapi/baseline/rpc.baseline.yaml +++ b/packages/server/test/openapi/baseline/rpc.baseline.yaml @@ -4324,9 +4324,32 @@ components: contains: type: string fuzzy: - type: string - fuzzyContains: - type: string + type: object + properties: + search: + type: string + minLength: 1 + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + threshold: + type: number + minimum: 0 + maximum: 1 + unaccent: + default: false + type: boolean + required: + - search + - mode + - unaccent + additionalProperties: false mode: anyOf: - type: string @@ -4593,9 +4616,32 @@ components: contains: type: string fuzzy: - type: string - fuzzyContains: - type: string + type: object + properties: + search: + type: string + minLength: 1 + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + threshold: + type: number + minimum: 0 + maximum: 1 + unaccent: + default: false + type: boolean + required: + - search + - mode + - unaccent + additionalProperties: false mode: anyOf: - type: string @@ -5138,7 +5184,7 @@ components: const: asc - type: string const: desc - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -5172,7 +5218,7 @@ components: additionalProperties: false setting: $ref: "#/components/schemas/SettingOrderByWithRelationInput" - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -5257,7 +5303,7 @@ components: - sort - nulls additionalProperties: false - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -6452,7 +6498,7 @@ components: $ref: "#/components/schemas/UserOrderByWithRelationInput" _max: $ref: "#/components/schemas/UserOrderByWithRelationInput" - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -6581,9 +6627,32 @@ components: contains: type: string fuzzy: - type: string - fuzzyContains: - type: string + type: object + properties: + search: + type: string + minLength: 1 + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + threshold: + type: number + minimum: 0 + maximum: 1 + unaccent: + default: false + type: boolean + required: + - search + - mode + - unaccent + additionalProperties: false mode: anyOf: - type: string @@ -6935,7 +7004,7 @@ components: const: asc - type: string const: desc - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -7558,7 +7627,7 @@ components: $ref: "#/components/schemas/ProfileOrderByWithRelationInput" _max: $ref: "#/components/schemas/ProfileOrderByWithRelationInput" - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -8270,7 +8339,7 @@ components: const: asc - type: string const: desc - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -8304,7 +8373,7 @@ components: additionalProperties: false setting: $ref: "#/components/schemas/SettingOrderByWithRelationInput" - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -9685,7 +9754,7 @@ components: $ref: "#/components/schemas/PostOrderByWithRelationInput" _max: $ref: "#/components/schemas/PostOrderByWithRelationInput" - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -9818,9 +9887,32 @@ components: contains: type: string fuzzy: - type: string - fuzzyContains: - type: string + type: object + properties: + search: + type: string + minLength: 1 + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + threshold: + type: number + minimum: 0 + maximum: 1 + unaccent: + default: false + type: boolean + required: + - search + - mode + - unaccent + additionalProperties: false mode: anyOf: - type: string @@ -10225,7 +10317,7 @@ components: const: asc - type: string const: desc - _relevance: + _fuzzyRelevance: type: object properties: fields: @@ -10835,7 +10927,7 @@ components: $ref: "#/components/schemas/CommentOrderByWithRelationInput" _max: $ref: "#/components/schemas/CommentOrderByWithRelationInput" - _relevance: + _fuzzyRelevance: type: object properties: fields: diff --git a/tests/e2e/orm/client-api/fuzzy-search.test.ts b/tests/e2e/orm/client-api/fuzzy-search.test.ts index cbfd614bd..088ba61c8 100644 --- a/tests/e2e/orm/client-api/fuzzy-search.test.ts +++ b/tests/e2e/orm/client-api/fuzzy-search.test.ts @@ -36,51 +36,61 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { }); // --------------------------------------------------------------- - // A. Fuzzy search — basic English words + // A. fuzzy mode 'simple' — basic English words // --------------------------------------------------------------- it('finds Apple despite missing letter (Aple)', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'Aple' } }, + where: { name: { fuzzy: { search: 'Aple' } } }, }); expect(results.some((r) => r.name === 'Apple')).toBe(true); }); it('finds Apple with transposed letters (Appel)', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'Appel' } }, + where: { name: { fuzzy: { search: 'Appel' } } }, }); expect(results.some((r) => r.name === 'Apple')).toBe(true); }); it('finds Strawberry despite missing letter (Strawbery)', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'Strawbery' } }, + where: { name: { fuzzy: { search: 'Strawbery' } } }, }); expect(results.some((r) => r.name === 'Strawberry')).toBe(true); }); it('finds Banana with truncation (Banan)', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'Banan' } }, + where: { name: { fuzzy: { search: 'Banan' } } }, }); expect(results.some((r) => r.name === 'Banana')).toBe(true); }); it('returns nothing for totally unrelated term', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'xyz123' } }, + where: { name: { fuzzy: { search: 'xyz123' } } }, }); expect(results).toHaveLength(0); }); + it('explicit mode "simple" matches the default', async () => { + const implicit = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Aple' } } }, + }); + const explicit = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'simple', search: 'Aple' } } }, + }); + expect(explicit.map((r) => r.id).sort()).toEqual(implicit.map((r) => r.id).sort()); + }); + // --------------------------------------------------------------- - // B. Fuzzy search — French words with accents + // B. fuzzy mode 'simple' — French words with accents // --------------------------------------------------------------- it('finds accented names when searching without accents (creme)', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'creme' } }, + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Crème brûlée'); @@ -89,7 +99,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('finds accented names when searching with exact accents (Crème)', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'Crème' } }, + where: { name: { fuzzy: { search: 'Crème' } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Crème brûlée'); @@ -97,7 +107,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('finds Café au lait without accent (cafe)', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'cafe' } }, + where: { name: { fuzzy: { search: 'cafe', unaccent: true } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Café au lait'); @@ -105,7 +115,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('finds Éclair au chocolat with exact accent', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'Éclair' } }, + where: { name: { fuzzy: { search: 'Éclair' } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Éclair au chocolat'); @@ -113,7 +123,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('finds Éclair au chocolat without accent (eclair)', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'eclair' } }, + where: { name: { fuzzy: { search: 'eclair', unaccent: true } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Éclair au chocolat'); @@ -121,7 +131,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('finds Pâté à choux with exact accent', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'Pâté' } }, + where: { name: { fuzzy: { search: 'Pâté' } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Pâté à choux'); @@ -129,38 +139,38 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('finds Pâté à choux without accent (pate)', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'pate' } }, + where: { name: { fuzzy: { search: 'pate', unaccent: true } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Pâté à choux'); }); // --------------------------------------------------------------- - // C. Fuzzy on nullable field + // C. fuzzy on nullable field // --------------------------------------------------------------- it('does not return items with null name', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'Apple' } }, + where: { name: { fuzzy: { search: 'Apple' } } }, }); expect(results.every((r) => r.name !== null)).toBe(true); }); it('fuzzy on description works for items with null name', async () => { const results = await client.flavor.findMany({ - where: { description: { fuzzy: 'item' } }, + where: { description: { fuzzy: { search: 'item' } } }, }); expect(results.some((r) => r.name === null)).toBe(true); }); // --------------------------------------------------------------- - // D. Fuzzy combined with other filters + // D. fuzzy combined with other filters // --------------------------------------------------------------- it('fuzzy combined with contains on another field', async () => { const results = await client.flavor.findMany({ where: { - name: { fuzzy: 'creme' }, + name: { fuzzy: { search: 'creme', unaccent: true } }, description: { contains: 'custard' }, }, }); @@ -171,7 +181,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('fuzzy combined with contains on the same field', async () => { const results = await client.flavor.findMany({ where: { - name: { fuzzy: 'creme', contains: 'brûlée' }, + name: { fuzzy: { search: 'creme', unaccent: true }, contains: 'brûlée' }, }, }); expect(results).toHaveLength(1); @@ -181,7 +191,10 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('fuzzy combined with AND and startsWith', async () => { const results = await client.flavor.findMany({ where: { - AND: [{ name: { fuzzy: 'creme' } }, { description: { startsWith: 'Thick' } }], + AND: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { description: { startsWith: 'Thick' } }, + ], }, }); expect(results).toHaveLength(1); @@ -189,13 +202,16 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { }); // --------------------------------------------------------------- - // E. Fuzzy in logical compositions + // E. fuzzy in logical compositions // --------------------------------------------------------------- it('OR with two fuzzy terms', async () => { const results = await client.flavor.findMany({ where: { - OR: [{ name: { fuzzy: 'apple' } }, { name: { fuzzy: 'banana' } }], + OR: [ + { name: { fuzzy: { search: 'apple' } } }, + { name: { fuzzy: { search: 'banana' } } }, + ], }, }); const names = results.map((r) => r.name); @@ -209,7 +225,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { }); const results = await client.flavor.findMany({ where: { - NOT: { name: { fuzzy: 'apple' } }, + NOT: { name: { fuzzy: { search: 'apple' } } }, name: { not: null }, }, }); @@ -218,14 +234,14 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { }); // --------------------------------------------------------------- - // F. orderBy _relevance — single field + // F. orderBy _fuzzyRelevance — single field // --------------------------------------------------------------- it('orders by relevance with best match first', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzy: 'Apple' } }, + where: { name: { fuzzy: { search: 'Apple' } } }, orderBy: { - _relevance: { fields: ['name'], search: 'Apple', sort: 'desc' }, + _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' }, }, }); expect(results.length).toBeGreaterThanOrEqual(1); @@ -235,10 +251,13 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('orders by relevance for accented search', async () => { const results = await client.flavor.findMany({ where: { - OR: [{ name: { fuzzy: 'creme' } }, { name: { fuzzy: 'cafe' } }], + OR: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + ], }, orderBy: { - _relevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc' }, }, }); expect(results.length).toBeGreaterThanOrEqual(2); @@ -247,19 +266,19 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { }); // --------------------------------------------------------------- - // G. orderBy _relevance — multiple fields + // G. orderBy _fuzzyRelevance — multiple fields // --------------------------------------------------------------- it('orders by relevance across multiple fields', async () => { const results = await client.flavor.findMany({ where: { OR: [ - { name: { fuzzy: 'chocolate' } }, - { description: { fuzzy: 'chocolate' } }, + { name: { fuzzy: { search: 'chocolate' } } }, + { description: { fuzzy: { search: 'chocolate' } } }, ], }, orderBy: { - _relevance: { + _fuzzyRelevance: { fields: ['name', 'description'], search: 'chocolate', sort: 'desc', @@ -271,25 +290,31 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { }); // --------------------------------------------------------------- - // H. orderBy _relevance with skip/take + // H. orderBy _fuzzyRelevance with skip/take // --------------------------------------------------------------- it('supports pagination with relevance ordering', async () => { const allResults = await client.flavor.findMany({ where: { - OR: [{ name: { fuzzy: 'creme' } }, { name: { fuzzy: 'cafe' } }], + OR: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + ], }, orderBy: { - _relevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc' }, }, }); const paged = await client.flavor.findMany({ where: { - OR: [{ name: { fuzzy: 'creme' } }, { name: { fuzzy: 'cafe' } }], + OR: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + ], }, orderBy: { - _relevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc' }, }, skip: 1, take: 1, @@ -301,48 +326,48 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { }); // --------------------------------------------------------------- - // I. fuzzyContains — approximate substring matching + // I. fuzzy mode 'word' — approximate substring matching (formerly fuzzyContains) // --------------------------------------------------------------- - it('fuzzyContains finds short term within longer name', async () => { + it('mode "word" finds short term within longer name', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzyContains: 'choco' } }, + where: { name: { fuzzy: { mode: 'word', search: 'choco' } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Éclair au chocolat'); }); - it('fuzzyContains finds term within description', async () => { + it('mode "word" tolerates typos within description (pastryy)', async () => { const results = await client.flavor.findMany({ - where: { description: { fuzzyContains: 'pastryy' } }, + where: { description: { fuzzy: { mode: 'word', search: 'pastryy' } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Éclair au chocolat'); expect(names).toContain('Pâté à choux'); }); - it('fuzzyContains is accent-insensitive', async () => { + it('mode "word" is accent-insensitive', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzyContains: 'brulee' } }, + where: { name: { fuzzy: { mode: 'word', search: 'brulee', unaccent: true } } }, }); const names = results.map((r) => r.name); expect(names).toContain('Crème brûlée'); }); - it('fuzzyContains combined with fuzzy on another field', async () => { + it('mode "word" combined with simple fuzzy on another field', async () => { const results = await client.flavor.findMany({ where: { - name: { fuzzyContains: 'eclair' }, - description: { fuzzy: 'chocolate' }, + name: { fuzzy: { mode: 'word', search: 'eclair', unaccent: true } }, + description: { fuzzy: { search: 'chocolate' } }, }, }); expect(results).toHaveLength(1); expect(results[0]!.name).toBe('Éclair au chocolat'); }); - it('fuzzyContains returns nothing for unrelated term', async () => { + it('mode "word" returns nothing for unrelated term', async () => { const results = await client.flavor.findMany({ - where: { name: { fuzzyContains: 'zzzzz' } }, + where: { name: { fuzzy: { mode: 'word', search: 'zzzzz' } } }, }); expect(results).toHaveLength(0); }); @@ -351,9 +376,9 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { // J. Mutations with fuzzy filter // --------------------------------------------------------------- - it('updateMany with fuzzy filter', async () => { + it('updateMany with fuzzy mode "simple" filter', async () => { const { count } = await client.flavor.updateMany({ - where: { name: { fuzzy: 'creme' } }, + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, data: { description: 'Updated via fuzzy' }, }); expect(count).toBeGreaterThanOrEqual(2); @@ -366,9 +391,9 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { expect(names).toContain('Crème fraîche'); }); - it('updateMany with fuzzyContains filter', async () => { + it('updateMany with fuzzy mode "word" filter', async () => { const { count } = await client.flavor.updateMany({ - where: { name: { fuzzyContains: 'choco' } }, + where: { name: { fuzzy: { mode: 'word', search: 'choco' } } }, data: { description: 'Has chocolate' }, }); expect(count).toBeGreaterThanOrEqual(1); @@ -379,10 +404,10 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { expect(updated.some((r) => r.name === 'Éclair au chocolat')).toBe(true); }); - it('deleteMany with fuzzy filter', async () => { + it('deleteMany with fuzzy mode "simple" filter', async () => { const beforeCount = await client.flavor.count(); const { count } = await client.flavor.deleteMany({ - where: { name: { fuzzy: 'apple' } }, + where: { name: { fuzzy: { search: 'apple' } } }, }); expect(count).toBeGreaterThanOrEqual(1); @@ -395,9 +420,9 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { expect(remaining).toHaveLength(0); }); - it('deleteMany with fuzzyContains filter', async () => { + it('deleteMany with fuzzy mode "word" filter', async () => { const { count } = await client.flavor.deleteMany({ - where: { description: { fuzzyContains: 'pastry' } }, + where: { description: { fuzzy: { mode: 'word', search: 'pastry' } } }, }); expect(count).toBeGreaterThanOrEqual(1); @@ -414,7 +439,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('groupBy with fuzzy where filter', async () => { const groups = await client.flavor.groupBy({ by: ['description'], - where: { name: { fuzzy: 'creme' } }, + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, _count: true, }); expect(groups.length).toBeGreaterThanOrEqual(2); @@ -423,17 +448,356 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { expect(descriptions).toContain('Thick French cream'); }); - it('count with fuzzy filter', async () => { + it('count with fuzzy mode "simple" filter', async () => { const count = await client.flavor.count({ - where: { name: { fuzzy: 'creme' } }, + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, }); expect(count).toBeGreaterThanOrEqual(2); }); - it('count with fuzzyContains filter', async () => { + it('count with fuzzy mode "word" filter', async () => { const count = await client.flavor.count({ - where: { description: { fuzzyContains: 'pastry' } }, + where: { description: { fuzzy: { mode: 'word', search: 'pastry' } } }, }); expect(count).toBeGreaterThanOrEqual(2); }); + + // --------------------------------------------------------------- + // L. fuzzy with explicit threshold (function form: similarity() > threshold) + // --------------------------------------------------------------- + + it('high threshold (0.9) matches only near-exact terms', async () => { + const high = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Apple', threshold: 0.9 } } }, + }); + const names = high.map((r) => r.name); + expect(names).toContain('Apple'); + // 0.9 is strict — Apricot must not match Apple at this threshold + expect(names).not.toContain('Apricot'); + }); + + it('low threshold (0.05) matches more permissively than high threshold', async () => { + const low = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'App', threshold: 0.05 } } }, + }); + const high = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'App', threshold: 0.9 } } }, + }); + expect(low.length).toBeGreaterThan(high.length); + }); + + it('threshold 0 matches every non-null name', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Apple', threshold: 0 } } }, + }); + // similarity() > 0 is true for any sharing at least one trigram; many seed + // rows do NOT share a trigram with 'Apple', so this is not a free-for-all. + // We only assert the strictest match is included and at least one weaker one too. + const names = results.map((r) => r.name); + expect(names).toContain('Apple'); + expect(results.length).toBeGreaterThan(1); + }); + + it('threshold 1 rejects everything (similarity strictly > 1 is impossible)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Apple', threshold: 1 } } }, + }); + expect(results).toHaveLength(0); + }); + + it('threshold works with mode "word"', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'word', search: 'choco', threshold: 0.5 } } }, + }); + expect(results.some((r) => r.name === 'Éclair au chocolat')).toBe(true); + }); + + it('threshold works with mode "strictWord"', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'strictWord', search: 'choco', threshold: 0.3 } } }, + }); + expect(results.some((r) => r.name === 'Éclair au chocolat')).toBe(true); + }); + + it('threshold can be tuned per query without affecting subsequent queries', async () => { + // Verify two queries with different thresholds return different result sets, + // proving the threshold is per-query (function form), not session-wide. + const strict = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Aple', threshold: 0.9 } } }, + }); + const lenient = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Aple', threshold: 0.1 } } }, + }); + expect(lenient.length).toBeGreaterThanOrEqual(strict.length); + }); + + // --------------------------------------------------------------- + // M. fuzzy with mode 'strictWord' + // --------------------------------------------------------------- + + it('mode "strictWord" finds the chocolate item', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'strictWord', search: 'chocolat' } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + }); + + it('mode "strictWord" is generally stricter than mode "word"', async () => { + const word = await client.flavor.findMany({ + where: { description: { fuzzy: { mode: 'word', search: 'pastry' } } }, + }); + const strict = await client.flavor.findMany({ + where: { description: { fuzzy: { mode: 'strictWord', search: 'pastry' } } }, + }); + expect(strict.length).toBeLessThanOrEqual(word.length); + }); + + // --------------------------------------------------------------- + // N. fuzzy with unaccent (opt-in; default is false) + // --------------------------------------------------------------- + + it('omitted unaccent uses the default (false) and does NOT match accented names', async () => { + // Confirms the API contract: no implicit dependency on the `unaccent` extension. + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme' } } }, + }); + const names = results.map((r) => r.name); + expect(names).not.toContain('Crème brûlée'); + expect(names).not.toContain('Crème fraîche'); + }); + + it('unaccent: true (opt-in) finds accented terms via plain ascii search', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + }); + + it('unaccent: false still matches when search and field share casing/letters', async () => { + // 'Apple' has no diacritics — disabling unaccent must not break basic matching. + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Aple', unaccent: false } } }, + }); + expect(results.some((r) => r.name === 'Apple')).toBe(true); + }); + + it('unaccent: false yields fewer accented matches than unaccent: true', async () => { + const withUnaccent = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + }); + const withoutUnaccent = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: false } } }, + }); + // With unaccent: 'creme' matches 'Crème brûlée' / 'Crème fraîche'. + // Without unaccent: 'creme' will not match 'Crème ...' because trigrams differ. + expect(withoutUnaccent.length).toBeLessThan(withUnaccent.length); + }); + + it('unaccent: false works alongside threshold and mode "word"', async () => { + const results = await client.flavor.findMany({ + where: { + name: { + fuzzy: { mode: 'word', search: 'choco', threshold: 0.5, unaccent: false }, + }, + }, + }); + expect(results.some((r) => r.name === 'Éclair au chocolat')).toBe(true); + }); + + // --------------------------------------------------------------- + // O. cursor pagination guard + // --------------------------------------------------------------- + + it('rejects cursor pagination combined with _fuzzyRelevance', async () => { + const first = await client.flavor.findFirst({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + }); + expect(first).not.toBeNull(); + await expect( + client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + }, + cursor: { id: first!.id }, + take: 2, + }), + ).rejects.toThrow(/_fuzzyRelevance/); + }); + + // --------------------------------------------------------------- + // P. OrArray contract + // Validates the design decision (PR #2573 review) to keep + // `_fuzzyRelevance` INSIDE the OrArray wrapper via intersection. + // Each test pins one of the use cases enabled by that shape. + // --------------------------------------------------------------- + + it('case (a) single object: orderBy: { _fuzzyRelevance: {...} }', async () => { + // Filter null names: similarity(NULL, ...) is NULL and Postgres places + // NULLs first under DESC, which would crowd out the actual best match. + const results = await client.flavor.findMany({ + where: { name: { not: null } }, + orderBy: { _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' } }, + }); + expect(results[0]!.name).toBe('Apple'); + }); + + it('case (a) single object is treated identically to a single-element array', async () => { + // Proves the `enumerate()` normalization in buildOrderBy: the type-level + // `OrArray = T | T[]` collapses to the same runtime SQL. + const single = await client.flavor.findMany({ + where: { name: { not: null } }, + orderBy: { _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' } }, + }); + const arr = await client.flavor.findMany({ + where: { name: { not: null } }, + orderBy: [{ _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' } }], + }); + expect(arr.map((r) => r.id)).toEqual(single.map((r) => r.id)); + }); + + it('case (b) relevance + scalar tie-breaker enables deterministic pagination', async () => { + // Three identical names → primary similarity ties at 1.0. The scalar + // tie-breaker is the only thing deciding the final order. Flipping its + // direction must reverse the result order — proving Kysely chains + // ORDER BY similarity(...) DESC, "id" ASC|DESC (Kysely orderBy is additive, + // confirmed in node_modules/.pnpm/kysely.../order-by-node.js cloneWithItems). + const created = await Promise.all([ + client.flavor.create({ data: { name: 'Mango', description: 'first' } }), + client.flavor.create({ data: { name: 'Mango', description: 'second' } }), + client.flavor.create({ data: { name: 'Mango', description: 'third' } }), + ]); + const ids = created.map((r) => r.id); + + const asc = await client.flavor.findMany({ + where: { name: { equals: 'Mango' } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { id: 'asc' }, + ], + }); + const desc = await client.flavor.findMany({ + where: { name: { equals: 'Mango' } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { id: 'desc' }, + ], + }); + + expect(asc.map((r) => r.id)).toEqual([...ids].sort((a, b) => a - b)); + expect(desc.map((r) => r.id)).toEqual([...ids].sort((a, b) => b - a)); + }); + + it('case (b) tie-breaker survives skip/take pagination', async () => { + // Same forced-tie setup, then paginate. Page boundaries must be stable + // because the tie-breaker is part of the ORDER BY. + const created = await Promise.all([ + client.flavor.create({ data: { name: 'Mango', description: 'first' } }), + client.flavor.create({ data: { name: 'Mango', description: 'second' } }), + client.flavor.create({ data: { name: 'Mango', description: 'third' } }), + ]); + const sortedIds = created.map((r) => r.id).sort((a, b) => a - b); + + const page1 = await client.flavor.findMany({ + where: { name: { equals: 'Mango' } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { id: 'asc' }, + ], + take: 2, + }); + const page2 = await client.flavor.findMany({ + where: { name: { equals: 'Mango' } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { id: 'asc' }, + ], + skip: 2, + take: 2, + }); + + expect(page1.map((r) => r.id)).toEqual([sortedIds[0], sortedIds[1]]); + expect(page2.map((r) => r.id)).toEqual([sortedIds[2]]); + }); + + it('case (c) multi-relevance: secondary clause breaks primary ties', async () => { + // Two identical names → primary _fuzzyRelevance ties. + // Swapping the secondary search term ('tropical' vs 'sweet') must flip + // the order — proving the second relevance clause is genuinely emitted + // as a chained ORDER BY column, not silently ignored. + const created = await Promise.all([ + client.flavor.create({ data: { name: 'Mango', description: 'tropical fruit' } }), + client.flavor.create({ data: { name: 'Mango', description: 'sweet treat' } }), + ]); + const ids = created.map((r) => r.id); + + const tropicalFirst = await client.flavor.findMany({ + where: { id: { in: ids } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { _fuzzyRelevance: { fields: ['description'], search: 'tropical', sort: 'desc' } }, + ], + }); + const sweetFirst = await client.flavor.findMany({ + where: { id: { in: ids } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { _fuzzyRelevance: { fields: ['description'], search: 'sweet', sort: 'desc' } }, + ], + }); + + expect(tropicalFirst[0]!.description).toBe('tropical fruit'); + expect(tropicalFirst[1]!.description).toBe('sweet treat'); + expect(sweetFirst[0]!.description).toBe('sweet treat'); + expect(sweetFirst[1]!.description).toBe('tropical fruit'); + }); + + it('case (c) multi-relevance combined with scalar tie-breaker', async () => { + // Stress the chain: 3 records, primary tied, secondary tied between two + // of them, scalar tie-breaker decides the leftover. Verifies arbitrary + // chaining depth works. + const created = await Promise.all([ + client.flavor.create({ data: { name: 'Mango', description: 'tropical' } }), + client.flavor.create({ data: { name: 'Mango', description: 'cherry' } }), + client.flavor.create({ data: { name: 'Mango', description: 'cherry' } }), + ]); + const ids = created.map((r) => r.id); + const cherryIds = [ids[1]!, ids[2]!].sort((a, b) => a - b); + + const results = await client.flavor.findMany({ + where: { id: { in: ids } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { _fuzzyRelevance: { fields: ['description'], search: 'cherry', sort: 'desc' } }, + { id: 'asc' }, + ], + }); + + expect(results.map((r) => r.id)).toEqual([cherryIds[0], cherryIds[1], ids[0]]); + }); + + it('contract: empty object as array element is silently no-op', async () => { + // Falls out of buildOrderBy's `Object.entries({})` yielding nothing — the + // element is skipped without affecting other elements in the array. + const ref = await client.flavor.findMany({ orderBy: { id: 'asc' } }); + const padded = await client.flavor.findMany({ orderBy: [{}, { id: 'asc' }] }); + expect(padded.map((r) => r.id)).toEqual(ref.map((r) => r.id)); + }); + + it('contract: multi-key in a single orderBy element is rejected by Zod refinement', async () => { + // The intersection `OrderBy & FuzzyRelevanceOrderBy` allows multiple keys + // at the type level, but `refineAtMostOneKey` in zod/factory.ts rejects + // them at runtime. This forces users into the array form for + // tie-breakers, which is the path the runtime parser actually supports. + await expect( + client.flavor.findMany({ + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' }, + id: 'asc', + }, + }), + ).rejects.toThrow(); + }); }); From 09614be1cbfee19d1f215dd9bd99e284cbc6a8b8 Mon Sep 17 00:00:00 2001 From: docloulou Date: Thu, 30 Apr 2026 11:22:35 +0200 Subject: [PATCH 5/8] feat(fuzzy-search): add mode and unaccent options for improved relevance scoring - Introduced `mode` options ('simple', 'word', 'strictWord') to enhance fuzzy search relevance ranking. - Added `unaccent` option to control accent sensitivity during searches. - Updated relevant types and methods across PostgreSQL, MySQL, SQLite, and Zod schema to accommodate new options. - Enhanced tests to validate the new functionality and ensure expected behavior with various search scenarios. --- packages/orm/src/client/crud-types.ts | 10 +- .../src/client/crud/dialects/base-dialect.ts | 15 ++ .../orm/src/client/crud/dialects/mysql.ts | 2 + .../src/client/crud/dialects/postgresql.ts | 24 ++- .../orm/src/client/crud/dialects/sqlite.ts | 2 + packages/orm/src/client/zod/factory.ts | 2 + .../test/openapi/baseline/rpc.baseline.yaml | 154 ++++++++++++++++++ tests/e2e/orm/client-api/fuzzy-search.test.ts | 63 ++++++- 8 files changed, 261 insertions(+), 11 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 5ea2e51a8..cf73211d5 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -941,7 +941,7 @@ type StringFields> = { export type FuzzyRelevanceOrderBy> = { /** - * Sorts by fuzzy search relevance using PostgreSQL `similarity()` from `pg_trgm`. + * 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. * @@ -957,6 +957,14 @@ export type FuzzyRelevanceOrderBy { 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; } @@ -1643,6 +1656,8 @@ export abstract class BaseCrudDialect { fieldRefs: Expression[], search: string, sort: SortOrder, + mode: FuzzyFilterOptions['mode'], + unaccent: boolean, ): SelectQueryBuilder; /** diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 59ab3d94b..f50b7c642 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -409,6 +409,8 @@ export class MySqlCrudDialect extends LateralJoinDiale _fieldRefs: Expression[], _search: string, _sort: SortOrder, + _mode: FuzzyFilterOptions['mode'], + _unaccent: boolean, ): SelectQueryBuilder { throw createNotSupportedError('"_fuzzyRelevance" ordering is not supported by the "mysql" provider'); } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index dd8d0e172..e33266929 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -632,16 +632,26 @@ export class PostgresCrudDialect extends LateralJoinDi fieldRefs: Expression[], search: string, sort: SortOrder, + mode: FuzzyFilterOptions['mode'], + unaccent: boolean, ): SelectQueryBuilder { + const valueExpr = this.normalizeForTrigram(sql.val(search), unaccent); + const buildSimilarity = (fieldRef: Expression) => { + 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( - sql`similarity(unaccent(lower(${fieldRefs[0]})), unaccent(lower(${sql.val(search)})))`, - sort, - ); + return query.orderBy(buildSimilarity(fieldRefs[0]!), sort); } - const similarities = fieldRefs.map( - (ref) => sql`similarity(unaccent(lower(${ref})), unaccent(lower(${sql.val(search)})))`, - ); + const similarities = fieldRefs.map((ref) => buildSimilarity(ref)); return query.orderBy(sql`GREATEST(${sql.join(similarities)})`, sort); } diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 8b6f7e6cd..8e14c7ecb 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -557,6 +557,8 @@ export class SqliteCrudDialect extends BaseCrudDialect _fieldRefs: Expression[], _search: string, _sort: SortOrder, + _mode: FuzzyFilterOptions['mode'], + _unaccent: boolean, ): SelectQueryBuilder { throw createNotSupportedError('"_fuzzyRelevance" ordering is not supported by the "sqlite" provider'); } diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index c0792a9fe..75dd8994a 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -1319,6 +1319,8 @@ export class ZodSchemaFactory< .strictObject({ fields: z.array(z.enum(stringFieldNames as [string, ...string[]])).min(1), search: z.string(), + mode: z.union([z.literal('simple'), z.literal('word'), z.literal('strictWord')]).default('simple'), + unaccent: z.boolean().default(false), sort, }) .optional(); diff --git a/packages/server/test/openapi/baseline/rpc.baseline.yaml b/packages/server/test/openapi/baseline/rpc.baseline.yaml index eb1eba4d0..7cd84bbc6 100644 --- a/packages/server/test/openapi/baseline/rpc.baseline.yaml +++ b/packages/server/test/openapi/baseline/rpc.baseline.yaml @@ -5196,6 +5196,18 @@ components: - content search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -5205,6 +5217,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false _count: @@ -5231,6 +5245,18 @@ components: - authorId search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -5240,6 +5266,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false _count: @@ -5316,6 +5344,18 @@ components: - email search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -5325,6 +5365,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false additionalProperties: false @@ -6511,6 +6553,18 @@ components: - email search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -6520,6 +6574,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false additionalProperties: false @@ -7017,6 +7073,18 @@ components: - userId search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -7026,6 +7094,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false additionalProperties: false @@ -7640,6 +7710,18 @@ components: - userId search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -7649,6 +7731,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false additionalProperties: false @@ -8351,6 +8435,18 @@ components: - content search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -8360,6 +8456,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false _count: @@ -8386,6 +8484,18 @@ components: - authorId search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -8395,6 +8505,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false additionalProperties: false @@ -9767,6 +9879,18 @@ components: - authorId search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -9776,6 +9900,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false additionalProperties: false @@ -10329,6 +10455,18 @@ components: - content search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -10338,6 +10476,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false additionalProperties: false @@ -10939,6 +11079,18 @@ components: - content search: type: string + mode: + default: simple + anyOf: + - type: string + const: simple + - type: string + const: word + - type: string + const: strictWord + unaccent: + default: false + type: boolean sort: anyOf: - type: string @@ -10948,6 +11100,8 @@ components: required: - fields - search + - mode + - unaccent - sort additionalProperties: false additionalProperties: false diff --git a/tests/e2e/orm/client-api/fuzzy-search.test.ts b/tests/e2e/orm/client-api/fuzzy-search.test.ts index 088ba61c8..7b23ce5b0 100644 --- a/tests/e2e/orm/client-api/fuzzy-search.test.ts +++ b/tests/e2e/orm/client-api/fuzzy-search.test.ts @@ -257,7 +257,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { ], }, orderBy: { - _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true }, }, }); expect(results.length).toBeGreaterThanOrEqual(2); @@ -302,7 +302,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { ], }, orderBy: { - _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true }, }, }); @@ -314,7 +314,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { ], }, orderBy: { - _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true }, }, skip: 1, take: 1, @@ -800,4 +800,61 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { }), ).rejects.toThrow(); }); + + // --------------------------------------------------------------- + // Q. orderBy _fuzzyRelevance options + // --------------------------------------------------------------- + + it('mode "word" ranks an exact embedded word above a prefix-only word', async () => { + const prefixOnly = await client.flavor.create({ data: { name: 'Chocolate', description: 'prefix only' } }); + const embeddedWord = await client.flavor.create({ data: { name: 'Hot choco drink', description: 'word' } }); + + const results = await client.flavor.findMany({ + where: { id: { in: [prefixOnly.id, embeddedWord.id] } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'choco', mode: 'word', sort: 'desc' } }, + { id: 'asc' }, + ], + }); + + expect(results[0]!.id).toBe(embeddedWord.id); + }); + + it('mode "strictWord" ranks word-boundary matches above non-boundary matches', async () => { + const nonBoundary = await client.flavor.create({ data: { name: 'xxchocoxx', description: 'non-boundary' } }); + const wordBoundary = await client.flavor.create({ data: { name: 'hot choco drink', description: 'boundary' } }); + + const strict = await client.flavor.findMany({ + where: { id: { in: [nonBoundary.id, wordBoundary.id] } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'choco', mode: 'strictWord', sort: 'desc' } }, + { id: 'asc' }, + ], + }); + + expect(strict[0]!.id).toBe(wordBoundary.id); + }); + + it('unaccent toggles relevance scoring for ascii searches against accented names', async () => { + const accented = await client.flavor.create({ data: { name: 'Crème', description: 'accented exact' } }); + const asciiPrefix = await client.flavor.create({ data: { name: 'Cremezzzz', description: 'ascii prefix' } }); + + const withoutUnaccent = await client.flavor.findMany({ + where: { id: { in: [accented.id, asciiPrefix.id] } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: false } }, + { id: 'asc' }, + ], + }); + const withUnaccent = await client.flavor.findMany({ + where: { id: { in: [accented.id, asciiPrefix.id] } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true } }, + { id: 'asc' }, + ], + }); + + expect(withoutUnaccent[0]!.id).toBe(asciiPrefix.id); + expect(withUnaccent[0]!.id).toBe(accented.id); + }); }); From 48b7835b71d03cbeee03da321c83834baaa7e630 Mon Sep 17 00:00:00 2001 From: lucas z Date: Sat, 2 May 2026 23:14:56 +0200 Subject: [PATCH 6/8] refactor(zod): gate fuzzy filter and _fuzzyRelevance to postgres only Mirror the existing `providerSupportsCaseSensitivity` pattern: introduce `providerSupportsFuzzySearch` and use it to add the `fuzzy` operator and the `_fuzzyRelevance` orderBy schema only when the schema's provider is postgresql. --- packages/orm/src/client/zod/factory.ts | 42 +++++++++++++++++--------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 75dd8994a..cd4b51197 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -1026,7 +1026,11 @@ export class ZodSchemaFactory< startsWith: z.string().optional(), endsWith: z.string().optional(), contains: z.string().optional(), - fuzzy: this.makeFuzzyFilterSchema().optional(), + ...(this.providerSupportsFuzzySearch + ? { + fuzzy: this.makeFuzzyFilterSchema().optional(), + } + : {}), ...(this.providerSupportsCaseSensitivity ? { mode: this.makeStringModeSchema().optional(), @@ -1309,21 +1313,25 @@ export class ZodSchemaFactory< } } - // _fuzzyRelevance ordering for fuzzy search (string fields only). + // _fuzzyRelevance ordering for fuzzy search (string fields only, postgres only). // Distinct from a future `_searchRelevance` for full-text search. - const stringFieldNames = this.getModelFields(model) - .filter(([, def]) => !def.relation && def.type === 'String') - .map(([name]) => name); - if (stringFieldNames.length > 0) { - fields['_fuzzyRelevance'] = z - .strictObject({ - fields: z.array(z.enum(stringFieldNames as [string, ...string[]])).min(1), - search: z.string(), - mode: z.union([z.literal('simple'), z.literal('word'), z.literal('strictWord')]).default('simple'), - unaccent: z.boolean().default(false), - sort, - }) - .optional(); + if (this.providerSupportsFuzzySearch) { + const stringFieldNames = this.getModelFields(model) + .filter(([, def]) => !def.relation && def.type === 'String') + .map(([name]) => name); + if (stringFieldNames.length > 0) { + fields['_fuzzyRelevance'] = z + .strictObject({ + fields: z.array(z.enum(stringFieldNames as [string, ...string[]])).min(1), + search: z.string(), + mode: z + .union([z.literal('simple'), z.literal('word'), z.literal('strictWord')]) + .default('simple'), + unaccent: z.boolean().default(false), + sort, + }) + .optional(); + } } const schema = refineAtMostOneKey(z.strictObject(fields)); @@ -2350,6 +2358,10 @@ export class ZodSchemaFactory< return this.schema.provider.type === 'postgresql'; } + private get providerSupportsFuzzySearch() { + return this.schema.provider.type === 'postgresql'; + } + /** * Gets the effective set of allowed FilterKind values for a specific model and field. * Respects the precedence: model[field] > model.$all > $all[field] > $all.$all. From 13a4991df46b18de2f9c1c3b3c7f6e37bf189892 Mon Sep 17 00:00:00 2001 From: lucas z Date: Sat, 2 May 2026 23:17:22 +0200 Subject: [PATCH 7/8] refactor(types): restrict fuzzy filter and _fuzzyRelevance to postgres at type level Follow the existing `ProviderSupportsDistinct` pattern (review feedback #4): - Add `ProviderSupportsFuzzy` mirroring `ProviderSupportsDistinct`. - Extract the fuzzy payload into a reusable `FuzzyFilterPayload` type. - Gate `_fuzzyRelevance` in `SortAndTakeArgs.orderBy` so it is only typed when provider is postgresql. - Gate `fuzzy` on `StringFilter` via a non-invasive `AddFuzzyIfPostgres` wrapper applied in `FieldFilter` (which already has `Schema` in scope). The wrapper returns `Base` unchanged for non-postgres to preserve nullability (intersecting with `{}` would strip null/undefined). - Update the e2e fuzzy test suite to override the static sqlite provider type: the test only runs on postgres at runtime but typechecks against the basic schema's compile-time provider. --- packages/orm/src/client/crud-types.ts | 158 +++++++++++------- tests/e2e/orm/client-api/fuzzy-search.test.ts | 9 +- 2 files changed, 107 insertions(+), 60 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index cf73211d5..69fc70401 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -376,13 +376,47 @@ type FieldFilter< AllowedKinds > : // primitive - PrimitiveFilter< + AddFuzzyIfPostgres< + Schema, GetModelFieldType, - ModelFieldIsOptional, - WithAggregations, - AllowedKinds + AllowedKinds, + PrimitiveFilter< + GetModelFieldType, + ModelFieldIsOptional, + 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 AddFuzzyIfPostgres< + Schema extends SchemaDef, + FieldType extends string, + AllowedKinds extends FilterKind, + Base, +> = FieldType extends 'String' + ? 'Fuzzy' extends AllowedKinds + ? ProviderSupportsFuzzy 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, @@ -573,59 +607,13 @@ export type StringFilter< */ endsWith?: string; - /** - * Specifies the string comparison mode. Not effective for "sqlite" provider - */ - mode?: 'default' | 'insensitive'; - } - : {}) & - ('Fuzzy' extends AllowedKinds - ? { - /** - * 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 + /** + * Specifies the string comparison mode. Not effective for "sqlite" provider + */ + mode?: 'default' | 'insensitive'; + } + : {}) & + (WithAggregations extends true ? { /** * Filters against the count of records. @@ -939,6 +927,50 @@ type StringFields> = { : never; }[NonRelationFields]; +/** + * 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> = { /** * Sorts by fuzzy search relevance using PostgreSQL `pg_trgm` similarity functions. @@ -951,6 +983,9 @@ export type FuzzyRelevanceOrderBy, ...StringFields[]]; /** @@ -1322,7 +1357,10 @@ type SortAndTakeArgs< /** * Order by clauses */ - orderBy?: OrArray & FuzzyRelevanceOrderBy>; + orderBy?: OrArray< + OrderBy & + (ProviderSupportsFuzzy extends true ? FuzzyRelevanceOrderBy : {}) + >; /** * Cursor for pagination @@ -2550,6 +2588,10 @@ type ProviderSupportsDistinct = Schema['provider']['ty ? true : false; +type ProviderSupportsFuzzy = Schema['provider']['type'] extends 'postgresql' + ? true + : false; + /** * Extracts extended query args for a specific operation. */ diff --git a/tests/e2e/orm/client-api/fuzzy-search.test.ts b/tests/e2e/orm/client-api/fuzzy-search.test.ts index 7b23ce5b0..f5b1ef822 100644 --- a/tests/e2e/orm/client-api/fuzzy-search.test.ts +++ b/tests/e2e/orm/client-api/fuzzy-search.test.ts @@ -3,14 +3,19 @@ import type { ClientContract } from '@zenstackhq/orm'; import { createTestClient, getTestDbProvider } from '@zenstackhq/testtools'; import { schema } from '../schemas/basic'; -type Schema = typeof schema; +// The basic schema is statically typed as sqlite, but `createTestClient` swaps the +// provider at runtime based on TEST_DB_PROVIDER. Fuzzy search is postgres-only, so +// we override the provider type here to enable `fuzzy` / `_fuzzyRelevance` typings. +type Schema = Omit & { + provider: { type: 'postgresql'; defaultSchema?: string }; +}; const provider = getTestDbProvider(); describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createTestClient(schema); + client = (await createTestClient(schema)) as unknown as ClientContract; await client.$executeRaw`CREATE EXTENSION IF NOT EXISTS unaccent`; await client.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_trgm`; From 2208fcba5064ce465f24bd94ad91903db27be930 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 3 May 2026 18:37:36 -0700 Subject: [PATCH 8/8] chore: rename a type helper --- packages/orm/src/client/crud-types.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 69fc70401..c107143ee 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -376,7 +376,7 @@ type FieldFilter< AllowedKinds > : // primitive - AddFuzzyIfPostgres< + AddFuzzyFilterIfSupported< Schema, GetModelFieldType, AllowedKinds, @@ -397,7 +397,7 @@ type FieldFilter< * Returns `Base` unchanged when any condition fails — never `Base & {}`, * since intersecting with `{}` would strip `null`/`undefined` from `Base`. */ -type AddFuzzyIfPostgres< +type AddFuzzyFilterIfSupported< Schema extends SchemaDef, FieldType extends string, AllowedKinds extends FilterKind, @@ -607,13 +607,13 @@ export type StringFilter< */ endsWith?: string; - /** - * Specifies the string comparison mode. Not effective for "sqlite" provider - */ - mode?: 'default' | 'insensitive'; - } - : {}) & - (WithAggregations extends true + /** + * Specifies the string comparison mode. Not effective for "sqlite" provider + */ + mode?: 'default' | 'insensitive'; + } + : {}) & + (WithAggregations extends true ? { /** * Filters against the count of records. @@ -2588,9 +2588,7 @@ type ProviderSupportsDistinct = Schema['provider']['ty ? true : false; -type ProviderSupportsFuzzy = Schema['provider']['type'] extends 'postgresql' - ? true - : false; +type ProviderSupportsFuzzy = Schema['provider']['type'] extends 'postgresql' ? true : false; /** * Extracts extended query args for a specific operation.