Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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]) @@@once

/**
* 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