Skip to content

feat(orm): implement postgres full-text search#2653

Merged
ymc9 merged 2 commits intodevfrom
feat/orm-full-text-search
May 7, 2026
Merged

feat(orm): implement postgres full-text search#2653
ymc9 merged 2 commits intodevfrom
feat/orm-full-text-search

Conversation

@ymc9
Copy link
Copy Markdown
Member

@ymc9 ymc9 commented May 6, 2026

Summary

  • Adds a Prisma-style full-text search capability behind a new field-level @fullText ZModel attribute. PostgreSQL only — MySQL/SQLite throw NotSupported. Mirrors the existing @fuzzy design.
  • Filter operator fts: where: { title: { fts: { search, config? } } } emits to_tsvector(field) @@ to_tsquery(query) (with an inline ::regconfig cast when config is provided; otherwise Postgres falls back to 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 (e.g. cat & dog) match rows where the terms are split across fields — matches Prisma's behavior.
  • Type-level + Zod gating: the fts operator and _ftsRelevance orderBy appear only on String fields annotated with @fullText, only when the provider is postgresql. The 'FullText' filter kind plugs into existing slicing.
  • Cursor pagination is rejected when combined with _ftsRelevance (parallel to _fuzzyRelevance).
  • Refactors buildOrderBy into per-branch dispatchers: applyScalarOrderBy, applyAggregationOrderBy, applyRelationOrderBy, applyFuzzyRelevanceOrderBy, applyFtsRelevanceOrderBy.

Surface

// schema
model Article {
  id    Int    @id @default(autoincrement())
  title String @fullText
  body  String @fullText
  notes String?
}

// usage
const results = await db.article.findMany({
  where: { title: { fts: { search: 'cat & dog', config: 'english' } } },
  orderBy: {
    _ftsRelevance: { fields: ['title', 'body'], search: 'cat & dog', sort: 'desc' },
  },
});

Test plan

  • pnpm build (full graph)
  • pnpm lint
  • e2e typecheck (pnpm test:typecheck)
  • New full-text-search.test.ts — 31 tests covering: basic fts, &/|/!/<-> operators, config (english stems, simple does not, per-query isolation, DB-default fallback), composition with other filters, _ftsRelevance single + multi-field (incl. AND-across-fields), pagination + cursor guard, mutations (updateMany/deleteMany), @fullText gating (Zod-precise error messages), malformed query passthrough, OrArray contract, and three filter-kind slicing tests
  • No regressions in fuzzy-search.test.ts (64 tests)
  • No regressions in broader orderBy-touching suites (find, filter, aggregate, group-by, relation)

Notes

  • Single-field SQL is identical to Prisma's. Multi-field uses concat_ws (Prisma parity) rather than summing per-field ts_rank — chosen because the SUM form silently scores 0 on AND queries when terms span fields.
  • Out of scope (future work): per-field weighting via setweight(), schema-level default config (@fullText(config: "english")), GIN-index hint generation, and stored tsvector columns. Wrapping under fts (vs. Prisma's flat search: string) leaves room for these.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added PostgreSQL full-text search: annotate string fields for FTS, run boolean/configurable text queries, relevance-based ordering, combined filtering with other operators, and pagination-aware behavior (cursor restrictions when ordering by relevance).
  • Tests

    • Added comprehensive end-to-end tests and a sample schema exercising FTS queries, ordering, pagination, mutations, and validation scenarios.

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>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 943fc865-c3fd-4473-8647-ad05031b2fdf

📥 Commits

Reviewing files that changed from the base of the PR and between d0dd954 and 1997cf3.

📒 Files selected for processing (4)
  • packages/orm/src/client/crud/dialects/postgresql.ts
  • tests/e2e/orm/client-api/full-text-search.test.ts
  • tests/e2e/orm/schemas/full-text-search/schema.ts
  • tests/e2e/orm/schemas/full-text-search/schema.zmodel
✅ Files skipped from review due to trivial changes (1)
  • tests/e2e/orm/schemas/full-text-search/schema.zmodel
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/e2e/orm/schemas/full-text-search/schema.ts
  • packages/orm/src/client/crud/dialects/postgresql.ts

📝 Walkthrough

Walkthrough

A new @fullText() attribute and full-text search feature were added: language attribute and validator, schema flag, type-system and Zod input support, dialect hooks with PostgreSQL implementation and stubs for others, client constants, SDK generation updates, and extensive PostgreSQL e2e tests and schemas.

Changes

Full-Text Search Feature

Layer / File(s) Summary
Language Definition
packages/language/res/stdlib.zmodel
Insert attribute @fulltext() @@@targetField([StringField]) @@@once with documentation after @fuzzy.
Language Validation
packages/language/src/validators/attribute-application-validator.ts
Add _checkFullText validator (@check('@fulltext')) to ensure @fullText only on Postgres-backed models.
Schema Type
packages/schema/src/schema.ts
Add fullText?: boolean to FieldDef.
Client Constants
packages/orm/src/client/constants.ts
Add fts: 'FullText' entry to FILTER_PROPERTY_TO_KIND.
Type System / API Types
packages/orm/src/client/crud-types.ts
Add FullTextFields, FullTextFilterPayload, FtsRelevanceOrderBy, ProviderSupportsFullText; compose string filters to include fts and fuzzy; extend orderBy typing to allow _ftsRelevance when supported.
Dialect Base
packages/orm/src/client/crud/dialects/base-dialect.ts
Declare abstract buildFullTextFilter(...) and buildFtsRelevanceOrderBy(...); update cursor pagination check to reject _fuzzyRelevance and _ftsRelevance.
Postgres Dialect
packages/orm/src/client/crud/dialects/postgresql.ts
Implement buildFullTextFilter using to_tsvector @@ to_tsquery with optional regconfig; add normalizeFullTextOptions validation; implement buildFtsRelevanceOrderBy using ts_rank and combined-document expression. Add FullTextFilterOptions alias.
MySQL / SQLite Dialects
packages/orm/src/client/crud/dialects/mysql.ts, packages/orm/src/client/crud/dialects/sqlite.ts
Add overrides buildFullTextFilter and buildFtsRelevanceOrderBy that throw not-supported errors (stubs).
Zod / Input Schema Factory
packages/orm/src/client/zod/factory.ts
Propagate withFullText into primitive/string filter builders; add makeFullTextFilterSchema; include fts operator when providerSupportsFullTextSearch; include _ftsRelevance in orderBy schema when supported.
SDK Generator
packages/sdk/src/ts-schema-generator.ts
Emit fullText: true into generated field objects when @fullText attribute present.
Tests & Test Schemas
tests/e2e/orm/schemas/full-text-search/schema.zmodel, tests/e2e/orm/schemas/full-text-search/schema.ts, tests/e2e/orm/client-api/full-text-search.test.ts
Add Postgres-only test schema and comprehensive e2e tests covering fts queries, boolean operators, config handling, multi-field behavior, _ftsRelevance ordering, pagination constraints, mutations/aggregations, validation for non-annotated fields, tsquery errors, and filter-kind slicing.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I hopped through schema, type, and test,

added fts where Postgres is best,
Validators, dialects, and Zod all sing,
relevance ranks make results take wing,
a rabbit burrow now finds every text.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: implementing PostgreSQL full-text search in the ORM.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/orm-full-text-search

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 6, 2026

Claude finished @ymc9's task —— View job


PR Review: Full-Text Search Implementation

I've conducted a comprehensive review of the full-text search implementation. Overall, this is a well-architected and secure implementation that follows good patterns.

Review Tasks:

  • Analyze core implementation files
  • Review SQL generation and injection safety
  • Evaluate type safety and error handling
  • Assess test coverage and quality
  • Check performance considerations
  • Review architecture and code organization

Strengths

Security & SQL Injection Prevention

  • Excellent parameterization: All user inputs are properly parameterized using sql.val() in packages/orm/src/client/crud/dialects/postgresql.ts:665-670
  • No string concatenation: The implementation correctly avoids string concatenation for SQL generation
  • Proper validation: Input validation in normalizeFullTextOptions() prevents malformed queries from reaching the database

Type Safety

  • Strong typing: Full-text search operators only appear on @fullText annotated String fields
  • Provider gating: Type-level restrictions ensure fts operators only exist when provider = "postgresql"
  • Zod validation: Comprehensive runtime validation in packages/orm/src/client/zod/factory.ts:1093-1098

Architecture & Code Organization

  • Consistent patterns: Mirrors existing @fuzzy implementation design
  • Proper separation: Clean separation between SQL generation, type generation, and validation
  • Provider abstraction: Other providers (MySQL/SQLite) correctly throw NotSupported errors

Comprehensive Test Coverage

The test suite in tests/e2e/orm/client-api/full-text-search.test.ts covers:

  • Basic single-term searches
  • Boolean operators (&, |, !, <->)
  • Configuration support with different text search configs
  • Multi-field relevance ordering
  • Error cases and malformed queries
  • Integration with other filters

🔍 Areas for Consideration

Error Handling

  • Malformed query passthrough: The implementation correctly passes malformed to_tsquery syntax to PostgreSQL, which will throw at execution time. This is acceptable but could potentially be improved with client-side validation.

Performance Considerations

  • Index recommendations: Consider documenting that users should create GIN indexes on @fullText fields for optimal performance
  • Multi-field concatenation: Using concat_ws(' ', ...) for multi-field relevance is correct for AND queries but may have different performance characteristics than individual field ranking

Documentation

  • Missing index guidance: The implementation doesn't provide guidance on creating supporting indexes
  • Configuration examples: More examples of different text search configurations could be helpful

🏗️ Technical Implementation Details

SQL Generation (packages/orm/src/client/crud/dialects/postgresql.ts)

// Clean parameterized approach - no injection risk
if (options.config === undefined) {
    return sql<SqlBool>`to_tsvector(${fieldRef}) @@ to_tsquery(${query})`;
}
const cfg = sql.val(options.config);
return sql<SqlBool>`to_tsvector(${cfg}::regconfig, ${fieldRef}) @@ to_tsquery(${cfg}::regconfig, ${query})`;

Type Safety (packages/orm/src/client/crud-types.ts)

The type system ensures fts only appears on properly annotated fields:

  • Fields must have @fullText attribute
  • Provider must be postgresql
  • Proper integration with filter slicing system

Multi-field Relevance

The concat_ws approach for multi-field ranking is well-chosen:

  • Handles NULL values correctly
  • Enables AND queries to match across fields
  • Maintains Prisma compatibility

🎯 Minor Suggestions

  1. Consider adding index hints: Future enhancement could include automatic GIN index recommendations
  2. Enhanced error messages: Could provide more specific error messages for common tsquery syntax errors
  3. Documentation: Add examples of optimal index creation in the schema documentation

📋 Summary

This is a high-quality implementation that:

  • ✅ Properly prevents SQL injection through parameterization
  • ✅ Maintains strong type safety with provider and attribute gating
  • ✅ Follows established architectural patterns
  • ✅ Includes comprehensive test coverage
  • ✅ Handles edge cases appropriately

The implementation is ready for production and follows security best practices. The code quality is excellent and maintains consistency with the existing codebase.


@ymc9 ymc9 changed the title feat(orm): add @fullText attribute and Postgres full-text search feat(orm): implement postgres full-text search May 6, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/orm/src/client/crud-types.ts`:
- Around line 416-467: The current AddFuzzyFilterIfSupported and
AddFullTextFilterIfSupported intersect the extra operator bag directly into Base
causing union distribution that drops primitive shorthand (e.g., string); change
the augmentation to only apply to object branches by replacing "Base & { ... } :
Base" with a distributive conditional that checks whether Base extends object
(or Record<string, unknown>) and only then adds the fuzzy/fts operator (i.e.,
Base extends object ? Base & { fuzzy?: FuzzyFilterPayload } : Base for
AddFuzzyFilterIfSupported and similarly Base extends object ? Base & { fts?:
FullTextFilterPayload } : Base for AddFullTextFilterIfSupported), keeping the
existing guards that check ProviderSupportsFuzzy/ProviderSupportsFullText and
GetModelField[...]['fuzzy'|'fullText'].

In `@packages/orm/src/client/crud/dialects/postgresql.ts`:
- Around line 709-719: The single-field branch builds `document` from
`fieldRefs[0]` but doesn't coalesce nullable strings, so
`to_tsvector(${document})` can yield NULL and make `ts_rank` return NULL; update
the single-field branch that defines `document` (the `document` variable used
with `to_tsvector`/`ts_rank` and in `query.orderBy`) to coalesce the single
field to an empty string (same null-skipping behavior as the multi-field
`concat_ws` path) before passing it to `to_tsvector` and `ts_rank`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9913c800-a26e-4f36-a060-f28be32e840f

📥 Commits

Reviewing files that changed from the base of the PR and between 9d147b9 and d0dd954.

📒 Files selected for processing (14)
  • packages/language/res/stdlib.zmodel
  • packages/language/src/validators/attribute-application-validator.ts
  • packages/orm/src/client/constants.ts
  • packages/orm/src/client/crud-types.ts
  • packages/orm/src/client/crud/dialects/base-dialect.ts
  • packages/orm/src/client/crud/dialects/mysql.ts
  • packages/orm/src/client/crud/dialects/postgresql.ts
  • packages/orm/src/client/crud/dialects/sqlite.ts
  • packages/orm/src/client/zod/factory.ts
  • packages/schema/src/schema.ts
  • packages/sdk/src/ts-schema-generator.ts
  • tests/e2e/orm/client-api/full-text-search.test.ts
  • tests/e2e/orm/schemas/full-text-search/schema.ts
  • tests/e2e/orm/schemas/full-text-search/schema.zmodel

Comment thread packages/orm/src/client/crud-types.ts
Comment thread packages/orm/src/client/crud/dialects/postgresql.ts
`to_tsvector(NULL)` returns NULL and `ts_rank(NULL, ...)` therefore
returns NULL — under Postgres's default `NULLS FIRST` for `ORDER BY DESC`
this would surface NULL-valued rows ahead of any matching ones, an
asymmetry with the multi-field path where `concat_ws(' ', ...)` already
skips NULLs and yields a 0.0 rank. Coalescing the field to `''` aligns
the two paths.

Adds `subtitle: String? @fullText` to the test fixture and a regression
test that orders a NULL-subtitle row against a matching one — without
the fix, the NULL row ranks first under DESC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 7, 2026

Claude finished @ymc9's task —— View job


I'll review this full-text search implementation for code quality, security, performance, and test coverage.

Review Tasks:

  • Analyze core implementation files
  • Review SQL generation and injection safety
  • Evaluate type safety and error handling
  • Assess test coverage and quality
  • Check performance considerations
  • Review architecture and code organization

PR Review: Full-Text Search Implementation

I've conducted a comprehensive review of the full-text search implementation. Overall, this is a well-architected and secure implementation that follows good patterns.

Strengths

Security & SQL Injection Prevention

  • Excellent parameterization: All user inputs are properly parameterized using sql.val() in packages/orm/src/client/crud/dialects/postgresql.ts:665-670
  • No string concatenation: The implementation correctly avoids string concatenation for SQL generation
  • Proper validation: Input validation in normalizeFullTextOptions() prevents malformed queries from reaching the database

Type Safety

  • Strong typing: Full-text search operators only appear on @fullText annotated String fields
  • Provider gating: Type-level restrictions ensure fts operators only exist when provider = "postgresql"
  • Zod validation: Comprehensive runtime validation in packages/orm/src/client/zod/factory.ts:1093-1098

Architecture & Code Organization

  • Consistent patterns: Mirrors existing @fuzzy implementation design
  • Proper separation: Clean separation between SQL generation, type generation, and validation
  • Provider abstraction: Other providers (MySQL/SQLite) correctly throw NotSupported errors

Comprehensive Test Coverage

The test suite in tests/e2e/orm/client-api/full-text-search.test.ts covers:

  • Basic single-term searches
  • Boolean operators (&, |, !, <->)
  • Configuration support with different text search configs
  • Multi-field relevance ordering
  • Error cases and malformed queries
  • Integration with other filters

🔍 Areas for Consideration

Error Handling

  • Malformed query passthrough: The implementation correctly passes malformed to_tsquery syntax to PostgreSQL, which will throw at execution time. This is acceptable but could potentially be improved with client-side validation.

Performance Considerations

  • Index recommendations: Consider documenting that users should create GIN indexes on @fullText fields for optimal performance
  • Multi-field concatenation: Using concat_ws(' ', ...) for multi-field relevance is correct for AND queries but may have different performance characteristics than individual field ranking

Documentation

  • Missing index guidance: The implementation doesn't provide guidance on creating supporting indexes
  • Configuration examples: More examples of different text search configurations could be helpful

🏗️ Technical Implementation Details

SQL Generation (packages/orm/src/client/crud/dialects/postgresql.ts)

// Clean parameterized approach - no injection risk
if (options.config === undefined) {
    return sql<SqlBool>`to_tsvector(${fieldRef}) @@ to_tsquery(${query})`;
}
const cfg = sql.val(options.config);
return sql<SqlBool>`to_tsvector(${cfg}::regconfig, ${fieldRef}) @@ to_tsquery(${cfg}::regconfig, ${query})`;

Type Safety (packages/orm/src/client/crud-types.ts)

The type system ensures fts only appears on properly annotated fields:

  • Fields must have @fullText attribute
  • Provider must be postgresql
  • Proper integration with filter slicing system

Multi-field Relevance

The concat_ws approach for multi-field ranking is well-chosen:

  • Handles NULL values correctly
  • Enables AND queries to match across fields
  • Maintains Prisma compatibility

🎯 Minor Suggestions

  1. Consider adding index hints: Future enhancement could include automatic GIN index recommendations
  2. Enhanced error messages: Could provide more specific error messages for common tsquery syntax errors
  3. Documentation: Add examples of optimal index creation in the schema documentation

📋 Summary

This is a high-quality implementation that:

  • ✅ Properly prevents SQL injection through parameterization
  • ✅ Maintains strong type safety with provider and attribute gating
  • ✅ Follows established architectural patterns
  • ✅ Includes comprehensive test coverage
  • ✅ Handles edge cases appropriately

The implementation is ready for production and follows security best practices. The code quality is excellent and maintains consistency with the existing codebase.


@ymc9 ymc9 merged commit 9bfc3fe into dev May 7, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant