Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
13 changes: 1 addition & 12 deletions packages/language/src/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import { createZModelServices, type ZModelServices } from './module';
import {
getAllFields,
getDataModelAndTypeDefs,
getDataSourceProvider,
getDocument,
getLiteral,
hasAttribute,
resolveImport,
resolveTransitiveImports,
Expand Down Expand Up @@ -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<string>(provider.value);
}
17 changes: 17 additions & 0 deletions packages/language/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isConfigArrayExpr,
isDataField,
isDataModel,
isDataSource,
isEnumField,
isExpression,
isInvocationExpr,
Expand Down Expand Up @@ -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<string>(providerField.value);
}

/**
* Resolves the given reference and returns the target AST node. Throws an error if the reference is not resolved.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getAllAttributes,
getAttributeArg,
getContainingDataModel,
getDataSourceProvider,
getStringLiteral,
hasAttribute,
isAuthOrAuthMemberAccess,
Expand Down Expand Up @@ -350,6 +351,18 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
}
}

@check('@fuzzy')
private _checkFuzzy(attr: AttributeApplication, accept: ValidationAcceptor) {
const zmodel = AstUtils.getContainerOfType(attr, isModel);
if (!zmodel) {
return;
}
const provider = getDataSourceProvider(zmodel);
if (provider && provider !== 'postgresql') {
accept('error', `\`@fuzzy\` is only supported for the \`postgresql\` provider`, { node: attr });
}
}

@check('@@schema')
private _checkSchema(attr: AttributeApplication, accept: ValidationAcceptor) {
const schemaName = getStringLiteral(attr.args[0]?.value);
Expand Down
68 changes: 68 additions & 0 deletions packages/language/test/attribute-application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,74 @@ describe('Attribute application validation tests', () => {
});
});

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(
`
Expand Down
51 changes: 33 additions & 18 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,8 @@ type FieldFilter<
: // primitive
AddFuzzyFilterIfSupported<
Schema,
GetModelFieldType<Schema, Model, Field>,
Model,
Field,
AllowedKinds,
PrimitiveFilter<
GetModelFieldType<Schema, Model, Field>,
Expand All @@ -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<Schema>,
Field extends GetModelFields<Schema, Model>,
AllowedKinds extends FilterKind,
Base,
> = FieldType extends 'String'
? 'Fuzzy' extends AllowedKinds
? ProviderSupportsFuzzy<Schema> 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<Schema, Model, Field> extends 'String'
? 'Fuzzy' extends AllowedKinds
? ProviderSupportsFuzzy<Schema> extends true
? GetModelField<Schema, Model, Field>['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,
Expand Down Expand Up @@ -929,6 +936,14 @@ type StringFields<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
: never;
}[NonRelationFields<Schema, Model>];

/**
* String fields that have been annotated with `@fuzzy` and are therefore eligible
* for `_fuzzyRelevance` ordering.
*/
type FuzzyFields<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
[Key in StringFields<Schema, Model>]: GetModelField<Schema, Model, Key>['fuzzy'] extends true ? Key : never;
}[StringFields<Schema, Model>];

/**
* Payload for the `fuzzy` string filter operator. Performs a fuzzy search using
* PostgreSQL `pg_trgm` (only available when the schema's provider is `postgresql`).
Expand Down Expand Up @@ -984,12 +999,12 @@ export type FuzzyRelevanceOrderBy<Schema extends SchemaDef, Model extends GetMod
*/
_fuzzyRelevance?: {
/**
* String fields to compute relevance against (must be non-empty).
* String fields annotated with `@fuzzy` to compute relevance against (must be non-empty).
*
* When multiple fields are provided, the row's relevance score is the
* greatest per-field similarity, i.e. `GREATEST(similarity(field1, search), similarity(field2, search), ...)`.
*/
fields: [StringFields<Schema, Model>, ...StringFields<Schema, Model>[]];
fields: [FuzzyFields<Schema, Model>, ...FuzzyFields<Schema, Model>[]];
/**
* The search term to compute relevance for.
*/
Expand Down
17 changes: 14 additions & 3 deletions packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
}

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),
)
Expand Down Expand Up @@ -915,7 +915,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
return { conditions, consumedKeys };
}

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

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

if (key === 'fuzzy') {
invariant(
fieldDef?.fuzzy === true,
`field "${fieldDef?.name ?? '<unknown>'}" is not fuzzy-searchable; add the \`@fuzzy\` attribute to use the \`fuzzy\` filter`,
);
conditions.push(this.buildFuzzyFilter(fieldRef, this.normalizeFuzzyOptions(value)));
continue;
}
Expand Down Expand Up @@ -1125,6 +1129,13 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
);
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,
Expand Down
Loading
Loading