diff --git a/packages/orm/src/client/constants.ts b/packages/orm/src/client/constants.ts index a945b7da2..0b15e8c74 100644 --- a/packages/orm/src/client/constants.ts +++ b/packages/orm/src/client/constants.ts @@ -68,6 +68,9 @@ export const FILTER_PROPERTY_TO_KIND = { array_starts_with: 'Json', array_ends_with: 'Json', + // Fuzzy search operators + fuzzy: 'Fuzzy', + // List operators has: 'List', hasEvery: 'List', diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 6f82aaba1..cf73211d5 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -579,6 +579,52 @@ export type StringFilter< 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 ? { /** @@ -887,6 +933,45 @@ 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 FuzzyRelevanceOrderBy> = { + /** + * Sorts by fuzzy search relevance using PostgreSQL `pg_trgm` similarity functions. + * Not supported on MySQL or SQLite (throws `NotSupported` at runtime). + * Cannot be combined with cursor-based pagination. + * + * The `_fuzzyRelevance` name is intentionally distinct from `_searchRelevance` + * (reserved for future full-text-search relevance) so the two can coexist. + */ + _fuzzyRelevance?: { + /** + * String fields to compute relevance against (must be non-empty). + */ + fields: [StringFields, ...StringFields[]]; + /** + * The search term to compute relevance for. + */ + search: string; + /** + * Fuzzy matching mode used to compute relevance. + */ + mode?: 'simple' | 'word' | 'strictWord'; + /** + * Whether to remove accents before computing relevance. + */ + unaccent?: boolean; + /** + * Sort direction. + */ + sort: SortOrder; + }; +}; + export type OrderBy< Schema extends SchemaDef, Model extends GetModels, @@ -1237,7 +1322,7 @@ type SortAndTakeArgs< /** * Order by clauses */ - orderBy?: OrArray>; + 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 c90f1f4d0..a9d4d2fcb 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -166,6 +166,14 @@ 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' && '_fuzzyRelevance' in ob) + ) { + throw createNotSupportedError( + 'cursor pagination cannot be combined with "_fuzzyRelevance" ordering', + ); + } result = this.buildCursorFilter( model, result, @@ -924,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; } @@ -932,6 +939,11 @@ export abstract class BaseCrudDialect { continue; } + if (key === 'fuzzy') { + conditions.push(this.buildFuzzyFilter(fieldRef, this.normalizeFuzzyOptions(value))); + continue; + } + invariant(typeof value === 'string', `${key} value must be a string`); const escapedValue = this.escapeLikePattern(value); @@ -1088,6 +1100,43 @@ export abstract class BaseCrudDialect { continue; } + // _fuzzyRelevance ordering + if (field === '_fuzzyRelevance') { + invariant( + typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value, + 'invalid orderBy value for "_fuzzyRelevance"', + ); + invariant( + Array.isArray(value.fields) && value.fields.length > 0, + '_fuzzyRelevance.fields must be a non-empty array', + ); + invariant( + value.sort === 'asc' || value.sort === 'desc', + 'invalid sort value for "_fuzzyRelevance"', + ); + invariant( + typeof value.search === 'string' && value.search.length > 0, + '_fuzzyRelevance.search must be a non-empty string', + ); + const mode = value.mode ?? 'simple'; + invariant( + mode === 'simple' || mode === 'word' || mode === 'strictWord', + '_fuzzyRelevance.mode must be "simple", "word" or "strictWord"', + ); + const unaccent = value.unaccent ?? false; + invariant(typeof unaccent === 'boolean', '_fuzzyRelevance.unaccent must be a boolean'); + const fieldRefs = value.fields.map((f: string) => buildFieldRef(model, f, modelAlias)); + result = this.buildFuzzyRelevanceOrderBy( + result, + fieldRefs, + value.search, + this.negateSort(value.sort, negated), + mode, + unaccent, + ); + continue; + } + // aggregations if (['_count', '_avg', '_sum', '_min', '_max'].includes(field)) { invariant(typeof value === 'object', `invalid orderBy value for field "${field}"`); @@ -1592,5 +1641,70 @@ export abstract class BaseCrudDialect { nulls: 'first' | 'last', ): SelectQueryBuilder; + /** + * 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, options: FuzzyFilterOptions): Expression; + + /** + * Builds an ORDER BY clause that sorts by fuzzy relevance to a search term. + */ + abstract buildFuzzyRelevanceOrderBy( + query: SelectQueryBuilder, + fieldRefs: Expression[], + search: string, + sort: SortOrder, + mode: FuzzyFilterOptions['mode'], + unaccent: boolean, + ): 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 012e755e9..f50b7c642 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 { @@ -396,4 +397,23 @@ export class MySqlCrudDialect extends LateralJoinDiale } // #endregion + + // #region fuzzy search + + override buildFuzzyFilter(_fieldRef: Expression, _options: FuzzyFilterOptions): Expression { + throw createNotSupportedError('"fuzzy" filter is not supported by the "mysql" provider'); + } + + override buildFuzzyRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _sort: SortOrder, + _mode: FuzzyFilterOptions['mode'], + _unaccent: boolean, + ): SelectQueryBuilder { + 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 c8a12de2b..e33266929 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 { @@ -582,4 +583,77 @@ export class PostgresCrudDialect extends LateralJoinDi } // #endregion + + // #region search + + /** + * Wraps an expression with `unaccent(lower(...))` or just `lower(...)` depending on + * whether the user opted into accent-insensitive matching. The lowering is always + * applied so trigram comparisons are case-insensitive on both sides. + */ + private normalizeForTrigram(expr: Expression, 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}`; + } + } + + // 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 buildFuzzyRelevanceOrderBy( + query: SelectQueryBuilder, + 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(buildSimilarity(fieldRefs[0]!), sort); + } + const similarities = fieldRefs.map((ref) => buildSimilarity(ref)); + 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 44f8274c6..8e14c7ecb 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() { @@ -547,5 +547,20 @@ export class SqliteCrudDialect extends BaseCrudDialect return ob; }); } + + override buildFuzzyFilter(_fieldRef: Expression, _options: FuzzyFilterOptions): Expression { + throw createNotSupportedError('"fuzzy" filter is not supported by the "sqlite" provider'); + } + + override buildFuzzyRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _sort: SortOrder, + _mode: FuzzyFilterOptions['mode'], + _unaccent: boolean, + ): SelectQueryBuilder { + 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 42d08324a..75dd8994a 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -1026,6 +1026,7 @@ export class ZodSchemaFactory< startsWith: z.string().optional(), endsWith: z.string().optional(), contains: z.string().optional(), + fuzzy: this.makeFuzzyFilterSchema().optional(), ...(this.providerSupportsCaseSensitivity ? { mode: this.makeStringModeSchema().optional(), @@ -1053,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 = {}; @@ -1299,6 +1309,23 @@ export class ZodSchemaFactory< } } + // _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['_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)); let schemaId = `${model}OrderBy`; diff --git a/packages/server/test/openapi/baseline/rpc.baseline.yaml b/packages/server/test/openapi/baseline/rpc.baseline.yaml index 9c850e1c3..7cd84bbc6 100644 --- a/packages/server/test/openapi/baseline/rpc.baseline.yaml +++ b/packages/server/test/openapi/baseline/rpc.baseline.yaml @@ -4323,6 +4323,33 @@ components: type: string contains: type: string + fuzzy: + 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 @@ -4588,6 +4615,33 @@ components: type: string contains: type: string + fuzzy: + 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 @@ -5130,6 +5184,43 @@ components: const: asc - type: string const: desc + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false _count: anyOf: - type: string @@ -5141,6 +5232,44 @@ components: additionalProperties: false setting: $ref: "#/components/schemas/SettingOrderByWithRelationInput" + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - title + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false _count: anyOf: - type: string @@ -5202,6 +5331,44 @@ components: - sort - nulls additionalProperties: false + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - myId + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false additionalProperties: false UserWhereUniqueInputWithoutRelation: type: object @@ -6373,6 +6540,44 @@ components: $ref: "#/components/schemas/UserOrderByWithRelationInput" _max: $ref: "#/components/schemas/UserOrderByWithRelationInput" + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - myId + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false additionalProperties: false StringFilterAgg: anyOf: @@ -6477,6 +6682,33 @@ components: type: string contains: type: string + fuzzy: + 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 @@ -6828,6 +7060,44 @@ components: const: asc - type: string const: desc + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - gender + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false additionalProperties: false ProfileWhereUniqueInputWithoutRelation: type: object @@ -7427,6 +7697,44 @@ components: $ref: "#/components/schemas/ProfileOrderByWithRelationInput" _max: $ref: "#/components/schemas/ProfileOrderByWithRelationInput" + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - gender + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false additionalProperties: false IntFilterAgg: anyOf: @@ -8115,6 +8423,43 @@ components: const: asc - type: string const: desc + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false _count: anyOf: - type: string @@ -8126,6 +8471,44 @@ components: additionalProperties: false setting: $ref: "#/components/schemas/SettingOrderByWithRelationInput" + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - title + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false additionalProperties: false PostFindFirstArgs: type: object @@ -9483,6 +9866,44 @@ components: $ref: "#/components/schemas/PostOrderByWithRelationInput" _max: $ref: "#/components/schemas/PostOrderByWithRelationInput" + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - title + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false additionalProperties: false StringFilterOptionalAgg: anyOf: @@ -9591,6 +10012,33 @@ components: type: string contains: type: string + fuzzy: + 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 @@ -9995,6 +10443,43 @@ components: const: asc - type: string const: desc + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false additionalProperties: false CommentFindFirstArgs: type: object @@ -10582,6 +11067,43 @@ components: $ref: "#/components/schemas/CommentOrderByWithRelationInput" _max: $ref: "#/components/schemas/CommentOrderByWithRelationInput" + _fuzzyRelevance: + type: object + properties: + fields: + minItems: 1 + type: array + items: + type: string + enum: + - 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 + const: asc + - type: string + const: desc + required: + - fields + - search + - mode + - unaccent + - sort + additionalProperties: false additionalProperties: false CommentWhereInputWithoutRelationWithAggregation: type: object 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..7b23ce5b0 --- /dev/null +++ b/tests/e2e/orm/client-api/fuzzy-search.test.ts @@ -0,0 +1,860 @@ +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 mode 'simple' — basic English words + // --------------------------------------------------------------- + + it('finds Apple despite missing letter (Aple)', async () => { + const results = await client.flavor.findMany({ + 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: { 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: { 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: { 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: { 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 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: { search: 'creme', unaccent: true } } }, + }); + 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: { search: '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: { search: 'cafe', unaccent: true } } }, + }); + 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: { search: 'É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: { search: 'eclair', unaccent: true } } }, + }); + 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: { search: '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: { search: 'pate', unaccent: true } } }, + }); + 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: { 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: { search: '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: { search: 'creme', unaccent: true } }, + 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: { search: 'creme', unaccent: true }, 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: { search: 'creme', unaccent: true } } }, + { 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: { search: 'apple' } } }, + { name: { fuzzy: { search: '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: { search: 'apple' } } }, + name: { not: null }, + }, + }); + expect(results.length).toBeLessThan(all.length); + expect(results.every((r) => r.name !== 'Apple')).toBe(true); + }); + + // --------------------------------------------------------------- + // F. orderBy _fuzzyRelevance — single field + // --------------------------------------------------------------- + + it('orders by relevance with best match first', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Apple' } } }, + orderBy: { + _fuzzyRelevance: { 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: { search: 'creme', unaccent: true } } }, + { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + ], + }, + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true }, + }, + }); + 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 _fuzzyRelevance — multiple fields + // --------------------------------------------------------------- + + it('orders by relevance across multiple fields', async () => { + const results = await client.flavor.findMany({ + where: { + OR: [ + { name: { fuzzy: { search: 'chocolate' } } }, + { description: { fuzzy: { search: 'chocolate' } } }, + ], + }, + orderBy: { + _fuzzyRelevance: { + fields: ['name', 'description'], + search: 'chocolate', + sort: 'desc', + }, + }, + }); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]!.name).toBe('Éclair au chocolat'); + }); + + // --------------------------------------------------------------- + // H. orderBy _fuzzyRelevance with skip/take + // --------------------------------------------------------------- + + it('supports pagination with relevance ordering', async () => { + const allResults = await client.flavor.findMany({ + where: { + OR: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + ], + }, + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true }, + }, + }); + + const paged = await client.flavor.findMany({ + where: { + OR: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + ], + }, + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true }, + }, + skip: 1, + take: 1, + }); + + expect(paged).toHaveLength(1); + expect(allResults.length).toBeGreaterThan(1); + expect(paged[0]!.id).toBe(allResults[1]!.id); + }); + + // --------------------------------------------------------------- + // I. fuzzy mode 'word' — approximate substring matching (formerly fuzzyContains) + // --------------------------------------------------------------- + + it('mode "word" finds short term within longer name', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'word', search: 'choco' } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + }); + + it('mode "word" tolerates typos within description (pastryy)', async () => { + const results = await client.flavor.findMany({ + 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('mode "word" is accent-insensitive', async () => { + const results = await client.flavor.findMany({ + 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('mode "word" combined with simple fuzzy on another field', async () => { + const results = await client.flavor.findMany({ + where: { + 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('mode "word" returns nothing for unrelated term', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'word', search: 'zzzzz' } } }, + }); + expect(results).toHaveLength(0); + }); + + // --------------------------------------------------------------- + // J. Mutations with fuzzy filter + // --------------------------------------------------------------- + + it('updateMany with fuzzy mode "simple" filter', async () => { + const { count } = await client.flavor.updateMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + 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 fuzzy mode "word" filter', async () => { + const { count } = await client.flavor.updateMany({ + where: { name: { fuzzy: { mode: 'word', search: '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 mode "simple" filter', async () => { + const beforeCount = await client.flavor.count(); + const { count } = await client.flavor.deleteMany({ + where: { name: { fuzzy: { search: '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 fuzzy mode "word" filter', async () => { + const { count } = await client.flavor.deleteMany({ + where: { description: { fuzzy: { mode: 'word', search: '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: { search: 'creme', unaccent: true } } }, + _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 mode "simple" filter', async () => { + const count = await client.flavor.count({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + }); + expect(count).toBeGreaterThanOrEqual(2); + }); + + it('count with fuzzy mode "word" filter', async () => { + const count = await client.flavor.count({ + 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(); + }); + + // --------------------------------------------------------------- + // 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); + }); +}); 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 +}