From 8f70f17d945d561938423bf2486644fcbb189cc5 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 3 May 2026 20:45:40 -0700 Subject: [PATCH 1/4] feat(orm): add field-level @fuzzy attribute to gate fuzzy search Adds a new ZModel field-level `@fuzzy` attribute that opts a `String` field into fuzzy search. Without `@fuzzy`, the `fuzzy` filter operator and `_fuzzyRelevance` orderBy are unavailable both at the type level and at runtime. - Language: declare `@fuzzy` in stdlib (StringField only, @@@once) - Validator: enforce postgres-only via shared `getDataSourceProvider` helper - Schema: surface `fuzzy?: boolean` on `FieldDef`; generator emits it - ORM types: gate `fuzzy:` filter and `_fuzzyRelevance.fields` to opted-in fields - Zod factory: thread `withFuzzy` through string filter; filter `_fuzzyRelevance` enum - Runtime: throw `InvalidInputError` when fuzzy is used on non-`@fuzzy` fields - Tests: dedicated `fuzzy-search` test schema; validator coverage for postgres/sqlite/mysql/non-String Breaking: existing schemas using fuzzy search must annotate fields with `@fuzzy` to retain access. Fuzzy search shipped only one PR ago (#2573). Co-Authored-By: Claude Sonnet 4.6 --- packages/language/res/stdlib.zmodel | 9 ++- packages/language/src/document.ts | 13 +--- packages/language/src/utils.ts | 17 +++++ .../attribute-application-validator.ts | 13 ++++ .../test/attribute-application.test.ts | 68 +++++++++++++++++++ packages/orm/src/client/crud-types.ts | 51 +++++++++----- .../src/client/crud/dialects/base-dialect.ts | 17 ++++- packages/orm/src/client/zod/factory.ts | 26 ++++--- packages/schema/src/schema.ts | 1 + packages/sdk/src/ts-schema-generator.ts | 4 ++ tests/e2e/orm/client-api/fuzzy-search.test.ts | 62 ++++++++--------- 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 -- tests/e2e/orm/schemas/fuzzy-search/index.ts | 1 + tests/e2e/orm/schemas/fuzzy-search/schema.ts | 51 ++++++++++++++ .../orm/schemas/fuzzy-search/schema.zmodel | 11 +++ 18 files changed, 268 insertions(+), 129 deletions(-) create mode 100644 tests/e2e/orm/schemas/fuzzy-search/index.ts create mode 100644 tests/e2e/orm/schemas/fuzzy-search/schema.ts create mode 100644 tests/e2e/orm/schemas/fuzzy-search/schema.zmodel diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index cb604c74a..f1d9de37c 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -394,12 +394,19 @@ attribute @ignore() @@@prisma attribute @@ignore() @@@prisma /** - * Indicates that the field should be omitted by default when read with an ORM client. The omission can be + * Indicates that the field should be omitted by default when read with an ORM client. The omission can be * overridden in options passed to create `ZenStackClient`, or at query time by explicitly passing in an * `omit` clause. The attribute is only effective for ORM query APIs, not for query-builder APIs. */ attribute @omit() +/** + * Marks a `String` field as fuzzy-searchable. Fields with this attribute can be used with the + * `fuzzy` filter operator and the `_fuzzyRelevance` orderBy. Fuzzy search is currently + * supported only on the `postgresql` provider (requires `pg_trgm` extension). + */ +attribute @fuzzy() @@@targetField([StringField]) + /** * Automatically stores the time when a record was last updated. * diff --git a/packages/language/src/document.ts b/packages/language/src/document.ts index 2d497d796..87fc48564 100644 --- a/packages/language/src/document.ts +++ b/packages/language/src/document.ts @@ -17,8 +17,8 @@ import { createZModelServices, type ZModelServices } from './module'; import { getAllFields, getDataModelAndTypeDefs, + getDataSourceProvider, getDocument, - getLiteral, hasAttribute, resolveImport, resolveTransitiveImports, @@ -262,14 +262,3 @@ export async function formatDocument(content: string) { return TextDocument.applyEdits(document.textDocument, edits); } -function getDataSourceProvider(model: Model) { - const dataSource = model.declarations.find(isDataSource); - if (!dataSource) { - return undefined; - } - const provider = dataSource?.fields.find((f) => f.name === 'provider'); - if (!provider) { - return undefined; - } - return getLiteral(provider.value); -} diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index c73522afc..88897a49f 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -11,6 +11,7 @@ import { isConfigArrayExpr, isDataField, isDataModel, + isDataSource, isEnumField, isExpression, isInvocationExpr, @@ -170,6 +171,22 @@ export function isDelegateModel(node: AstNode) { return isDataModel(node) && hasAttribute(node, '@@delegate'); } +/** + * Returns the datasource provider literal (e.g. `'postgresql'`) declared in the schema, or undefined + * if no datasource is found or its provider is not a literal. + */ +export function getDataSourceProvider(model: Model) { + const dataSource = model.declarations.find(isDataSource); + if (!dataSource) { + return undefined; + } + const providerField = dataSource.fields.find((f) => f.name === 'provider'); + if (!providerField) { + return undefined; + } + return getLiteral(providerField.value); +} + /** * Resolves the given reference and returns the target AST node. Throws an error if the reference is not resolved. */ diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 28983e822..0e06d6a38 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -28,6 +28,7 @@ import { getAllAttributes, getAttributeArg, getContainingDataModel, + getDataSourceProvider, getStringLiteral, hasAttribute, isAuthOrAuthMemberAccess, @@ -350,6 +351,18 @@ export default class AttributeApplicationValidator implements AstValidator { }); }); + describe('Field-level @fuzzy attribute', () => { + it('accepts @fuzzy on a String field with postgres provider', async () => { + await loadSchema(` + datasource db { + provider = 'postgresql' + url = 'postgresql://localhost/test' + } + + model Flavor { + id Int @id @default(autoincrement()) + name String @fuzzy + description String? @fuzzy + } + `); + }); + + it('rejects @fuzzy with sqlite provider', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model Flavor { + id Int @id @default(autoincrement()) + name String @fuzzy + } + `, + /`@fuzzy` is only supported for the `postgresql` provider/, + ); + }); + + it('rejects @fuzzy with mysql provider', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'mysql' + url = 'mysql://localhost/test' + } + + model Flavor { + id Int @id @default(autoincrement()) + name String @fuzzy + } + `, + /`@fuzzy` is only supported for the `postgresql` provider/, + ); + }); + + it('rejects @fuzzy on a non-String field', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = 'postgresql://localhost/test' + } + + model Flavor { + id Int @id @default(autoincrement()) + count Int @fuzzy + } + `, + /attribute "@fuzzy" cannot be used on this type of field/, + ); + }); + }); + it('requires relation and fk to have consistent optionality', async () => { await loadSchemaWithError( ` diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index b5981c797..453bd0908 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -378,7 +378,8 @@ type FieldFilter< : // primitive AddFuzzyFilterIfSupported< Schema, - GetModelFieldType, + Model, + Field, AllowedKinds, PrimitiveFilter< GetModelFieldType, @@ -392,30 +393,36 @@ type FieldFilter< * 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). + * 3. The schema's provider supports fuzzy search (postgres only), AND + * 4. The field is annotated with `@fuzzy` in the ZModel schema. * * Returns `Base` unchanged when any condition fails — never `Base & {}`, * since intersecting with `{}` would strip `null`/`undefined` from `Base`. */ type AddFuzzyFilterIfSupported< Schema extends SchemaDef, - FieldType extends string, + Model extends GetModels, + Field extends GetModelFields, 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; - } +> = + GetModelFieldType extends 'String' + ? 'Fuzzy' extends AllowedKinds + ? ProviderSupportsFuzzy extends true + ? GetModelField['fuzzy'] extends true + ? Base & { + /** + * Performs a fuzzy search on the string field. Only available when + * the schema's provider is `postgresql` (requires `pg_trgm` extension) + * and the field is annotated with `@fuzzy` in the ZModel schema. + * See {@link FuzzyFilterPayload} for the full options reference. + */ + fuzzy?: FuzzyFilterPayload; + } + : Base + : Base : Base - : Base - : Base; + : Base; type EnumFilter< Schema extends SchemaDef, @@ -929,6 +936,14 @@ type StringFields> = { : never; }[NonRelationFields]; +/** + * String fields that have been annotated with `@fuzzy` and are therefore eligible + * for `_fuzzyRelevance` ordering. + */ +type FuzzyFields> = { + [Key in StringFields]: GetModelField['fuzzy'] extends true ? Key : never; +}[StringFields]; + /** * Payload for the `fuzzy` string filter operator. Performs a fuzzy search using * PostgreSQL `pg_trgm` (only available when the schema's provider is `postgresql`). @@ -984,12 +999,12 @@ export type FuzzyRelevanceOrderBy, ...StringFields[]]; + fields: [FuzzyFields, ...FuzzyFields[]]; /** * 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 a9d4d2fcb..004a12a05 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -600,7 +600,7 @@ export abstract class BaseCrudDialect { } return match(fieldDef.type as BuiltinType) - .with('String', () => this.buildStringFilter(fieldRef, payload)) + .with('String', () => this.buildStringFilter(fieldRef, payload, fieldDef)) .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => this.buildNumberFilter(fieldRef, type, payload), ) @@ -915,7 +915,7 @@ export abstract class BaseCrudDialect { return { conditions, consumedKeys }; } - private buildStringFilter(fieldRef: Expression, payload: StringFilter) { + private buildStringFilter(fieldRef: Expression, payload: StringFilter, fieldDef?: FieldDef) { let mode: 'default' | 'insensitive' | undefined; if (payload && typeof payload === 'object' && 'mode' in payload) { mode = payload.mode; @@ -926,7 +926,7 @@ export abstract class BaseCrudDialect { payload, mode === 'insensitive' ? this.eb.fn('lower', [fieldRef]) : fieldRef, (value) => this.prepStringCasing(this.eb, value, mode), - (value) => this.buildStringFilter(fieldRef, value as StringFilter), + (value) => this.buildStringFilter(fieldRef, value as StringFilter, fieldDef), ); if (payload && typeof payload === 'object') { @@ -940,6 +940,10 @@ export abstract class BaseCrudDialect { } if (key === 'fuzzy') { + invariant( + fieldDef?.fuzzy === true, + `field "${fieldDef?.name ?? ''}" is not fuzzy-searchable; add the \`@fuzzy\` attribute to use the \`fuzzy\` filter`, + ); conditions.push(this.buildFuzzyFilter(fieldRef, this.normalizeFuzzyOptions(value))); continue; } @@ -1125,6 +1129,13 @@ export abstract class BaseCrudDialect { ); const unaccent = value.unaccent ?? false; invariant(typeof unaccent === 'boolean', '_fuzzyRelevance.unaccent must be a boolean'); + for (const fieldName of value.fields as string[]) { + const fieldDef = requireField(this.schema, model, fieldName); + invariant( + fieldDef.fuzzy === true, + `field "${fieldName}" is not fuzzy-searchable; add the \`@fuzzy\` attribute to use it in \`_fuzzyRelevance\``, + ); + } const fieldRefs = value.fields.map((f: string) => buildFieldRef(model, f, modelAlias)); result = this.buildFuzzyRelevanceOrderBy( result, diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index fb8ed027c..8827b7987 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -505,6 +505,7 @@ export class ZodSchemaFactory< !!fieldDef.optional, withAggregations, allowedFilterKinds, + !!fieldDef.fuzzy, ); } } @@ -792,9 +793,12 @@ export class ZodSchemaFactory< optional: boolean, withAggregations: boolean, allowedFilterKinds: string[] | undefined, + withFuzzy = false, ) { return match(type) - .with('String', () => this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds)) + .with('String', () => + this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds, withFuzzy), + ) .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => this.makeNumberFilterSchema(type, optional, withAggregations, allowedFilterKinds), ) @@ -1012,11 +1016,12 @@ export class ZodSchemaFactory< optional: boolean, withAggregations: boolean, allowedFilterKinds: string[] | undefined, + withFuzzy = false, ): ZodType { const baseComponents = this.makeCommonPrimitiveFilterComponents( z.string(), optional, - () => z.lazy(() => this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds)), + () => z.lazy(() => this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds, withFuzzy)), undefined, withAggregations ? ['_count', '_min', '_max'] : undefined, allowedFilterKinds, @@ -1026,7 +1031,7 @@ export class ZodSchemaFactory< startsWith: z.string().optional(), endsWith: z.string().optional(), contains: z.string().optional(), - ...(this.providerSupportsFuzzySearch + ...(withFuzzy && this.providerSupportsFuzzySearch ? { fuzzy: this.makeFuzzyFilterSchema().optional(), } @@ -1047,8 +1052,9 @@ export class ZodSchemaFactory< }; const schema = this.createUnionFilterSchema(z.string(), optional, allComponents, allowedFilterKinds); + const fuzzySuffix = withFuzzy ? 'Fuzzy' : ''; this.registerSchema( - `StringFilter${this.filterSchemaSuffix({ optional, allowedFilterKinds, withAggregations })}`, + `StringFilter${this.filterSchemaSuffix({ optional, allowedFilterKinds, withAggregations })}${fuzzySuffix}`, schema, ); return schema; @@ -1313,16 +1319,16 @@ export class ZodSchemaFactory< } } - // _fuzzyRelevance ordering for fuzzy search (string fields only, postgres only). - // Distinct from a future `_searchRelevance` for full-text search. + // _fuzzyRelevance ordering for fuzzy search — only fields annotated with `@fuzzy` + // (postgres only). Distinct from a future `_searchRelevance` for full-text search. if (this.providerSupportsFuzzySearch) { - const stringFieldNames = this.getModelFields(model) - .filter(([, def]) => !def.relation && def.type === 'String') + const fuzzyFieldNames = this.getModelFields(model) + .filter(([, def]) => !def.relation && def.type === 'String' && def.fuzzy === true) .map(([name]) => name); - if (stringFieldNames.length > 0) { + if (fuzzyFieldNames.length > 0) { fields['_fuzzyRelevance'] = z .strictObject({ - fields: z.array(z.enum(stringFieldNames as [string, ...string[]])).min(1), + fields: z.array(z.enum(fuzzyFieldNames as [string, ...string[]])).min(1), search: z.string(), mode: z .union([z.literal('simple'), z.literal('word'), z.literal('strictWord')]) diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 2475991ce..c0cf9aaa3 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -76,6 +76,7 @@ export type FieldDef = { attributes?: readonly AttributeApplication[]; default?: FieldDefault; omit?: boolean; + fuzzy?: boolean; relation?: RelationInfo; foreignKeyFor?: readonly string[]; computed?: boolean; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 03eeafdbd..3674d5712 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -626,6 +626,10 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('omit', ts.factory.createTrue())); } + if (hasAttribute(field, '@fuzzy')) { + objectFields.push(ts.factory.createPropertyAssignment('fuzzy', ts.factory.createTrue())); + } + // originModel if ( contextModel && diff --git a/tests/e2e/orm/client-api/fuzzy-search.test.ts b/tests/e2e/orm/client-api/fuzzy-search.test.ts index f5b1ef822..2e383a992 100644 --- a/tests/e2e/orm/client-api/fuzzy-search.test.ts +++ b/tests/e2e/orm/client-api/fuzzy-search.test.ts @@ -1,14 +1,9 @@ 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'; - -// 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 }; -}; +import { schema } from '../schemas/fuzzy-search'; + +type Schema = typeof schema; const provider = getTestDbProvider(); describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { @@ -213,10 +208,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('OR with two fuzzy terms', async () => { const results = await client.flavor.findMany({ where: { - OR: [ - { name: { fuzzy: { search: 'apple' } } }, - { name: { fuzzy: { search: 'banana' } } }, - ], + OR: [{ name: { fuzzy: { search: 'apple' } } }, { name: { fuzzy: { search: 'banana' } } }], }, }); const names = results.map((r) => r.name); @@ -277,10 +269,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { it('orders by relevance across multiple fields', async () => { const results = await client.flavor.findMany({ where: { - OR: [ - { name: { fuzzy: { search: 'chocolate' } } }, - { description: { fuzzy: { search: 'chocolate' } } }, - ], + OR: [{ name: { fuzzy: { search: 'chocolate' } } }, { description: { fuzzy: { search: 'chocolate' } } }], }, orderBy: { _fuzzyRelevance: { @@ -678,17 +667,11 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { const asc = await client.flavor.findMany({ where: { name: { equals: 'Mango' } }, - orderBy: [ - { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, - { id: 'asc' }, - ], + 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' }, - ], + orderBy: [{ _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, { id: 'desc' }], }); expect(asc.map((r) => r.id)).toEqual([...ids].sort((a, b) => a - b)); @@ -707,18 +690,12 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { const page1 = await client.flavor.findMany({ where: { name: { equals: 'Mango' } }, - orderBy: [ - { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, - { id: 'asc' }, - ], + 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' }, - ], + orderBy: [{ _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, { id: 'asc' }], skip: 2, take: 2, }); @@ -840,6 +817,27 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { expect(strict[0]!.id).toBe(wordBoundary.id); }); + // --------------------------------------------------------------- + // E. @fuzzy attribute gating + // --------------------------------------------------------------- + + it('rejects fuzzy filter on a field without @fuzzy', async () => { + await expect( + client.flavor.findMany({ + // 'notes' has no @fuzzy attribute on the model + where: { notes: { fuzzy: { search: 'anything' } } } as any, + }), + ).rejects.toThrow(/not fuzzy-searchable/); + }); + + it('rejects _fuzzyRelevance ordering against a field without @fuzzy', async () => { + await expect( + client.flavor.findMany({ + orderBy: { _fuzzyRelevance: { fields: ['notes'], search: 'anything', sort: 'desc' } } as any, + }), + ).rejects.toThrow(/not fuzzy-searchable/); + }); + 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' } }); diff --git a/tests/e2e/orm/schemas/basic/input.ts b/tests/e2e/orm/schemas/basic/input.ts index 4b2e7c67b..f94cafcf4 100644 --- a/tests/e2e/orm/schemas/basic/input.ts +++ b/tests/e2e/orm/schemas/basic/input.ts @@ -133,24 +133,3 @@ export type PlainCheckedCreateInput = $CheckedCreateInput<$Schema, "Plain">; export type PlainUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Plain">; export type PlainCheckedUpdateInput = $CheckedUpdateInput<$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 08e87f6ed..39bd52fdf 100644 --- a/tests/e2e/orm/schemas/basic/models.ts +++ b/tests/e2e/orm/schemas/basic/models.ts @@ -12,7 +12,6 @@ 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 1de0f8c12..39f85eef7 100644 --- a/tests/e2e/orm/schemas/basic/schema.ts +++ b/tests/e2e/orm/schemas/basic/schema.ts @@ -271,31 +271,6 @@ 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 2c796727e..9b2898bb1 100644 --- a/tests/e2e/orm/schemas/basic/schema.zmodel +++ b/tests/e2e/orm/schemas/basic/schema.zmodel @@ -69,9 +69,3 @@ model Plain { id Int @id @default(autoincrement()) value Int } - -model Flavor { - id Int @id @default(autoincrement()) - name String? - description String -} diff --git a/tests/e2e/orm/schemas/fuzzy-search/index.ts b/tests/e2e/orm/schemas/fuzzy-search/index.ts new file mode 100644 index 000000000..9aca4acd4 --- /dev/null +++ b/tests/e2e/orm/schemas/fuzzy-search/index.ts @@ -0,0 +1 @@ +export { schema } from './schema'; diff --git a/tests/e2e/orm/schemas/fuzzy-search/schema.ts b/tests/e2e/orm/schemas/fuzzy-search/schema.ts new file mode 100644 index 000000000..b9b7bbe98 --- /dev/null +++ b/tests/e2e/orm/schemas/fuzzy-search/schema.ts @@ -0,0 +1,51 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "postgresql" + } as const; + models = { + 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, + fuzzy: true, + attributes: [{ name: "@fuzzy" }] as readonly AttributeApplication[] + }, + description: { + name: "description", + type: "String", + fuzzy: true, + attributes: [{ name: "@fuzzy" }] as readonly AttributeApplication[] + }, + notes: { + name: "notes", + type: "String", + optional: true + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/e2e/orm/schemas/fuzzy-search/schema.zmodel b/tests/e2e/orm/schemas/fuzzy-search/schema.zmodel new file mode 100644 index 000000000..83c1b689a --- /dev/null +++ b/tests/e2e/orm/schemas/fuzzy-search/schema.zmodel @@ -0,0 +1,11 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Flavor { + id Int @id @default(autoincrement()) + name String? @fuzzy + description String @fuzzy + notes String? // not fuzzy-searchable +} From 669d8704b3971f8286e0ce95c4bb1b1f6bca171f Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 3 May 2026 20:58:57 -0700 Subject: [PATCH 2/4] fix: address PR comments --- packages/language/res/stdlib.zmodel | 2 +- tests/e2e/orm/client-api/fuzzy-search.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index f1d9de37c..56f2789ee 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -405,7 +405,7 @@ attribute @omit() * `fuzzy` filter operator and the `_fuzzyRelevance` orderBy. Fuzzy search is currently * supported only on the `postgresql` provider (requires `pg_trgm` extension). */ -attribute @fuzzy() @@@targetField([StringField]) +attribute @fuzzy() @@@targetField([StringField]) @@@once /** * Automatically stores the time when a record was last updated. diff --git a/tests/e2e/orm/client-api/fuzzy-search.test.ts b/tests/e2e/orm/client-api/fuzzy-search.test.ts index 2e383a992..0182eb6ce 100644 --- a/tests/e2e/orm/client-api/fuzzy-search.test.ts +++ b/tests/e2e/orm/client-api/fuzzy-search.test.ts @@ -818,7 +818,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { }); // --------------------------------------------------------------- - // E. @fuzzy attribute gating + // R. @fuzzy attribute gating // --------------------------------------------------------------- it('rejects fuzzy filter on a field without @fuzzy', async () => { From 08ac228d82f7bcd8ee276b94a68adfb8917d4e5a Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 5 May 2026 11:03:54 -0700 Subject: [PATCH 3/4] chore: regenerate openapi rpc baseline Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test/openapi/baseline/rpc.baseline.yaml | 522 ------------------ 1 file changed, 522 deletions(-) diff --git a/packages/server/test/openapi/baseline/rpc.baseline.yaml b/packages/server/test/openapi/baseline/rpc.baseline.yaml index 7cd84bbc6..9c850e1c3 100644 --- a/packages/server/test/openapi/baseline/rpc.baseline.yaml +++ b/packages/server/test/openapi/baseline/rpc.baseline.yaml @@ -4323,33 +4323,6 @@ 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 @@ -4615,33 +4588,6 @@ 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 @@ -5184,43 +5130,6 @@ 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 @@ -5232,44 +5141,6 @@ 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 @@ -5331,44 +5202,6 @@ 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 @@ -6540,44 +6373,6 @@ 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: @@ -6682,33 +6477,6 @@ 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 @@ -7060,44 +6828,6 @@ 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 @@ -7697,44 +7427,6 @@ 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: @@ -8423,43 +8115,6 @@ 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 @@ -8471,44 +8126,6 @@ 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 @@ -9866,44 +9483,6 @@ 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: @@ -10012,33 +9591,6 @@ 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 @@ -10443,43 +9995,6 @@ 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 @@ -11067,43 +10582,6 @@ 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 From e853fbde58bd004bf5cfb52460842d09b90db0f2 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 5 May 2026 11:52:54 -0700 Subject: [PATCH 4/4] fix: update tests --- tests/e2e/orm/client-api/fuzzy-search.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/orm/client-api/fuzzy-search.test.ts b/tests/e2e/orm/client-api/fuzzy-search.test.ts index 0182eb6ce..769067a50 100644 --- a/tests/e2e/orm/client-api/fuzzy-search.test.ts +++ b/tests/e2e/orm/client-api/fuzzy-search.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '@zenstackhq/orm'; import { createTestClient, getTestDbProvider } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { schema } from '../schemas/fuzzy-search'; type Schema = typeof schema; @@ -827,7 +827,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { // 'notes' has no @fuzzy attribute on the model where: { notes: { fuzzy: { search: 'anything' } } } as any, }), - ).rejects.toThrow(/not fuzzy-searchable/); + ).rejects.toThrow('Unrecognized key'); }); it('rejects _fuzzyRelevance ordering against a field without @fuzzy', async () => { @@ -835,7 +835,7 @@ describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { client.flavor.findMany({ orderBy: { _fuzzyRelevance: { fields: ['notes'], search: 'anything', sort: 'desc' } } as any, }), - ).rejects.toThrow(/not fuzzy-searchable/); + ).rejects.toThrow('expected one of "name"|"description"'); }); it('unaccent toggles relevance scoring for ascii searches against accented names', async () => {