Skip to content

Commit d0dd954

Browse files
ymc9claude
andcommitted
feat(orm): add @fulltext attribute and Postgres full-text search
Introduces a Prisma-style full-text search capability gated by a new field-level `@fullText` ZModel attribute. PostgreSQL only — MySQL/SQLite throw NotSupported. Mirrors the existing `@fuzzy` design. - Filter operator: `where: { title: { fts: { search, config? } } }` emits `to_tsvector(field) @@ to_tsquery(query)` (or with a `::regconfig` cast when `config` is provided; otherwise Postgres uses the database's `default_text_search_config`). - OrderBy operator: `_ftsRelevance: { fields, search, config?, sort }` emits a single `ts_rank(...)`. Multi-field combines fields with `concat_ws(' ', ...)` so AND queries match terms across fields (matches Prisma's behavior). - Type-level gating: the `fts` operator and `_ftsRelevance` orderBy appear only on String fields annotated with `@fullText` and only when the schema's provider is `postgresql`. Slicing's `'FullText'` filter kind controls availability of the runtime operator. - Cursor pagination is rejected when combined with `_ftsRelevance` (parallel to `_fuzzyRelevance`). Also refactors `buildOrderBy` to dispatch to small per-branch helpers (`applyScalarOrderBy`, `applyAggregationOrderBy`, `applyRelationOrderBy`, `applyFuzzyRelevanceOrderBy`, `applyFtsRelevanceOrderBy`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f0fa5ea commit d0dd954

14 files changed

Lines changed: 1162 additions & 160 deletions

File tree

packages/language/res/stdlib.zmodel

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,13 @@ attribute @omit()
407407
*/
408408
attribute @fuzzy() @@@targetField([StringField]) @@@once
409409

410+
/**
411+
* Marks a `String` field as full-text-searchable. Fields with this attribute can be used with the
412+
* `fts` filter operator and the `_ftsRelevance` orderBy. Full-text search is currently
413+
* supported only on the `postgresql` provider (uses `to_tsvector` / `to_tsquery` / `ts_rank`).
414+
*/
415+
attribute @fullText() @@@targetField([StringField]) @@@once
416+
410417
/**
411418
* Automatically stores the time when a record was last updated.
412419
*

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,18 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
363363
}
364364
}
365365

366+
@check('@fullText')
367+
private _checkFullText(attr: AttributeApplication, accept: ValidationAcceptor) {
368+
const zmodel = AstUtils.getContainerOfType(attr, isModel);
369+
if (!zmodel) {
370+
return;
371+
}
372+
const provider = getDataSourceProvider(zmodel);
373+
if (provider && provider !== 'postgresql') {
374+
accept('error', `\`@fullText\` is only supported for the \`postgresql\` provider`, { node: attr });
375+
}
376+
}
377+
366378
@check('@@schema')
367379
private _checkSchema(attr: AttributeApplication, accept: ValidationAcceptor) {
368380
const schemaName = getStringLiteral(attr.args[0]?.value);

packages/orm/src/client/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export const FILTER_PROPERTY_TO_KIND = {
7171
// Fuzzy search operators
7272
fuzzy: 'Fuzzy',
7373

74+
// Full-text search operators
75+
fts: 'FullText',
76+
7477
// List operators
7578
has: 'List',
7679
hasEvery: 'List',

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

Lines changed: 143 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -377,53 +377,94 @@ type FieldFilter<
377377
AllowedKinds
378378
>
379379
: // primitive
380-
AddFuzzyFilterIfSupported<
381-
Schema,
382-
Model,
383-
Field,
384-
AllowedKinds,
385-
PrimitiveFilter<
386-
GetModelFieldType<Schema, Model, Field>,
387-
ModelFieldIsOptional<Schema, Model, Field>,
388-
WithAggregations,
389-
AllowedKinds
390-
>
391-
>;
380+
GetModelFieldType<Schema, Model, Field> extends 'String'
381+
? // string — additionally consider fuzzy / full-text augmentations
382+
AddFullTextFilterIfSupported<
383+
Schema,
384+
Model,
385+
Field,
386+
AllowedKinds,
387+
AddFuzzyFilterIfSupported<
388+
Schema,
389+
Model,
390+
Field,
391+
AllowedKinds,
392+
PrimitiveFilter<
393+
GetModelFieldType<Schema, Model, Field>,
394+
ModelFieldIsOptional<Schema, Model, Field>,
395+
WithAggregations,
396+
AllowedKinds
397+
>
398+
>
399+
>
400+
: PrimitiveFilter<
401+
GetModelFieldType<Schema, Model, Field>,
402+
ModelFieldIsOptional<Schema, Model, Field>,
403+
WithAggregations,
404+
AllowedKinds
405+
>;
392406

393407
/**
394-
* Conditionally augments a primitive filter with the `fuzzy` operator when:
395-
* 1. The field's type is `String`, AND
396-
* 2. The `Fuzzy` filter kind is allowed for this field, AND
397-
* 3. The schema's provider supports fuzzy search (postgres only), AND
398-
* 4. The field is annotated with `@fuzzy` in the ZModel schema.
408+
* Conditionally augments a string-field filter with the `fuzzy` operator when:
409+
* 1. The `Fuzzy` filter kind is allowed for this field, AND
410+
* 2. The schema's provider supports fuzzy search (postgres only), AND
411+
* 3. The field is annotated with `@fuzzy` in the ZModel schema.
399412
*
400-
* Returns `Base` unchanged when any condition fails — never `Base & {}`,
401-
* since intersecting with `{}` would strip `null`/`undefined` from `Base`.
413+
* Caller is responsible for only invoking this on String-typed fields
414+
* (the gate lives in `FieldFilter`).
402415
*/
403416
type AddFuzzyFilterIfSupported<
404417
Schema extends SchemaDef,
405418
Model extends GetModels<Schema>,
406419
Field extends GetModelFields<Schema, Model>,
407420
AllowedKinds extends FilterKind,
408421
Base,
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
422+
> = 'Fuzzy' extends AllowedKinds
423+
? ProviderSupportsFuzzy<Schema> extends true
424+
? GetModelField<Schema, Model, Field>['fuzzy'] extends true
425+
? Base & {
426+
/**
427+
* Performs a fuzzy search on the string field. Only available when
428+
* the schema's provider is `postgresql` (requires `pg_trgm` extension)
429+
* and the field is annotated with `@fuzzy` in the ZModel schema.
430+
* See {@link FuzzyFilterPayload} for the full options reference.
431+
*/
432+
fuzzy?: FuzzyFilterPayload;
433+
}
434+
: Base
435+
: Base
436+
: Base;
437+
438+
/**
439+
* Conditionally augments a string-field filter with the `fts` operator when:
440+
* 1. The `FullText` filter kind is allowed for this field, AND
441+
* 2. The schema's provider supports full-text search (postgres only), AND
442+
* 3. The field is annotated with `@fullText` in the ZModel schema.
443+
*
444+
* Caller is responsible for only invoking this on String-typed fields
445+
* (the gate lives in `FieldFilter`).
446+
*/
447+
type AddFullTextFilterIfSupported<
448+
Schema extends SchemaDef,
449+
Model extends GetModels<Schema>,
450+
Field extends GetModelFields<Schema, Model>,
451+
AllowedKinds extends FilterKind,
452+
Base,
453+
> = 'FullText' extends AllowedKinds
454+
? ProviderSupportsFullText<Schema> extends true
455+
? GetModelField<Schema, Model, Field>['fullText'] extends true
456+
? Base & {
457+
/**
458+
* Performs a full-text search on the string field. Only available when
459+
* the schema's provider is `postgresql` and the field is annotated with
460+
* `@fullText` in the ZModel schema.
461+
* See {@link FullTextFilterPayload} for the full options reference.
462+
*/
463+
fts?: FullTextFilterPayload;
464+
}
425465
: Base
426-
: Base;
466+
: Base
467+
: Base;
427468

428469
type EnumFilter<
429470
Schema extends SchemaDef,
@@ -994,9 +1035,6 @@ export type FuzzyRelevanceOrderBy<Schema extends SchemaDef, Model extends GetMod
9941035
* Sorts by fuzzy search relevance using PostgreSQL `pg_trgm` similarity functions.
9951036
* Not supported on MySQL or SQLite (throws `NotSupported` at runtime).
9961037
* Cannot be combined with cursor-based pagination.
997-
*
998-
* The `_fuzzyRelevance` name is intentionally distinct from `_searchRelevance`
999-
* (reserved for future full-text-search relevance) so the two can coexist.
10001038
*/
10011039
_fuzzyRelevance?: {
10021040
/**
@@ -1025,6 +1063,66 @@ export type FuzzyRelevanceOrderBy<Schema extends SchemaDef, Model extends GetMod
10251063
};
10261064
};
10271065

1066+
/**
1067+
* String fields that have been annotated with `@fullText` and are therefore eligible
1068+
* for `_ftsRelevance` ordering.
1069+
*/
1070+
type FullTextFields<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
1071+
[Key in StringFields<Schema, Model>]: GetModelField<Schema, Model, Key>['fullText'] extends true ? Key : never;
1072+
}[StringFields<Schema, Model>];
1073+
1074+
/**
1075+
* Payload for the `fts` string filter operator. Performs full-text search using
1076+
* PostgreSQL `to_tsvector` / `to_tsquery` (postgresql provider only).
1077+
*
1078+
* Query syntax follows `to_tsquery`: callers write raw `&` (AND), `|` (OR),
1079+
* `!` (NOT), `<->` (FOLLOWED BY). Malformed queries throw at SQL execution time.
1080+
*/
1081+
export type FullTextFilterPayload = {
1082+
/**
1083+
* Search query in `to_tsquery` syntax (must be a non-empty string).
1084+
*/
1085+
search: string;
1086+
/**
1087+
* Postgres text-search configuration (e.g. `'english'`, `'simple'`). When
1088+
* omitted, the database's `default_text_search_config` setting is used —
1089+
* the SQL is emitted as `to_tsvector(field) @@ to_tsquery(query)` without
1090+
* an explicit regconfig argument.
1091+
*/
1092+
config?: string;
1093+
};
1094+
1095+
export type FtsRelevanceOrderBy<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
1096+
/**
1097+
* Sorts by full-text-search relevance using PostgreSQL `ts_rank`.
1098+
*/
1099+
_ftsRelevance?: {
1100+
/**
1101+
* String fields annotated with `@fullText` to compute relevance against (must be non-empty).
1102+
*
1103+
* When multiple fields are provided, the fields are concatenated with a
1104+
* space separator and a single `ts_rank` is computed over the combined
1105+
* document — i.e. `ts_rank(to_tsvector(concat_ws(' ', f1, f2, ...)), q)`.
1106+
* This means an AND query (e.g. `'cat & dog'`) matches rows where the
1107+
* terms appear across different fields, not just within the same field.
1108+
*/
1109+
fields: [FullTextFields<Schema, Model>, ...FullTextFields<Schema, Model>[]];
1110+
/**
1111+
* The search term to compute relevance for (in `to_tsquery` syntax).
1112+
*/
1113+
search: string;
1114+
/**
1115+
* Postgres text-search configuration. When omitted, the database's
1116+
* `default_text_search_config` setting is used.
1117+
*/
1118+
config?: string;
1119+
/**
1120+
* Sort direction.
1121+
*/
1122+
sort: SortOrder;
1123+
};
1124+
};
1125+
10281126
export type OrderBy<
10291127
Schema extends SchemaDef,
10301128
Model extends GetModels<Schema>,
@@ -1377,7 +1475,8 @@ type SortAndTakeArgs<
13771475
*/
13781476
orderBy?: OrArray<
13791477
OrderBy<Schema, Model, true, false> &
1380-
(ProviderSupportsFuzzy<Schema> extends true ? FuzzyRelevanceOrderBy<Schema, Model> : {})
1478+
(ProviderSupportsFuzzy<Schema> extends true ? FuzzyRelevanceOrderBy<Schema, Model> : {}) &
1479+
(ProviderSupportsFullText<Schema> extends true ? FtsRelevanceOrderBy<Schema, Model> : {})
13811480
>;
13821481

13831482
/**
@@ -2757,6 +2856,10 @@ type ProviderSupportsDistinct<Schema extends SchemaDef> = Schema['provider']['ty
27572856

27582857
type ProviderSupportsFuzzy<Schema extends SchemaDef> = Schema['provider']['type'] extends 'postgresql' ? true : false;
27592858

2859+
type ProviderSupportsFullText<Schema extends SchemaDef> = Schema['provider']['type'] extends 'postgresql'
2860+
? true
2861+
: false;
2862+
27602863
/**
27612864
* Extracts extended query args for a specific operation.
27622865
*/

0 commit comments

Comments
 (0)