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..75f64d6e7 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,34 @@ 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 fuzzy search relevance using PostgreSQL `similarity()` from `pg_trgm`. + * Not supported on MySQL or SQLite (throws `NotSupported` at runtime). + * Cannot be combined with cursor-based pagination. + */ + _relevance?: { + /** + * String fields to compute relevance against (must be non-empty). + */ + fields: [StringFields, ...StringFields[]]; + /** + * The search term to compute relevance for. + */ + search: string; + /** + * Sort direction. + */ + sort: SortOrder; + }; +}; + export type OrderBy< Schema extends SchemaDef, Model extends GetModels, @@ -1243,7 +1287,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..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, @@ -932,7 +938,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 +945,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 +1113,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 +1641,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..87a12631e 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 search (string fields only) + const stringFieldNames = this.getModelFields(model) + .filter(([, def]) => !def.relation && def.type === 'String') + .map(([name]) => name); + if (stringFieldNames.length > 0) { + fields['_relevance'] = z + .strictObject({ + fields: z.array(z.enum(stringFieldNames as [string, ...string[]])).min(1), + search: z.string(), + sort, + }) + .optional(); + } + return refineAtMostOneKey(z.strictObject(fields)); } 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 +}