Skip to content

Commit 8ddbfde

Browse files
ymc9claude
andauthored
feat(orm): add field-level @fuzzy attribute to gate fuzzy search (#2642)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent eff4263 commit 8ddbfde

19 files changed

Lines changed: 269 additions & 652 deletions

File tree

packages/language/res/stdlib.zmodel

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,12 +394,19 @@ attribute @ignore() @@@prisma
394394
attribute @@ignore() @@@prisma
395395

396396
/**
397-
* Indicates that the field should be omitted by default when read with an ORM client. The omission can be
397+
* Indicates that the field should be omitted by default when read with an ORM client. The omission can be
398398
* overridden in options passed to create `ZenStackClient`, or at query time by explicitly passing in an
399399
* `omit` clause. The attribute is only effective for ORM query APIs, not for query-builder APIs.
400400
*/
401401
attribute @omit()
402402

403+
/**
404+
* Marks a `String` field as fuzzy-searchable. Fields with this attribute can be used with the
405+
* `fuzzy` filter operator and the `_fuzzyRelevance` orderBy. Fuzzy search is currently
406+
* supported only on the `postgresql` provider (requires `pg_trgm` extension).
407+
*/
408+
attribute @fuzzy() @@@targetField([StringField]) @@@once
409+
403410
/**
404411
* Automatically stores the time when a record was last updated.
405412
*

packages/language/src/document.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import { createZModelServices, type ZModelServices } from './module';
1717
import {
1818
getAllFields,
1919
getDataModelAndTypeDefs,
20+
getDataSourceProvider,
2021
getDocument,
21-
getLiteral,
2222
hasAttribute,
2323
resolveImport,
2424
resolveTransitiveImports,
@@ -262,14 +262,3 @@ export async function formatDocument(content: string) {
262262
return TextDocument.applyEdits(document.textDocument, edits);
263263
}
264264

265-
function getDataSourceProvider(model: Model) {
266-
const dataSource = model.declarations.find(isDataSource);
267-
if (!dataSource) {
268-
return undefined;
269-
}
270-
const provider = dataSource?.fields.find((f) => f.name === 'provider');
271-
if (!provider) {
272-
return undefined;
273-
}
274-
return getLiteral<string>(provider.value);
275-
}

packages/language/src/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isConfigArrayExpr,
1212
isDataField,
1313
isDataModel,
14+
isDataSource,
1415
isEnumField,
1516
isExpression,
1617
isInvocationExpr,
@@ -170,6 +171,22 @@ export function isDelegateModel(node: AstNode) {
170171
return isDataModel(node) && hasAttribute(node, '@@delegate');
171172
}
172173

174+
/**
175+
* Returns the datasource provider literal (e.g. `'postgresql'`) declared in the schema, or undefined
176+
* if no datasource is found or its provider is not a literal.
177+
*/
178+
export function getDataSourceProvider(model: Model) {
179+
const dataSource = model.declarations.find(isDataSource);
180+
if (!dataSource) {
181+
return undefined;
182+
}
183+
const providerField = dataSource.fields.find((f) => f.name === 'provider');
184+
if (!providerField) {
185+
return undefined;
186+
}
187+
return getLiteral<string>(providerField.value);
188+
}
189+
173190
/**
174191
* Resolves the given reference and returns the target AST node. Throws an error if the reference is not resolved.
175192
*/

packages/language/src/validators/attribute-application-validator.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
getAllAttributes,
2929
getAttributeArg,
3030
getContainingDataModel,
31+
getDataSourceProvider,
3132
getStringLiteral,
3233
hasAttribute,
3334
isAuthOrAuthMemberAccess,
@@ -350,6 +351,18 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
350351
}
351352
}
352353

354+
@check('@fuzzy')
355+
private _checkFuzzy(attr: AttributeApplication, accept: ValidationAcceptor) {
356+
const zmodel = AstUtils.getContainerOfType(attr, isModel);
357+
if (!zmodel) {
358+
return;
359+
}
360+
const provider = getDataSourceProvider(zmodel);
361+
if (provider && provider !== 'postgresql') {
362+
accept('error', `\`@fuzzy\` is only supported for the \`postgresql\` provider`, { node: attr });
363+
}
364+
}
365+
353366
@check('@@schema')
354367
private _checkSchema(attr: AttributeApplication, accept: ValidationAcceptor) {
355368
const schemaName = getStringLiteral(attr.args[0]?.value);

packages/language/test/attribute-application.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,74 @@ describe('Attribute application validation tests', () => {
431431
});
432432
});
433433

