diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 56f2789ee..761e577a4 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -407,6 +407,13 @@ attribute @omit() */ attribute @fuzzy() @@@targetField([StringField]) @@@once +/** + * Marks a `String` field as full-text-searchable. Fields with this attribute can be used with the + * `fts` filter operator and the `_ftsRelevance` orderBy. Full-text search is currently + * supported only on the `postgresql` provider (uses `to_tsvector` / `to_tsquery` / `ts_rank`). + */ +attribute @fullText() @@@targetField([StringField]) @@@once + /** * Automatically stores the time when a record was last updated. * diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 0e06d6a38..b490e20d4 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -363,6 +363,18 @@ export default class AttributeApplicationValidator implements AstValidator : // primitive - AddFuzzyFilterIfSupported< - Schema, - Model, - Field, - AllowedKinds, - PrimitiveFilter< - GetModelFieldType, - ModelFieldIsOptional, - WithAggregations, - AllowedKinds - > - >; + GetModelFieldType extends 'String' + ? // string — additionally consider fuzzy / full-text augmentations + AddFullTextFilterIfSupported< + Schema, + Model, + Field, + AllowedKinds, + AddFuzzyFilterIfSupported< + Schema, + Model, + Field, + AllowedKinds, + PrimitiveFilter< + GetModelFieldType, + ModelFieldIsOptional, + WithAggregations, + AllowedKinds + > + > + > + : PrimitiveFilter< + GetModelFieldType, + ModelFieldIsOptional, + WithAggregations, + AllowedKinds + >; /** - * 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), AND - * 4. The field is annotated with `@fuzzy` in the ZModel schema. + * Conditionally augments a string-field filter with the `fuzzy` operator when: + * 1. The `Fuzzy` filter kind is allowed for this field, AND + * 2. The schema's provider supports fuzzy search (postgres only), AND + * 3. 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`. + * Caller is responsible for only invoking this on String-typed fields + * (the gate lives in `FieldFilter`). */ type AddFuzzyFilterIfSupported< Schema extends SchemaDef, @@ -406,24 +419,52 @@ type AddFuzzyFilterIfSupported< Field extends GetModelFields, AllowedKinds extends FilterKind, Base, -> = - 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 +> = '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; + +/** + * Conditionally augments a string-field filter with the `fts` operator when: + * 1. The `FullText` filter kind is allowed for this field, AND + * 2. The schema's provider supports full-text search (postgres only), AND + * 3. The field is annotated with `@fullText` in the ZModel schema. + * + * Caller is responsible for only invoking this on String-typed fields + * (the gate lives in `FieldFilter`). + */ +type AddFullTextFilterIfSupported< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, + AllowedKinds extends FilterKind, + Base, +> = 'FullText' extends AllowedKinds + ? ProviderSupportsFullText extends true + ? GetModelField['fullText'] extends true + ? Base & { + /** + * Performs a full-text search on the string field. Only available when + * the schema's provider is `postgresql` and the field is annotated with + * `@fullText` in the ZModel schema. + * See {@link FullTextFilterPayload} for the full options reference. + */ + fts?: FullTextFilterPayload; + } : Base - : Base; + : Base + : Base; type EnumFilter< Schema extends SchemaDef, @@ -994,9 +1035,6 @@ export type FuzzyRelevanceOrderBy> = { + [Key in StringFields]: GetModelField['fullText'] extends true ? Key : never; +}[StringFields]; + +/** + * Payload for the `fts` string filter operator. Performs full-text search using + * PostgreSQL `to_tsvector` / `to_tsquery` (postgresql provider only). + * + * Query syntax follows `to_tsquery`: callers write raw `&` (AND), `|` (OR), + * `!` (NOT), `<->` (FOLLOWED BY). Malformed queries throw at SQL execution time. + */ +export type FullTextFilterPayload = { + /** + * Search query in `to_tsquery` syntax (must be a non-empty string). + */ + search: string; + /** + * Postgres text-search configuration (e.g. `'english'`, `'simple'`). When + * omitted, the database's `default_text_search_config` setting is used — + * the SQL is emitted as `to_tsvector(field) @@ to_tsquery(query)` without + * an explicit regconfig argument. + */ + config?: string; +}; + +export type FtsRelevanceOrderBy> = { + /** + * Sorts by full-text-search relevance using PostgreSQL `ts_rank`. + */ + _ftsRelevance?: { + /** + * String fields annotated with `@fullText` to compute relevance against (must be non-empty). + * + * When multiple fields are provided, the fields are concatenated with a + * space separator and a single `ts_rank` is computed over the combined + * document — i.e. `ts_rank(to_tsvector(concat_ws(' ', f1, f2, ...)), q)`. + * This means an AND query (e.g. `'cat & dog'`) matches rows where the + * terms appear across different fields, not just within the same field. + */ + fields: [FullTextFields, ...FullTextFields[]]; + /** + * The search term to compute relevance for (in `to_tsquery` syntax). + */ + search: string; + /** + * Postgres text-search configuration. When omitted, the database's + * `default_text_search_config` setting is used. + */ + config?: string; + /** + * Sort direction. + */ + sort: SortOrder; + }; +}; + export type OrderBy< Schema extends SchemaDef, Model extends GetModels, @@ -1377,7 +1475,8 @@ type SortAndTakeArgs< */ orderBy?: OrArray< OrderBy & - (ProviderSupportsFuzzy extends true ? FuzzyRelevanceOrderBy : {}) + (ProviderSupportsFuzzy extends true ? FuzzyRelevanceOrderBy : {}) & + (ProviderSupportsFullText extends true ? FtsRelevanceOrderBy : {}) >; /** @@ -2757,6 +2856,10 @@ type ProviderSupportsDistinct = Schema['provider']['ty type ProviderSupportsFuzzy = Schema['provider']['type'] extends 'postgresql' ? true : false; +type ProviderSupportsFullText = Schema['provider']['type'] extends 'postgresql' + ? true + : false; + /** * Extracts extended query args for a specific operation. */ diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 004a12a05..12411beaa 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -166,13 +166,21 @@ export abstract class BaseCrudDialect { result = this.buildOrderBy(result, model, modelAlias, effectiveOrderBy, negateOrderBy, take); if (args.cursor) { - if ( - effectiveOrderBy && - enumerate(effectiveOrderBy).some((ob: any) => typeof ob === 'object' && '_fuzzyRelevance' in ob) - ) { - throw createNotSupportedError( - 'cursor pagination cannot be combined with "_fuzzyRelevance" ordering', - ); + if (effectiveOrderBy) { + const offendingKey = enumerate(effectiveOrderBy) + .map((ob: any) => { + if (typeof ob !== 'object' || ob === null) return undefined; + if ('_fuzzyRelevance' in ob) return '_fuzzyRelevance'; + if ('_ftsRelevance' in ob) return '_ftsRelevance'; + return undefined; + }) + .find((k) => k !== undefined); + if (offendingKey) { + // TODO: revisit this limitation + throw createNotSupportedError( + `cursor pagination cannot be combined with "${offendingKey}" ordering`, + ); + } } result = this.buildCursorFilter( model, @@ -948,6 +956,15 @@ export abstract class BaseCrudDialect { continue; } + if (key === 'fts') { + invariant( + fieldDef?.fullText === true, + `field "${fieldDef?.name ?? ''}" is not full-text-searchable; add the \`@fullText\` attribute to use the \`fts\` filter`, + ); + conditions.push(this.buildFullTextFilter(fieldRef, value)); + continue; + } + invariant(typeof value === 'string', `${key} value must be a string`); const escapedValue = this.escapeLikePattern(value); @@ -1106,124 +1123,46 @@ export abstract class BaseCrudDialect { // _fuzzyRelevance ordering if (field === '_fuzzyRelevance') { - invariant( - typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value, - 'invalid orderBy value for "_fuzzyRelevance"', - ); - invariant( - Array.isArray(value.fields) && value.fields.length > 0, - '_fuzzyRelevance.fields must be a non-empty array', - ); - invariant( - value.sort === 'asc' || value.sort === 'desc', - 'invalid sort value for "_fuzzyRelevance"', - ); - invariant( - typeof value.search === 'string' && value.search.length > 0, - '_fuzzyRelevance.search must be a non-empty string', - ); - const mode = value.mode ?? 'simple'; - invariant( - mode === 'simple' || mode === 'word' || mode === 'strictWord', - '_fuzzyRelevance.mode must be "simple", "word" or "strictWord"', - ); - 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, - fieldRefs, - value.search, - this.negateSort(value.sort, negated), - mode, - unaccent, - ); + result = this.applyFuzzyRelevanceOrderBy(result, model, modelAlias, value, negated, buildFieldRef); + continue; + } + + // _ftsRelevance ordering + if (field === '_ftsRelevance') { + result = this.applyFtsRelevanceOrderBy(result, model, modelAlias, value, negated, buildFieldRef); continue; } // aggregations if (['_count', '_avg', '_sum', '_min', '_max'].includes(field)) { - invariant(typeof value === 'object', `invalid orderBy value for field "${field}"`); - for (const [k, v] of Object.entries(value)) { - invariant(v === 'asc' || v === 'desc', `invalid orderBy value for field "${field}"`); - result = result.orderBy( - (eb) => aggregate(eb, buildFieldRef(model, k, modelAlias), field as AggregateOperators), - this.negateSort(v, negated), - ); - } + result = this.applyAggregationOrderBy( + result, + model, + modelAlias, + field as AggregateOperators, + value, + negated, + buildFieldRef, + ); continue; } const fieldDef = requireField(this.schema, model, field); if (!fieldDef.relation) { - const fieldRef = buildFieldRef(model, field, modelAlias); - if (value === 'asc' || value === 'desc') { - result = result.orderBy(fieldRef, this.negateSort(value, negated)); - } else if ( - typeof value === 'object' && - 'nulls' in value && - 'sort' in value && - (value.sort === 'asc' || value.sort === 'desc') && - (value.nulls === 'first' || value.nulls === 'last') - ) { - result = this.buildOrderByField( - result, - fieldRef, - this.negateSort(value.sort, negated), - value.nulls, - ); - } + result = this.applyScalarOrderBy(result, model, modelAlias, field, value, negated, buildFieldRef); } else { - // order by relation - const relationModel = fieldDef.type; - - if (fieldDef.array) { - // order by to-many relation - if (typeof value !== 'object') { - throw createInvalidInputError(`invalid orderBy value for field "${field}"`); - } - if ('_count' in value) { - invariant( - value._count === 'asc' || value._count === 'desc', - 'invalid orderBy value for field "_count"', - ); - const sort = this.negateSort(value._count, negated); - result = result.orderBy((eb) => { - const subQueryAlias = tmpAlias(`${modelAlias}$ob$${field}$ct`); - let subQuery = this.buildSelectModel(relationModel, subQueryAlias); - const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, subQueryAlias); - subQuery = subQuery.where(() => - this.and( - ...joinPairs.map(([left, right]) => - eb(this.eb.ref(left), '=', this.eb.ref(right)), - ), - ), - ); - subQuery = subQuery.select(() => eb.fn.count(eb.lit(1)).as('_count')); - return subQuery; - }, sort); - } - } else { - // order by to-one relation - const joinAlias = tmpAlias(`${modelAlias}$ob$${index}`); - result = result.leftJoin(`${relationModel} as ${joinAlias}`, (join) => { - const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, joinAlias); - return join.on((eb) => - this.and( - ...joinPairs.map(([left, right]) => eb(this.eb.ref(left), '=', this.eb.ref(right))), - ), - ); - }); - result = this.buildOrderBy(result, relationModel, joinAlias, value, negated, take); - } + result = this.applyRelationOrderBy( + result, + model, + modelAlias, + field, + fieldDef, + value, + negated, + take, + index, + ); } } }); @@ -1231,6 +1170,192 @@ export abstract class BaseCrudDialect { return result; } + private applyRelationOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + field: string, + fieldDef: FieldDef, + value: any, + negated: boolean, + take: number | undefined, + index: number, + ): SelectQueryBuilder { + const relationModel = fieldDef.type; + + if (fieldDef.array) { + // order by to-many relation + if (typeof value !== 'object') { + throw createInvalidInputError(`invalid orderBy value for field "${field}"`); + } + if ('_count' in value) { + invariant( + value._count === 'asc' || value._count === 'desc', + 'invalid orderBy value for field "_count"', + ); + const sort = this.negateSort(value._count, negated); + return query.orderBy((eb) => { + const subQueryAlias = tmpAlias(`${modelAlias}$ob$${field}$ct`); + let subQuery = this.buildSelectModel(relationModel, subQueryAlias); + const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, subQueryAlias); + subQuery = subQuery.where(() => + this.and(...joinPairs.map(([left, right]) => eb(this.eb.ref(left), '=', this.eb.ref(right)))), + ); + subQuery = subQuery.select(() => eb.fn.count(eb.lit(1)).as('_count')); + return subQuery; + }, sort); + } + return query; + } + + // order by to-one relation + const joinAlias = tmpAlias(`${modelAlias}$ob$${index}`); + const joined = query.leftJoin(`${relationModel} as ${joinAlias}`, (join) => { + const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, joinAlias); + return join.on((eb) => + this.and(...joinPairs.map(([left, right]) => eb(this.eb.ref(left), '=', this.eb.ref(right)))), + ); + }); + return this.buildOrderBy(joined, relationModel, joinAlias, value, negated, take); + } + + private applyScalarOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + field: string, + value: any, + negated: boolean, + buildFieldRef: (model: string, field: string, modelAlias: string) => Expression, + ): SelectQueryBuilder { + const fieldRef = buildFieldRef(model, field, modelAlias); + if (value === 'asc' || value === 'desc') { + return query.orderBy(fieldRef, this.negateSort(value, negated)); + } + if ( + typeof value === 'object' && + 'nulls' in value && + 'sort' in value && + (value.sort === 'asc' || value.sort === 'desc') && + (value.nulls === 'first' || value.nulls === 'last') + ) { + return this.buildOrderByField(query, fieldRef, this.negateSort(value.sort, negated), value.nulls); + } + return query; + } + + private applyAggregationOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + field: AggregateOperators, + value: any, + negated: boolean, + buildFieldRef: (model: string, field: string, modelAlias: string) => Expression, + ): SelectQueryBuilder { + invariant(typeof value === 'object', `invalid orderBy value for field "${field}"`); + let result = query; + for (const [k, v] of Object.entries(value)) { + invariant(v === 'asc' || v === 'desc', `invalid orderBy value for field "${field}"`); + result = result.orderBy( + (eb) => aggregate(eb, buildFieldRef(model, k, modelAlias), field), + this.negateSort(v, negated), + ); + } + return result; + } + + private applyFuzzyRelevanceOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + value: any, + negated: boolean, + buildFieldRef: (model: string, field: string, modelAlias: string) => Expression, + ): SelectQueryBuilder { + invariant( + typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value, + 'invalid orderBy value for "_fuzzyRelevance"', + ); + invariant( + Array.isArray(value.fields) && value.fields.length > 0, + '_fuzzyRelevance.fields must be a non-empty array', + ); + invariant(value.sort === 'asc' || value.sort === 'desc', 'invalid sort value for "_fuzzyRelevance"'); + invariant( + typeof value.search === 'string' && value.search.length > 0, + '_fuzzyRelevance.search must be a non-empty string', + ); + const mode = value.mode ?? 'simple'; + invariant( + mode === 'simple' || mode === 'word' || mode === 'strictWord', + '_fuzzyRelevance.mode must be "simple", "word" or "strictWord"', + ); + 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 as string[]).map((f) => buildFieldRef(model, f, modelAlias)); + return this.buildFuzzyRelevanceOrderBy( + query, + fieldRefs, + value.search, + this.negateSort(value.sort, negated), + mode, + unaccent, + ); + } + + private applyFtsRelevanceOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + value: any, + negated: boolean, + buildFieldRef: (model: string, field: string, modelAlias: string) => Expression, + ): SelectQueryBuilder { + invariant( + typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value, + 'invalid orderBy value for "_ftsRelevance"', + ); + invariant( + Array.isArray(value.fields) && value.fields.length > 0, + '_ftsRelevance.fields must be a non-empty array', + ); + invariant(value.sort === 'asc' || value.sort === 'desc', 'invalid sort value for "_ftsRelevance"'); + invariant( + typeof value.search === 'string' && value.search.length > 0, + '_ftsRelevance.search must be a non-empty string', + ); + if (value.config !== undefined) { + invariant( + typeof value.config === 'string' && value.config.length > 0, + '_ftsRelevance.config must be a non-empty string', + ); + } + const config = value.config as string | undefined; + for (const fieldName of value.fields as string[]) { + const fieldDef = requireField(this.schema, model, fieldName); + invariant( + fieldDef.fullText === true, + `field "${fieldName}" is not full-text-searchable; add the \`@fullText\` attribute to use it in \`_ftsRelevance\``, + ); + } + const fieldRefs = (value.fields as string[]).map((f) => buildFieldRef(model, f, modelAlias)); + return this.buildFtsRelevanceOrderBy( + query, + fieldRefs, + value.search, + config, + this.negateSort(value.sort, negated), + ); + } + buildSelectAllFields( model: string, query: SelectQueryBuilder, @@ -1681,7 +1806,10 @@ export abstract class BaseCrudDialect { 'fuzzy filter must be an object with at least a "search" field', ); const raw = value as Record; - invariant(typeof raw['search'] === 'string' && raw['search'].length > 0, 'fuzzy.search must be a non-empty string'); + invariant( + typeof raw['search'] === 'string' && raw['search'].length > 0, + 'fuzzy.search must be a non-empty string', + ); const mode = raw['mode'] ?? 'simple'; invariant( mode === 'simple' || mode === 'word' || mode === 'strictWord', @@ -1704,6 +1832,24 @@ export abstract class BaseCrudDialect { }; } + /** + * Builds a full-text-search filter for a string field. Receives the raw + * user-supplied filter payload — dialects validate/normalize it themselves + * (the shape is provider-specific; only Postgres supports this filter). + */ + abstract buildFullTextFilter(fieldRef: Expression, payload: unknown): Expression; + + /** + * Builds an ORDER BY clause that sorts by full-text-search relevance to a search term. + */ + abstract buildFtsRelevanceOrderBy( + query: SelectQueryBuilder, + fieldRefs: Expression[], + search: string, + config: string | undefined, + sort: SortOrder, + ): SelectQueryBuilder; + // #endregion } @@ -1719,3 +1865,4 @@ export type FuzzyFilterOptions = { threshold?: number; unaccent: boolean; }; + diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index f50b7c642..2af95e2cd 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -416,4 +416,22 @@ export class MySqlCrudDialect extends LateralJoinDiale } // #endregion + + // #region full-text search + + override buildFullTextFilter(_fieldRef: Expression, _payload: unknown): Expression { + throw createNotSupportedError('"fts" filter is not supported by the "mysql" provider'); + } + + override buildFtsRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _config: string | undefined, + _sort: SortOrder, + ): SelectQueryBuilder { + throw createNotSupportedError('"_ftsRelevance" ordering is not supported by the "mysql" provider'); + } + + // #endregion } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index e33266929..e392717a2 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -655,5 +655,89 @@ export class PostgresCrudDialect extends LateralJoinDi return query.orderBy(sql`GREATEST(${sql.join(similarities)})`, sort); } + override buildFullTextFilter(fieldRef: Expression, payload: unknown): Expression { + // When `config` is provided we bind it via sql.val + an inline ::regconfig + // cast (parameterized, no string concatenation). When omitted we emit the + // single-arg forms `to_tsvector(field)` / `to_tsquery(query)` so Postgres + // falls back to the database-level `default_text_search_config`. + // `to_tsquery` throws at execution on syntax error; we don't pre-validate. + const options = this.normalizeFullTextOptions(payload); + const query = sql.val(options.search); + if (options.config === undefined) { + return sql`to_tsvector(${fieldRef}) @@ to_tsquery(${query})`; + } + const cfg = sql.val(options.config); + return sql`to_tsvector(${cfg}::regconfig, ${fieldRef}) @@ to_tsquery(${cfg}::regconfig, ${query})`; + } + + /** + * Validate the user-provided `fts` filter payload. When `config` is omitted + * it stays `undefined` so {@link buildFullTextFilter} can emit the no-regconfig + * SQL form and let Postgres fall back to `default_text_search_config`. + */ + private normalizeFullTextOptions(value: unknown): FullTextFilterOptions { + invariant( + value !== null && typeof value === 'object' && !Array.isArray(value), + 'fts filter must be an object with at least a "search" field', + ); + const raw = value as Record; + invariant( + typeof raw['search'] === 'string' && (raw['search'] as string).length > 0, + 'fts.search must be a non-empty string', + ); + if (raw['config'] !== undefined) { + invariant( + typeof raw['config'] === 'string' && (raw['config'] as string).length > 0, + 'fts.config must be a non-empty string', + ); + } + return { + search: raw['search'] as string, + config: raw['config'] as string | undefined, + }; + } + + override buildFtsRelevanceOrderBy( + query: SelectQueryBuilder, + fieldRefs: Expression[], + search: string, + config: string | undefined, + sort: SortOrder, + ): SelectQueryBuilder { + const q = sql.val(search); + + // Document expression: a single field, or `concat_ws` of all fields when + // multi-field. The single-field path coalesces NULL → '' so `ts_rank` + // returns 0.0 (not NULL) for NULL-valued rows, matching the null-skipping + // behavior `concat_ws` already provides on the multi-field path. + // Multi-field uses a single ts_rank over the combined document (matches + // Prisma; ensures AND queries match terms spread across fields). + const document = + fieldRefs.length === 1 + ? sql`coalesce(${fieldRefs[0]!}, '')` + : sql`concat_ws(' ', ${sql.join(fieldRefs)})`; + + if (config === undefined) { + // No regconfig — Postgres uses default_text_search_config. + return query.orderBy(sql`ts_rank(to_tsvector(${document}), to_tsquery(${q}))`, sort); + } + const cfg = sql.val(config); + return query.orderBy( + sql`ts_rank(to_tsvector(${cfg}::regconfig, ${document}), to_tsquery(${cfg}::regconfig, ${q}))`, + sort, + ); + } + // #endregion } + +/** + * Resolved options for the `fts` full-text filter (Postgres-only). `config` is + * left `undefined` when the user didn't supply one — `buildFullTextFilter` then + * emits `to_tsvector(field)` / `to_tsquery(query)` without a regconfig argument + * so Postgres falls back to the database-level `default_text_search_config`. + */ +type FullTextFilterOptions = { + search: string; + config: string | undefined; +}; diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 8e14c7ecb..48418072c 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -562,5 +562,19 @@ export class SqliteCrudDialect extends BaseCrudDialect ): SelectQueryBuilder { throw createNotSupportedError('"_fuzzyRelevance" ordering is not supported by the "sqlite" provider'); } + + override buildFullTextFilter(_fieldRef: Expression, _payload: unknown): Expression { + throw createNotSupportedError('"fts" filter is not supported by the "sqlite" provider'); + } + + override buildFtsRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _config: string | undefined, + _sort: SortOrder, + ): SelectQueryBuilder { + throw createNotSupportedError('"_ftsRelevance" ordering is not supported by the "sqlite" provider'); + } // #endregion } diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 8827b7987..29a8a616a 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -506,6 +506,7 @@ export class ZodSchemaFactory< withAggregations, allowedFilterKinds, !!fieldDef.fuzzy, + !!fieldDef.fullText, ); } } @@ -794,10 +795,11 @@ export class ZodSchemaFactory< withAggregations: boolean, allowedFilterKinds: string[] | undefined, withFuzzy = false, + withFullText = false, ) { return match(type) .with('String', () => - this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds, withFuzzy), + this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds, withFuzzy, withFullText), ) .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => this.makeNumberFilterSchema(type, optional, withAggregations, allowedFilterKinds), @@ -1017,11 +1019,21 @@ export class ZodSchemaFactory< withAggregations: boolean, allowedFilterKinds: string[] | undefined, withFuzzy = false, + withFullText = false, ): ZodType { const baseComponents = this.makeCommonPrimitiveFilterComponents( z.string(), optional, - () => z.lazy(() => this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds, withFuzzy)), + () => + z.lazy(() => + this.makeStringFilterSchema( + optional, + withAggregations, + allowedFilterKinds, + withFuzzy, + withFullText, + ), + ), undefined, withAggregations ? ['_count', '_min', '_max'] : undefined, allowedFilterKinds, @@ -1036,6 +1048,11 @@ export class ZodSchemaFactory< fuzzy: this.makeFuzzyFilterSchema().optional(), } : {}), + ...(withFullText && this.providerSupportsFullTextSearch + ? { + fts: this.makeFullTextFilterSchema().optional(), + } + : {}), ...(this.providerSupportsCaseSensitivity ? { mode: this.makeStringModeSchema().optional(), @@ -1052,9 +1069,9 @@ export class ZodSchemaFactory< }; const schema = this.createUnionFilterSchema(z.string(), optional, allComponents, allowedFilterKinds); - const fuzzySuffix = withFuzzy ? 'Fuzzy' : ''; + const featureSuffix = `${withFuzzy ? 'Fuzzy' : ''}${withFullText ? 'FullText' : ''}`; this.registerSchema( - `StringFilter${this.filterSchemaSuffix({ optional, allowedFilterKinds, withAggregations })}${fuzzySuffix}`, + `StringFilter${this.filterSchemaSuffix({ optional, allowedFilterKinds, withAggregations })}${featureSuffix}`, schema, ); return schema; @@ -1073,6 +1090,13 @@ export class ZodSchemaFactory< }); } + private makeFullTextFilterSchema() { + return z.strictObject({ + search: z.string().min(1), + config: z.string().min(1).optional(), + }); + } + @cache() private makeSelectSchema(model: string, options?: CreateSchemaOptions) { const fields: Record = {}; @@ -1319,8 +1343,7 @@ export class ZodSchemaFactory< } } - // _fuzzyRelevance ordering for fuzzy search — only fields annotated with `@fuzzy` - // (postgres only). Distinct from a future `_searchRelevance` for full-text search. + // _fuzzyRelevance ordering for fuzzy search — only fields annotated with `@fuzzy` (postgres only). if (this.providerSupportsFuzzySearch) { const fuzzyFieldNames = this.getModelFields(model) .filter(([, def]) => !def.relation && def.type === 'String' && def.fuzzy === true) @@ -1340,6 +1363,23 @@ export class ZodSchemaFactory< } } + // _ftsRelevance ordering for full-text search — only fields annotated with `@fullText` (postgres only). + if (this.providerSupportsFullTextSearch) { + const fullTextFieldNames = this.getModelFields(model) + .filter(([, def]) => !def.relation && def.type === 'String' && def.fullText === true) + .map(([name]) => name); + if (fullTextFieldNames.length > 0) { + fields['_ftsRelevance'] = z + .strictObject({ + fields: z.array(z.enum(fullTextFieldNames as [string, ...string[]])).min(1), + search: z.string().min(1), + config: z.string().min(1).optional(), + sort, + }) + .optional(); + } + } + const schema = refineAtMostOneKey(z.strictObject(fields)); let schemaId = `${model}OrderBy`; @@ -2364,6 +2404,10 @@ export class ZodSchemaFactory< return this.schema.provider.type === 'postgresql'; } + private get providerSupportsFullTextSearch() { + return this.schema.provider.type === 'postgresql'; + } + private get providerSupportsFuzzySearch() { return this.schema.provider.type === 'postgresql'; } diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index c0cf9aaa3..4696b795c 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -77,6 +77,7 @@ export type FieldDef = { default?: FieldDefault; omit?: boolean; fuzzy?: boolean; + fullText?: 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 3674d5712..14d74ae32 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -630,6 +630,10 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('fuzzy', ts.factory.createTrue())); } + if (hasAttribute(field, '@fullText')) { + objectFields.push(ts.factory.createPropertyAssignment('fullText', ts.factory.createTrue())); + } + // originModel if ( contextModel && diff --git a/tests/e2e/orm/client-api/full-text-search.test.ts b/tests/e2e/orm/client-api/full-text-search.test.ts new file mode 100644 index 000000000..734a34156 --- /dev/null +++ b/tests/e2e/orm/client-api/full-text-search.test.ts @@ -0,0 +1,532 @@ +import type { ClientContract } from '@zenstackhq/orm'; +import { createTestClient, getTestDbProvider } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/full-text-search/schema'; + +type Schema = typeof schema; +const provider = getTestDbProvider(); + +describe.skipIf(provider !== 'postgresql')('Full-text search tests', () => { + let client: ClientContract; + + beforeEach(async () => { + client = (await createTestClient(schema)) as unknown as ClientContract; + + await client.article.createMany({ + data: [ + { title: 'The quick brown fox', body: 'the quick brown fox jumps over the lazy dog' }, + { title: 'A cat and a dog', body: 'cat and dog make great pets together' }, + { title: 'Lazy cat sleeps all day', body: 'some cat sleeps more than others' }, + { title: 'The running man', body: 'He runs every morning before work' }, + { title: 'Database performance', body: 'Optimizing query performance for databases' }, + { title: 'PostgreSQL full-text search', body: 'tsvector and tsquery enable searching documents' }, + { title: 'Untitled note', body: 'just some notes', notes: 'a non-searchable note column' }, + ], + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + // --------------------------------------------------------------- + // A. Basic single-term search + // --------------------------------------------------------------- + + it('finds articles by a single term', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'fox' } } }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.title).toBe('The quick brown fox'); + }); + + it('searches in the body field', async () => { + const results = await client.article.findMany({ + where: { body: { fts: { search: 'pets' } } }, + }); + expect(results.some((r) => r.title === 'A cat and a dog')).toBe(true); + }); + + it('returns nothing for a term not in any document', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'zebra' } } }, + }); + expect(results).toHaveLength(0); + }); + + // --------------------------------------------------------------- + // B. to_tsquery boolean operators + // --------------------------------------------------------------- + + it('AND operator (&) requires both terms', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'cat & dog' } } }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('A cat and a dog'); + // Articles with only one of the terms should not match + expect(titles).not.toContain('Lazy cat sleeps all day'); + }); + + it('OR operator (|) matches either term', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'fox | cat' } } }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('The quick brown fox'); + expect(titles).toContain('A cat and a dog'); + expect(titles).toContain('Lazy cat sleeps all day'); + }); + + it('NOT operator (!) excludes a term', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'cat & !lazy' } } }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('A cat and a dog'); + expect(titles).not.toContain('Lazy cat sleeps all day'); + }); + + it('FOLLOWED-BY operator (<->) requires the words be adjacent in order', async () => { + const results = await client.article.findMany({ + where: { body: { fts: { search: 'quick <-> brown' } } }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('The quick brown fox'); + }); + + // --------------------------------------------------------------- + // C. Postgres text-search configuration + // --------------------------------------------------------------- + + it('config "english" applies stemming (running matches "runs")', async () => { + // 'english' stems "running" → "run", so a search for 'run' matches "runs". + const results = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'english' } } }, + }); + expect(results.some((r) => r.title === 'The running man')).toBe(true); + }); + + it('config "simple" does NOT stem', async () => { + // With 'simple', 'run' tokenizes literally and won't match 'runs' / 'running'. + const results = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'simple' } } }, + }); + // No row has the literal token 'run' — only 'runs' / 'running'. + expect(results.some((r) => r.title === 'The running man')).toBe(false); + }); + + it('omitting config uses the database-level default_text_search_config', async () => { + // Without an explicit config, Postgres falls back to the cluster's + // default_text_search_config setting. We assert the SQL works (no error) + // and round-trips an exact-token match — behavior under stemming-vs-not + // depends on the DB default and is not asserted here. + const results = await client.article.findMany({ + where: { body: { fts: { search: 'fox' } } }, + }); + expect(results.some((r) => r.title === 'The quick brown fox')).toBe(true); + }); + + it('config is per-query (no session leakage between queries)', async () => { + const englishStemmed = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'english' } } }, + }); + // Run an explicit-`simple` query right after — it must not inherit the + // previous `english` config from the connection. + const simple = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'simple' } } }, + }); + expect(englishStemmed.length).toBeGreaterThan(simple.length); + }); + + // --------------------------------------------------------------- + // D. Composition with logical combinators + // --------------------------------------------------------------- + + it('AND combinator with two fts filters across fields', async () => { + const results = await client.article.findMany({ + where: { + AND: [{ title: { fts: { search: 'cat' } } }, { body: { fts: { search: 'pets' } } }], + }, + }); + expect(results.some((r) => r.title === 'A cat and a dog')).toBe(true); + }); + + it('OR combinator with two fts filters', async () => { + const results = await client.article.findMany({ + where: { + OR: [{ title: { fts: { search: 'fox' } } }, { title: { fts: { search: 'database' } } }], + }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('The quick brown fox'); + expect(titles).toContain('Database performance'); + }); + + it('fts combined with another string operator on the same field', async () => { + const results = await client.article.findMany({ + where: { + title: { + fts: { search: 'cat' }, + contains: 'Lazy', + }, + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.title).toBe('Lazy cat sleeps all day'); + }); + + // --------------------------------------------------------------- + // E. _ftsRelevance orderBy — single field + // --------------------------------------------------------------- + + it('orders by relevance — best match first', async () => { + const results = await client.article.findMany({ + where: { body: { fts: { search: 'cat | dog' } } }, + orderBy: { _ftsRelevance: { fields: ['body'], search: 'cat & dog', sort: 'desc' } }, + }); + // The article that contains BOTH cat AND dog should rank highest. + expect(results[0]!.title).toBe('A cat and a dog'); + }); + + it('single-field _ftsRelevance on a nullable @fullText field tolerates NULL rows', async () => { + // `subtitle` is `String? @fullText`. A row whose subtitle is NULL must + // not break the orderBy expression — `to_tsvector(NULL)` returns NULL + // and `ts_rank(NULL, ...)` returns NULL, which would otherwise place + // those rows at the front under ASC. The single-field path coalesces + // NULL → '' so `ts_rank` returns 0.0 instead, matching how the + // multi-field `concat_ws` path already handles NULL inputs. + const created = await Promise.all([ + client.article.create({ data: { title: 't1', body: 'b1', subtitle: 'cat' } }), + client.article.create({ data: { title: 't2', body: 'b2', subtitle: null } }), + ]); + const ids = created.map((r) => r.id); + const results = await client.article.findMany({ + where: { id: { in: ids } }, + orderBy: [ + { _ftsRelevance: { fields: ['subtitle'], search: 'cat', sort: 'desc' } }, + { id: 'asc' }, + ], + }); + expect(results.map((r) => r.subtitle)).toEqual(['cat', null]); + }); + + it('orderBy with config option', async () => { + const results = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'english' } } }, + orderBy: { + _ftsRelevance: { fields: ['body'], search: 'run', config: 'english', sort: 'desc' }, + }, + }); + expect(results[0]!.title).toBe('The running man'); + }); + + // --------------------------------------------------------------- + // F. _ftsRelevance orderBy — multiple fields (concat_ws → single ts_rank) + // --------------------------------------------------------------- + + it('multi-field relevance: row with both terms ranks above row with one term', async () => { + // 'A cat and a dog' has both 'cat' and 'dog' in title and body. + // 'Lazy cat sleeps all day' has 'cat' only. + const results = await client.article.findMany({ + where: { + OR: [{ title: { fts: { search: 'cat | dog' } } }, { body: { fts: { search: 'cat | dog' } } }], + }, + orderBy: { + _ftsRelevance: { + fields: ['title', 'body'], + search: 'cat & dog', + sort: 'desc', + }, + }, + }); + expect(results[0]!.title).toBe('A cat and a dog'); + }); + + it('multi-field relevance: AND query matches when terms are split across fields', async () => { + // The whole point of concat_ws over per-field ts_rank: a row whose title + // has one term and body has the other term must rank above a row that + // has neither — i.e. the AND query has to evaluate against the COMBINED + // document, not against each field independently (which would yield 0). + const split = await client.article.create({ + data: { title: 'cat in the hat', body: 'plus a friendly dog' }, + }); + const results = await client.article.findMany({ + where: { id: split.id }, + orderBy: { + _ftsRelevance: { fields: ['title', 'body'], search: 'cat & dog', sort: 'desc' }, + }, + }); + // The split row is selected by id, so it must come back. The key invariant + // is that the orderBy expression doesn't error and ts_rank returns a + // non-zero score (which we verify indirectly by also pulling it via + // the OR fts filter — it should appear there too). + expect(results).toHaveLength(1); + expect(results[0]!.id).toBe(split.id); + + const ranked = await client.article.findMany({ + where: { + OR: [{ title: { fts: { search: 'cat | dog' } } }, { body: { fts: { search: 'cat | dog' } } }], + }, + orderBy: { + _ftsRelevance: { fields: ['title', 'body'], search: 'cat & dog', sort: 'desc' }, + }, + }); + // The split row qualifies for 'cat & dog' under concat semantics — so it + // must be included in the ranked output. Under the old SUM semantics it + // would have been scored 0 and ranked last, but it must now appear + // above any row that contains neither term. + expect(ranked.map((r) => r.id)).toContain(split.id); + }); + + // --------------------------------------------------------------- + // G. Pagination + // --------------------------------------------------------------- + + it('skip/take works alongside _ftsRelevance', async () => { + const all = await client.article.findMany({ + where: { body: { fts: { search: 'cat | dog | fox' } } }, + orderBy: [{ _ftsRelevance: { fields: ['body'], search: 'cat & dog', sort: 'desc' } }, { id: 'asc' }], + }); + const paged = await client.article.findMany({ + where: { body: { fts: { search: 'cat | dog | fox' } } }, + orderBy: [{ _ftsRelevance: { fields: ['body'], search: 'cat & dog', sort: 'desc' } }, { id: 'asc' }], + skip: 1, + take: 1, + }); + expect(paged).toHaveLength(1); + expect(paged[0]!.id).toBe(all[1]!.id); + }); + + it('rejects cursor pagination combined with _ftsRelevance', async () => { + const first = await client.article.findFirst({ + where: { body: { fts: { search: 'cat' } } }, + }); + expect(first).not.toBeNull(); + await expect( + client.article.findMany({ + where: { body: { fts: { search: 'cat' } } }, + orderBy: { _ftsRelevance: { fields: ['body'], search: 'cat', sort: 'desc' } }, + cursor: { id: first!.id }, + take: 2, + }), + ).rejects.toThrow(/_ftsRelevance/); + }); + + // --------------------------------------------------------------- + // H. Mutations / aggregations + // --------------------------------------------------------------- + + it('updateMany with fts filter', async () => { + const { count } = await client.article.updateMany({ + where: { body: { fts: { search: 'pets' } } }, + data: { notes: 'pet-related' }, + }); + expect(count).toBeGreaterThanOrEqual(1); + const updated = await client.article.findMany({ where: { notes: { equals: 'pet-related' } } }); + expect(updated.some((r) => r.title === 'A cat and a dog')).toBe(true); + }); + + it('deleteMany with fts filter', async () => { + const { count } = await client.article.deleteMany({ + where: { title: { fts: { search: 'fox' } } }, + }); + expect(count).toBe(1); + const remaining = await client.article.findMany({ where: { title: { equals: 'The quick brown fox' } } }); + expect(remaining).toHaveLength(0); + }); + + it('count with fts filter', async () => { + const c = await client.article.count({ where: { body: { fts: { search: 'cat | dog' } } } }); + expect(c).toBeGreaterThanOrEqual(2); + }); + + // --------------------------------------------------------------- + // I. @fullText gating — non-searchable fields rejected by Zod + // --------------------------------------------------------------- + + it('rejects fts on a non-@fullText field', async () => { + // The Zod schema strips `fts` from the StringFilter for fields without + // `@fullText`, so passing it here surfaces an "Unrecognized key" error + // pointing at the exact field path. + await expect( + client.article.findMany({ + where: { notes: { fts: { search: 'foo' } } as any } as any, + }), + ).rejects.toThrow(/Unrecognized key:\s*"fts"\s*at\s*"where\.notes"/i); + }); + + it('rejects _ftsRelevance on a non-@fullText field', async () => { + // `_ftsRelevance.fields` is typed as an enum of `@fullText` field names + // only — `notes` is rejected with a precise enum-mismatch error that + // also confirms the enum lists exactly the three `@fullText` fields. + await expect( + client.article.findMany({ + orderBy: { + _ftsRelevance: { fields: ['notes' as any], search: 'foo', sort: 'desc' }, + } as any, + }), + ).rejects.toThrow( + /expected one of "title"\|"body"\|"subtitle"\s*at\s*"orderBy\._ftsRelevance\.fields/i, + ); + }); + + // --------------------------------------------------------------- + // J. Malformed query — execution-time SQL error surfaces + // --------------------------------------------------------------- + + it('malformed to_tsquery syntax throws Postgres syntax error', async () => { + // 'foo &&' is not valid tsquery syntax — Postgres throws + // `syntax error in tsquery: "foo &&"` and we let it surface verbatim + // (we do not pre-validate the query string). + await expect( + client.article.findMany({ where: { title: { fts: { search: 'foo &&' } } } }), + ).rejects.toThrow(/syntax error in tsquery/i); + }); + + // --------------------------------------------------------------- + // K. OrArray contract — single object == single-element array + // --------------------------------------------------------------- + + it('single-object orderBy is equivalent to single-element-array orderBy', async () => { + const single = await client.article.findMany({ + orderBy: { _ftsRelevance: { fields: ['title'], search: 'cat | fox', sort: 'desc' } }, + where: { title: { fts: { search: 'cat | fox' } } }, + }); + const arr = await client.article.findMany({ + orderBy: [{ _ftsRelevance: { fields: ['title'], search: 'cat | fox', sort: 'desc' } }], + where: { title: { fts: { search: 'cat | fox' } } }, + }); + expect(arr.map((r) => r.id)).toEqual(single.map((r) => r.id)); + }); + + it('relevance + scalar tie-breaker enables deterministic pagination', async () => { + // Force ties by inserting duplicates with identical title/body content. + const created = await Promise.all([ + client.article.create({ data: { title: 'Tie title', body: 'Tie body' } }), + client.article.create({ data: { title: 'Tie title', body: 'Tie body' } }), + client.article.create({ data: { title: 'Tie title', body: 'Tie body' } }), + ]); + const ids = created.map((r) => r.id); + const asc = await client.article.findMany({ + where: { id: { in: ids } }, + orderBy: [{ _ftsRelevance: { fields: ['title'], search: 'tie', sort: 'desc' } }, { id: 'asc' }], + }); + const desc = await client.article.findMany({ + where: { id: { in: ids } }, + orderBy: [{ _ftsRelevance: { fields: ['title'], search: 'tie', sort: 'desc' } }, { id: 'desc' }], + }); + expect(asc.map((r) => r.id)).toEqual([...ids].sort((a, b) => a - b)); + expect(desc.map((r) => r.id)).toEqual([...ids].sort((a, b) => b - a)); + }); + + // --------------------------------------------------------------- + // L. Filter-kind slicing — `'FullText'` kind controls the `fts` operator + // --------------------------------------------------------------- + + it('slicing: excludedFilterKinds: ["FullText"] removes the fts operator', async () => { + // The suite-level beforeEach already opened a connection to this test's DB; + // release it so we can recreate the DB with custom slicing config. + await client.$disconnect(); + const options = { + slicing: { + models: { + article: { + fields: { + $all: { + excludedFilterKinds: ['FullText'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + const db = await createTestClient(schema, options); + await db.article.create({ data: { title: 'cat', body: 'a cat' } }); + + // Other string operators in the same StringFilter are still available. + const found = await db.article.findMany({ where: { title: { contains: 'cat' } } }); + expect(found).toHaveLength(1); + + // The `fts` operator is dropped from the schema. + await expect( + db.article.findMany({ + // @ts-expect-error — `fts` is excluded by slicing + where: { title: { fts: { search: 'cat' } } }, + }), + ).toBeRejectedByValidation(['"fts"']); + + await db.$disconnect(); + }); + + it('slicing: includedFilterKinds without "FullText" removes the fts operator', async () => { + await client.$disconnect(); + const options = { + slicing: { + models: { + article: { + fields: { + $all: { + includedFilterKinds: ['Equality', 'Like'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + const db = await createTestClient(schema, options); + await db.article.create({ data: { title: 'cat', body: 'a cat' } }); + + // Equality + Like still work + const eq = await db.article.findMany({ where: { title: { equals: 'cat' } } }); + expect(eq).toHaveLength(1); + const like = await db.article.findMany({ where: { title: { contains: 'cat' } } }); + expect(like).toHaveLength(1); + + await expect( + db.article.findMany({ + // @ts-expect-error — `fts` not in includedFilterKinds + where: { title: { fts: { search: 'cat' } } }, + }), + ).toBeRejectedByValidation(['"fts"']); + + await db.$disconnect(); + }); + + it('slicing: includedFilterKinds with "FullText" keeps fts and drops siblings', async () => { + await client.$disconnect(); + const options = { + slicing: { + models: { + article: { + fields: { + $all: { + includedFilterKinds: ['FullText', 'Equality'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + const db = await createTestClient(schema, options); + await db.article.create({ data: { title: 'cat', body: 'a cat' } }); + + // fts works + const fts = await db.article.findMany({ where: { title: { fts: { search: 'cat' } } } }); + expect(fts).toHaveLength(1); + + // Like-kind operators are excluded + await expect( + db.article.findMany({ + // @ts-expect-error — `contains` (Like) is not in includedFilterKinds + where: { title: { contains: 'cat' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + + await db.$disconnect(); + }); +}); diff --git a/tests/e2e/orm/schemas/full-text-search/schema.ts b/tests/e2e/orm/schemas/full-text-search/schema.ts new file mode 100644 index 000000000..1c23af701 --- /dev/null +++ b/tests/e2e/orm/schemas/full-text-search/schema.ts @@ -0,0 +1,57 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// 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 = { + Article: { + name: "Article", + 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 + }, + title: { + name: "title", + type: "String", + fullText: true, + attributes: [{ name: "@fullText" }] as readonly AttributeApplication[] + }, + body: { + name: "body", + type: "String", + fullText: true, + attributes: [{ name: "@fullText" }] as readonly AttributeApplication[] + }, + subtitle: { + name: "subtitle", + type: "String", + optional: true, + fullText: true, + attributes: [{ name: "@fullText" }] 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/full-text-search/schema.zmodel b/tests/e2e/orm/schemas/full-text-search/schema.zmodel new file mode 100644 index 000000000..673331d9d --- /dev/null +++ b/tests/e2e/orm/schemas/full-text-search/schema.zmodel @@ -0,0 +1,12 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Article { + id Int @id @default(autoincrement()) + title String @fullText + body String @fullText + subtitle String? @fullText + notes String? // not full-text-searchable +}