434+
describe('Field-level @fuzzy attribute', () => {
435+
it('accepts @fuzzy on a String field with postgres provider', async () => {
436+
await loadSchema(`
437+
datasource db {
438+
provider = 'postgresql'
439+
url = 'postgresql://localhost/test'
440+
}
441+
442+
model Flavor {
443+
id Int @id @default(autoincrement())
444+
name String @fuzzy
445+
description String? @fuzzy
446+
}
447+
`);
448+
});
449+
450+
it('rejects @fuzzy with sqlite provider', async () => {
451+
await loadSchemaWithError(
452+
`
453+
datasource db {
454+
provider = 'sqlite'
455+
url = 'file:./dev.db'
456+
}
457+
458+
model Flavor {
459+
id Int @id @default(autoincrement())
460+
name String @fuzzy
461+
}
462+
`,
463+
/`@fuzzy` is only supported for the `postgresql` provider/,
464+
);
465+
});
466+
467+
it('rejects @fuzzy with mysql provider', async () => {
468+
await loadSchemaWithError(
469+
`
470+
datasource db {
471+
provider = 'mysql'
472+
url = 'mysql://localhost/test'
473+
}
474+
475+
model Flavor {
476+
id Int @id @default(autoincrement())
477+
name String @fuzzy
478+
}
479+
`,
480+
/`@fuzzy` is only supported for the `postgresql` provider/,
481+
);
482+
});
483+
484+
it('rejects @fuzzy on a non-String field', async () => {
485+
await loadSchemaWithError(
486+
`
487+
datasource db {
488+
provider = 'postgresql'
489+
url = 'postgresql://localhost/test'
490+
}
491+
492+
model Flavor {
493+
id Int @id @default(autoincrement())
494+
count Int @fuzzy
495+
}
496+
`,
497+
/attribute "@fuzzy" cannot be used on this type of field/,
498+
);
499+
});
500+
});
501+
434502
it('requires relation and fk to have consistent optionality', async () => {
435503
await loadSchemaWithError(
436504
`

packages/orm/src/client/crud-types.ts

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,8 @@ type FieldFilter<
379379
: // primitive
380380
AddFuzzyFilterIfSupported<
381381
Schema,
382-
GetModelFieldType<Schema, Model, Field>,
382+
Model,
383+
Field,
383384
AllowedKinds,
384385
PrimitiveFilter<
385386
GetModelFieldType<Schema, Model, Field>,
@@ -393,30 +394,36 @@ type FieldFilter<
393394
* Conditionally augments a primitive filter with the `fuzzy` operator when:
394395
* 1. The field's type is `String`, AND
395396
* 2. The `Fuzzy` filter kind is allowed for this field, AND
396-
* 3. The schema's provider supports fuzzy search (postgres only).
397+
* 3. The schema's provider supports fuzzy search (postgres only), AND
398+
* 4. The field is annotated with `@fuzzy` in the ZModel schema.
397399
*
398400
* Returns `Base` unchanged when any condition fails — never `Base & {}`,
399401
* since intersecting with `{}` would strip `null`/`undefined` from `Base`.
400402
*/
401403
type AddFuzzyFilterIfSupported<
402404
Schema extends SchemaDef,
403-
FieldType extends string,
405+
Model extends GetModels<Schema>,
406+
Field extends GetModelFields<Schema, Model>,
404407
AllowedKinds extends FilterKind,
405408
Base,
406-
> = FieldType extends 'String'
407-
? 'Fuzzy' extends AllowedKinds
408-
? ProviderSupportsFuzzy<Schema> extends true
409-
? Base & {
410-
/**
411-
* Performs a fuzzy search on the string field. Only available when
412-
* the schema's provider is `postgresql` (uses `pg_trgm`).
413-
* See {@link FuzzyFilterPayload} for the full options reference.
414-
*/
415-
fuzzy?: FuzzyFilterPayload;
416-
}
409+
> =
410+
GetModelFieldType<Schema, Model, Field> extends 'String'
411+
? 'Fuzzy' extends AllowedKinds
412+
? ProviderSupportsFuzzy<Schema> extends true
413+
? GetModelField<Schema, Model, Field>['fuzzy'] extends true
414+
? Base & {
415+
/**
416+
* Performs a fuzzy search on the string field. Only available when
417+
* the schema's provider is `postgresql` (requires `pg_trgm` extension)
418+
* and the field is annotated with `@fuzzy` in the ZModel schema.
419+
* See {@link FuzzyFilterPayload} for the full options reference.
420+
*/
421+
fuzzy?: FuzzyFilterPayload;
422+
}
423+
: Base
424+
: Base
417425
: Base
418-
: Base
419-
: Base;
426+
: Base;
420427

421428
type EnumFilter<
422429
Schema extends SchemaDef,
@@ -930,6 +937,14 @@ type StringFields<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
930937
: never;
931938
}[NonRelationFields<Schema, Model>];
932939

940+
/**
941+
* String fields that have been annotated with `@fuzzy` and are therefore eligible
942+
* for `_fuzzyRelevance` ordering.
943+
*/
944+
type FuzzyFields<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
945+
[Key in StringFields<Schema, Model>]: GetModelField<Schema, Model, Key>['fuzzy'] extends true ? Key : never;
946+
}[StringFields<Schema, Model>];
947+
933948
/**
934949
* Payload for the `fuzzy` string filter operator. Performs a fuzzy search using
935950
* PostgreSQL `pg_trgm` (only available when the schema's provider is `postgresql`).
@@ -985,12 +1000,12 @@ export type FuzzyRelevanceOrderBy<Schema extends SchemaDef, Model extends GetMod
9851000
*/
9861001
_fuzzyRelevance?: {
9871002
/**
988-
* String fields to compute relevance against (must be non-empty).
1003+
* String fields annotated with `@fuzzy` to compute relevance against (must be non-empty).
9891004
*
9901005
* When multiple fields are provided, the row's relevance score is the
9911006
* greatest per-field similarity, i.e. `GREATEST(similarity(field1, search), similarity(field2, search), ...)`.
9921007
*/
993-
fields: [StringFields<Schema, Model>, ...StringFields<Schema, Model>[]];
1008+
fields: [FuzzyFields<Schema, Model>, ...FuzzyFields<Schema, Model>[]];
9941009
/**
9951010
* The search term to compute relevance for.
9961011
*/

packages/orm/src/client/crud/dialects/base-dialect.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
600600
}
601601

602602
return match(fieldDef.type as BuiltinType)
603-
.with('String', () => this.buildStringFilter(fieldRef, payload))
603+
.with('String', () => this.buildStringFilter(fieldRef, payload, fieldDef))
604604
.with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) =>
605605
this.buildNumberFilter(fieldRef, type, payload),
606606
)
@@ -915,7 +915,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
915915
return { conditions, consumedKeys };
916916
}
917917

918-
private buildStringFilter(fieldRef: Expression<any>, payload: StringFilter<true, boolean>) {
918+
private buildStringFilter(fieldRef: Expression<any>, payload: StringFilter<true, boolean>, fieldDef?: FieldDef) {
919919
let mode: 'default' | 'insensitive' | undefined;
920920
if (payload && typeof payload === 'object' && 'mode' in payload) {
921921
mode = payload.mode;
@@ -926,7 +926,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
926926
payload,
927927
mode === 'insensitive' ? this.eb.fn('lower', [fieldRef]) : fieldRef,
928928
(value) => this.prepStringCasing(this.eb, value, mode),
929-
(value) => this.buildStringFilter(fieldRef, value as StringFilter<true, boolean>),
929+
(value) => this.buildStringFilter(fieldRef, value as StringFilter<true, boolean>, fieldDef),
930930
);
931931

932932
if (payload && typeof payload === 'object') {
@@ -940,6 +940,10 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
940940
}
941941

942942
if (key === 'fuzzy') {
943+
invariant(
944+
fieldDef?.fuzzy === true,
945+
`field "${fieldDef?.name ?? '<unknown>'}" is not fuzzy-searchable; add the \`@fuzzy\` attribute to use the \`fuzzy\` filter`,
946+
);
943947
conditions.push(this.buildFuzzyFilter(fieldRef, this.normalizeFuzzyOptions(value)));
944948
continue;
945949
}
@@ -1125,6 +1129,13 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
11251129
);
11261130
const unaccent = value.unaccent ?? false;
11271131
invariant(typeof unaccent === 'boolean', '_fuzzyRelevance.unaccent must be a boolean');
1132+
for (const fieldName of value.fields as string[]) {
1133+
const fieldDef = requireField(this.schema, model, fieldName);
1134+
invariant(
1135+
fieldDef.fuzzy === true,
1136+
`field "${fieldName}" is not fuzzy-searchable; add the \`@fuzzy\` attribute to use it in \`_fuzzyRelevance\``,
1137+
);
1138+
}
11281139
const fieldRefs = value.fields.map((f: string) => buildFieldRef(model, f, modelAlias));
11291140
result = this.buildFuzzyRelevanceOrderBy(
11301141
result,

0 commit comments

Comments
 (0)