Skip to content

feat(orm): add fuzzy search and relevance ordering (PostgreSQL)#2573

Merged
ymc9 merged 9 commits intozenstackhq:devfrom
docloulou:fuzzysearch
May 4, 2026
Merged

feat(orm): add fuzzy search and relevance ordering (PostgreSQL)#2573
ymc9 merged 9 commits intozenstackhq:devfrom
docloulou:fuzzysearch

Conversation

@docloulou
Copy link
Copy Markdown
Contributor

@docloulou docloulou commented Apr 10, 2026

Summary

  • Add fuzzy and fuzzyContains filter operators for String fields in where clauses, using PostgreSQL's pg_trgm extension with unaccent for accent-insensitive trigram matching
  • Add _relevance ordering in orderBy to sort results by fuzzy similarity score, supporting single and multiple fields
  • MySQL and SQLite explicitly throw NotSupported errors for these operators

New API

// Fuzzy similarity match (pg_trgm %)
await client.flavor.findMany({
  where: { name: { fuzzy: 'creme' } }
});

// Fuzzy substring match (pg_trgm <%)
await client.flavor.findMany({
  where: { name: { fuzzyContains: 'choco' } }
});

// Relevance ordering
await client.flavor.findMany({
  where: { name: { fuzzy: 'creme' } },
  orderBy: { _relevance: { fields: ['name'], search: 'creme', sort: 'desc' } }
});

Prerequisites (PostgreSQL)

The user must enable the following extensions in their PostgreSQL database:

CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

Files changed

File Changes
packages/orm/src/client/crud-types.ts fuzzy, fuzzyContains in StringFilter; RelevanceOrderBy type
packages/orm/src/client/constants.ts Fuzzy filter kind in FILTER_PROPERTY_TO_KIND
packages/orm/src/client/crud/dialects/base-dialect.ts Handle fuzzy, fuzzyContains, _relevance in filter/orderBy builders; 3 abstract methods
packages/orm/src/client/crud/dialects/postgresql.ts pg_trgm + unaccent implementation (%, <%, similarity(), GREATEST())
packages/orm/src/client/crud/dialects/mysql.ts NotSupported errors
packages/orm/src/client/crud/dialects/sqlite.ts NotSupported errors
packages/orm/src/client/zod/factory.ts Zod validation schemas for fuzzy, fuzzyContains, _relevance
tests/e2e/orm/schemas/basic/schema.zmodel Added Flavor model
tests/e2e/orm/client-api/fuzzy-search.test.ts 35 E2E tests

Implementation details

fuzzy filter

Uses PostgreSQL trigram similarity operator % with unaccent and lower for accent-insensitive, case-insensitive matching:

unaccent(lower("name")) % unaccent(lower('creme'))

fuzzyContains filter

Uses PostgreSQL word similarity operator <% to check if the search term is approximately contained as a substring:

unaccent(lower('choco')) <% unaccent(lower("name"))

_relevance ordering

Uses similarity() function for single fields, GREATEST() for multiple fields:

-- Single field
ORDER BY similarity(unaccent(lower("name")), unaccent(lower('creme'))) DESC

-- Multiple fields
ORDER BY GREATEST(
  similarity(unaccent(lower("name")), unaccent(lower('chocolate'))),
  similarity(unaccent(lower("description")), unaccent(lower('chocolate')))
) DESC

Test plan

  • Basic fuzzy search (English words with typos, transpositions, truncation)
  • Accent-insensitive fuzzy search (French words: crème, café, éclair, pâté)
  • Nullable field handling
  • Combined filters (fuzzy + contains, fuzzy + startsWith, AND/OR/NOT)
  • fuzzyContains (substring fuzzy matching)
  • _relevance ordering (single field, multiple fields, with pagination)
  • Mutations (updateMany, deleteMany with fuzzy/fuzzyContains)
  • GroupBy and count with fuzzy filters
TEST_DB_PROVIDER=postgresql pnpm vitest run tests/e2e/orm/client-api/fuzzy-search.test.ts

Documentation : zenstackhq/zenstack-docs#596

Summary by CodeRabbit

  • New Features

    • Fuzzy text filtering with modes (simple/word/strictWord), optional threshold and unaccent tuning; relevance-based ordering via _fuzzyRelevance.
  • Database Support

    • PostgreSQL: full fuzzy and relevance support. MySQL and SQLite: fuzzy filters and relevance ordering are unsupported.
  • Tests

    • Comprehensive end-to-end tests added covering fuzzy matching, relevance ranking, pagination, mutations, aggregations, and edge cases.
  • Documentation

    • API schemas updated to expose fuzzy filter and _fuzzyRelevance order-by shapes.

… only)

- Introduced fuzzy search operators (`fuzzy`, `fuzzyContains`) in the ORM.
- Added `RelevanceOrderBy` type for sorting based on fuzzy search relevance.
- Implemented fuzzy search filters in PostgreSQL dialect.
- Added error handling for unsupported fuzzy search features in MySQL and SQLite dialects.
- Updated Zod schema factory to include fuzzy search fields.
- Created a new `Flavor` model in the schema for testing purposes.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a PostgreSQL-backed fuzzy text-search feature: new fuzzy filter kind and types, Zod validation, base-dialect hooks for fuzzy filters and fuzzy-relevance ordering, Postgres implementation, MySQL/SQLite rejectors, extensive Postgres e2e tests, and a test-only Flavor model and generated types.

Changes

Cohort / File(s) Summary
Filter mapping
packages/orm/src/client/constants.ts
Added fuzzy: 'Fuzzy' mapping to filter-kind constant.
Types & order-by
packages/orm/src/client/crud-types.ts
Added FuzzyRelevanceOrderBy type and included it in SortAndTakeArgs.orderBy; extended StringFilter typing to allow a fuzzy operator.
Dialect base
packages/orm/src/client/crud/dialects/base-dialect.ts
Added FuzzyFilterOptions type, normalizeFuzzyOptions, abstract buildFuzzyFilter and buildFuzzyRelevanceOrderBy methods; buildStringFilter/cursor logic updated to detect/handle fuzzy and _fuzzyRelevance.
Postgres dialect
packages/orm/src/client/crud/dialects/postgresql.ts
Implements buildFuzzyFilter and buildFuzzyRelevanceOrderBy using pg_trgm/similarity semantics and optional unaccent/lowercasing.
MySQL dialect
packages/orm/src/client/crud/dialects/mysql.ts
Added overrides that throw “not supported by the mysql provider” for fuzzy filter and _fuzzyRelevance ordering.
SQLite dialect
packages/orm/src/client/crud/dialects/sqlite.ts
Added overrides that throw createNotSupportedError(...) for fuzzy filter and _fuzzyRelevance ordering; imported FuzzyFilterOptions type.
Zod validation
packages/orm/src/client/zod/factory.ts
Added fuzzy operator schema to string filters and added _fuzzyRelevance order-by schema for models with non-relation String fields.
E2E tests (Postgres)
tests/e2e/orm/client-api/fuzzy-search.test.ts
New extensive Postgres-only fuzzy-search test suite exercising filters, ordering, pagination constraints, aggregations, updates/deletes, threshold/mode tuning, and unaccent behavior.
Test schema / model / types
tests/e2e/orm/schemas/basic/schema.zmodel, tests/e2e/orm/schemas/basic/schema.ts, tests/e2e/orm/schemas/basic/models.ts, tests/e2e/orm/schemas/basic/input.ts
Added Flavor model (id, optional name, description) and generated/added related exported types and model alias used by tests.
OpenAPI baseline
packages/server/test/openapi/baseline/rpc.baseline.yaml
Extended OpenAPI schemas to include fuzzy in string filters and _fuzzyRelevance in many order-by input schemas.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I nibble through strings, soft and spry,

trigrams tumble as I hop by,
accents fall with a gentle shrug,
Postgres hums and sorts the snug,
I leave a trail of tests — a sweet supply.

🚥 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 describes the main feature added: fuzzy search and relevance ordering for PostgreSQL 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 unit tests (beta)
  • Create PR with unit tests

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.

Tip

💬 Introducing Slack Agent: 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 your free trial and get 200 agent minutes per Slack user (a $50 value).


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.

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: 3

🧹 Nitpick comments (1)
packages/orm/src/client/crud/dialects/postgresql.ts (1)

561-590: Well-implemented PostgreSQL fuzzy search using pg_trgm.

The implementation correctly uses:

  • Trigram similarity operator (%) for fuzzy
  • Word similarity operator (<%) for fuzzyContains with proper operand ordering
  • GREATEST() aggregation for multi-field relevance scoring

The use of sql template tags is appropriate here as these are PostgreSQL-specific operators not available in Kysely's type-safe API. The sql template is Kysely's escape hatch mechanism.

Note: Extension dependencies (pg_trgm and unaccent) are already documented in the type definitions (crud-types.ts). Consider adding runtime error handling if extensions are missing, similar to the createNotSupportedError pattern used for MySQL/SQLite, to provide users with a clearer message instead of a generic PostgreSQL error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/crud/dialects/postgresql.ts` around lines 561 - 590,
Add runtime checks for the required PostgreSQL extensions and throw a clear
user-facing error if missing: implement an internal check (e.g.,
ensurePostgresExtensionsAvailable) that queries pg_extension for 'pg_trgm' and
'unaccent' and call it from the PostgreSQL dialect initialization or lazily
before using fuzzy features; update buildFuzzyFilter, buildFuzzyContainsFilter,
and buildRelevanceOrderBy to call this check (or ensure it's called beforehand)
and throw a createNotSupportedError-style error with a clear message and
remediation steps if either extension is absent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/orm/src/client/crud-types.ts`:
- Around line 912-930: Update the RelevanceOrderBy type and its JSDoc to match
runtime behavior: change the _relevance.fields type from plain array to a
NonEmptyArray<NonRelationFields<Schema, Model>> so an empty fields list is
rejected at the type level, and revise the comment for _relevance to indicate
that relevance uses PostgreSQL similarity() (and that MySQL is not supported /
throws NotSupported at runtime) so IntelliSense reflects actual provider
constraints; locate the RelevanceOrderBy type and the _relevance field
declaration to make these edits.

In `@packages/orm/src/client/crud/dialects/base-dialect.ts`:
- Around line 1110-1131: The _relevance branch adds complex ordering but cursor
pagination still assumes simple {field: 'asc'|'desc'} entries; update handling
so cursor with a _relevance order is either rejected early or supported: modify
the code path that constructs cursor filters (function buildCursorFilter) to
detect order entries where field === '_relevance' (created via
buildRelevanceOrderBy / buildFieldRef / negateSort) and generate a comparison
that first compares computed relevance (value.search against the same fields)
then applies a deterministic tie-breaker (e.g., primary key) in the same sort
direction, or alternatively throw a clear validation error when a cursor is
supplied alongside an _relevance order; ensure tests cover both rejection and
correct SQL generation if you implement support.

In `@packages/orm/src/client/zod/factory.ts`:
- Around line 1180-1192: The _relevance.fields enum is currently built from all
scalar fields (scalarFieldNames) which allows non-string types; change the
scalarFieldNames computation in the getModelFields/filter pipeline to include
only string-typed scalar fields (e.g., filter by the field metadata indicating
type === 'String' or equivalent in your field definition) so that
_relevance.fields contains only string fields, and keep the z.enum(...) usage
but fed from the new string-only scalarFieldNames; update the code around
getModelFields, scalarFieldNames, and the _relevance strictObject construction
to reflect this restriction.

---

Nitpick comments:
In `@packages/orm/src/client/crud/dialects/postgresql.ts`:
- Around line 561-590: Add runtime checks for the required PostgreSQL extensions
and throw a clear user-facing error if missing: implement an internal check
(e.g., ensurePostgresExtensionsAvailable) that queries pg_extension for
'pg_trgm' and 'unaccent' and call it from the PostgreSQL dialect initialization
or lazily before using fuzzy features; update buildFuzzyFilter,
buildFuzzyContainsFilter, and buildRelevanceOrderBy to call this check (or
ensure it's called beforehand) and throw a createNotSupportedError-style error
with a clear message and remediation steps if either extension is absent.
🪄 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: 5c89d679-7173-415f-83ce-5738308b98ee

📥 Commits

Reviewing files that changed from the base of the PR and between 39a0a28 and 62fc9d7.

📒 Files selected for processing (12)
  • 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
  • tests/e2e/orm/client-api/fuzzy-search.test.ts
  • tests/e2e/orm/schemas/basic/input.ts
  • tests/e2e/orm/schemas/basic/models.ts
  • tests/e2e/orm/schemas/basic/schema.ts
  • tests/e2e/orm/schemas/basic/schema.zmodel

Comment thread packages/orm/src/client/crud-types.ts Outdated
Comment thread packages/orm/src/client/crud/dialects/base-dialect.ts Outdated
Comment thread packages/orm/src/client/zod/factory.ts Outdated
- _relevance.fields restreint aux champs String dans le schéma Zod
- Rejet du cursor pagination combiné avec _relevance ordering
- Type RelevanceOrderBy restreint aux StringFields avec tuple non-vide
- JSDoc mis à jour pour refléter le support PostgreSQL uniquement
@ymc9
Copy link
Copy Markdown
Member

ymc9 commented Apr 14, 2026

Hi @docloulou , thanks for this amazing PR, very useful feature and well implemented!

I'm wondering if you're fine with delaying it to release v3.7 or 3.8. Asking this because, although not directly related, it's a bit odd to support fuzzy search but not regular full text search (a feature gap from Prisma). I hope to get FTS implemented, probably in 3.7, and we can have this feature either together or in a subsequent minor release. What do you think?

@docloulou
Copy link
Copy Markdown
Contributor Author

No problem for me. If the code in this PR looks solid to you, it can serve as a good template for adding the FTS feature. The main things left to handle would be adding the @@fulltext([xxxx]) annotations and the associated migrations: https://www.prisma.io/docs/v6/orm/prisma-client/queries/full-text-search#mysql-1

Note: one thing to watch out for - in this PR I’m using _relevance (as Prisma does for FTS) for the fuzzy search, so there could be a conflict.

@iPommes
Copy link
Copy Markdown

iPommes commented Apr 16, 2026

I'm really loving the idea of this! I would definitely use this a lot :D

@ymc9
Copy link
Copy Markdown
Member

ymc9 commented Apr 16, 2026

No problem for me. If the code in this PR looks solid to you, it can serve as a good template for adding the FTS feature. The main things left to handle would be adding the @@fulltext([xxxx]) annotations and the associated migrations: https://www.prisma.io/docs/v6/orm/prisma-client/queries/full-text-search#mysql-1

Note: one thing to watch out for - in this PR I’m using _relevance (as Prisma does for FTS) for the fuzzy search, so there could be a conflict.

Yes, it'll definitely help FTS implementation. Maybe we can share the _relevance field if conflict can very carefully avoided.

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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/orm/src/client/crud-types.ts`:
- Around line 906-922: The _relevance.fields type currently allows enum-backed
string literal unions because StringFields is based on MapModelFieldType
returning any string-like type; change the typing so only true free-form string
columns are allowed (i.e. require MapModelFieldType<Schema, Model, Key> to be
exactly the primitive string type, not a string-literal union or enum type) and
use that stricter alias in RelevanceOrderBy._relevance.fields; align this
compile-time guard with the runtime check implemented in the zod factory (the
string-only schema logic around factory.ts handling of String) by
creating/renaming StringFields to something like StringFieldsOnlyString and
swapping it into RelevanceOrderBy._relevance.fields.
🪄 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: 4df1bb7c-a65b-44f5-8926-58fcd6c21dee

📥 Commits

Reviewing files that changed from the base of the PR and between de08aa4 and 512a02e.

📒 Files selected for processing (6)
  • 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
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/orm/src/client/crud/dialects/postgresql.ts
  • packages/orm/src/client/zod/factory.ts

Comment thread packages/orm/src/client/crud-types.ts
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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/server/test/openapi/baseline/rpc.baseline.yaml`:
- Around line 4326-4329: The OpenAPI generator is advertising PostgreSQL-only
search operators (fuzzy, fuzzyContains, and _relevance) unconditionally; update
the generator in packages/orm/src/client/zod/factory.ts so that the schema
properties for "fuzzy", "fuzzyContains" and "_relevance" are only emitted when
the datasource provider is Postgres (or when the required extension is enabled),
otherwise either omit them or add an explicit description/enum stating
"PostgreSQL-only / extension required"; locate the emission logic around the
existing provider-agnostic blocks (see the sections you flagged near lines
~1025-1035 and ~1304-1316) and add a provider check (or description branch)
before adding those properties to the OpenAPI schema.
🪄 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: 456d6bee-273c-4080-a6e3-e57912084b89

📥 Commits

Reviewing files that changed from the base of the PR and between 512a02e and 75e3d4d.

📒 Files selected for processing (1)
  • packages/server/test/openapi/baseline/rpc.baseline.yaml

Comment thread packages/server/test/openapi/baseline/rpc.baseline.yaml Outdated
Copy link
Copy Markdown
Member

@ymc9 ymc9 left a comment

Choose a reason for hiding this comment

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

Hi @docloulou , I've finished a review pass and added several comments. Please review them when you have time. Thanks!

Comment thread packages/orm/src/client/crud-types.ts Outdated
Comment thread tests/e2e/orm/client-api/fuzzy-search.test.ts
Comment thread tests/e2e/orm/client-api/fuzzy-search.test.ts Outdated
…tured options

- Refactored fuzzy search parameters to use an object structure, allowing for search term, mode, threshold, and accent sensitivity options.
- Updated relevant types and methods across PostgreSQL, MySQL, SQLite, and Zod schema to support the new fuzzy search format.
- Adjusted tests to reflect the new fuzzy search implementation and ensure compatibility with existing functionality.
…nce scoring

- Introduced `mode` options ('simple', 'word', 'strictWord') to enhance fuzzy search relevance ranking.
- Added `unaccent` option to control accent sensitivity during searches.
- Updated relevant types and methods across PostgreSQL, MySQL, SQLite, and Zod schema to accommodate new options.
- Enhanced tests to validate the new functionality and ensure expected behavior with various search scenarios.
@docloulou
Copy link
Copy Markdown
Contributor Author

@ymc9 Thanks for the thorough review! I've addressed all three comments, added threshold support, and also extended _fuzzyRelevance with mode + unaccent so filtering and relevance ordering now expose the same fuzzy-search knobs. AI Summary below, and happy to iterate on any of it.

1. _relevance_fuzzyRelevance (rename) ✅

Renamed everywhere so that _searchRelevance stays free for the future FTS implementation, as you suggested. The naming is intentionally explicit — a comment in the type points future maintainers at the rationale so the name doesn't get "simplified" back later.
Touched: crud-types.ts (type + key), zod/factory.ts, base-dialect.ts (string check + error msgs), mysql.ts / sqlite.ts (NotSupported messages), rpc.baseline.yaml (regenerated), test file references. Also renamed RelevanceOrderByFuzzyRelevanceOrderBy and buildRelevanceOrderBybuildFuzzyRelevanceOrderBy for internal consistency.

2. fuzzy / fuzzyContains unified into a single object ✅

Followed your proposal almost literally. fuzzyContains is gone, both operators collapse into one fuzzy filter that takes mode + search, plus the optional threshold and unaccent knobs you suggested:

fuzzy?: {
  search: string;                                  // required
  mode?: 'simple' | 'word' | 'strictWord';         // default 'simple'
  threshold?: number;                              // 0..1, optional
  unaccent?: boolean;                              // default false
};

Mapping to your spec:

You proposed Implemented as
mode: 'simple' (operator %) mode: 'simple'field % search (or similarity(field, search) > threshold)
mode: 'word' (operator <%) mode: 'word'search <% field (or word_similarity(search, field) > threshold)
mode: 'strictWord' (operator <<%) mode: 'strictWord'search <<% field (or strict_word_similarity(search, field) > threshold)
threshold extension ✅ Implemented — when set, switches from operator form to <func>() > threshold so it bypasses session-level pg_trgm.*_threshold and is per-query
unaccent opt-in ✅ Implemented — default is false; when false, drops the unaccent() wrap, but keeps lower() so case-insensitivity is preserved
Backward-incompatible by design — no internal silent fallback from fuzzy: 'string'. Since the feature is unreleased it felt cleaner to break the shape now than to ship a dual-form API.

3. Should FuzzyRelevanceOrderBy live inside the OrArray wrapper? ✅ — keeping it as OrArray<OrderBy<...> & FuzzyRelevanceOrderBy<...>>

This is the part I want to argue most carefully because the alternatives drop genuinely useful capabilities.

What the current shape (OrArray<OrderBy & Fuzzy>) enables

// (a) Single relevance entry — basic case
orderBy: {
  _fuzzyRelevance: {
    fields: ['name'],
    search: 'creme',
    sort: 'desc',
  },
}
// (b) Relevance + scalar tie-breaker — required for deterministic pagination
orderBy: [
  {
    _fuzzyRelevance: {
      fields: ['name'],
      search: 'creme',
      sort: 'desc',
    },
  },
  { id: 'asc' },
]
// (c) Multi-relevance chained
orderBy: [
  {
    _fuzzyRelevance: {
      fields: ['name'],
      search: 'creme',
      sort: 'desc',
    },
  },
  {
    _fuzzyRelevance: {
      fields: ['description'],
      search: 'french',
      sort: 'desc',
    },
  },
]

All three execute correctly: each array element calls .orderBy() on the Kysely builder, so SQL chains as ORDER BY similarity(...) DESC, "id" ASC and so on. I added e2e tests covering each case.

_fuzzyRelevance now also supports mode + unaccent

Latest update: relevance ordering now accepts the same matching mode and accent normalization controls as the where.fuzzy filter:

orderBy: {
  _fuzzyRelevance: {
    fields: ['name'],
    search: 'choco',
    mode: 'word',       // default 'simple'
    unaccent: true,     // default false
    sort: 'desc',
  },
}

Mapping:

_fuzzyRelevance.mode Ranking function
simple similarity(field, search)
word word_similarity(search, field)
strictWord strict_word_similarity(search, field)
unaccent: true wraps both sides with unaccent(lower(...)); unaccent: false uses lower(...) only.
I intentionally did not add threshold to _fuzzyRelevance: threshold is a filtering concern, so it stays on where.fuzzy. Ranking should produce a score/order; filtering should decide whether a row is included.

Why not pull it out, i.e. OrArray<OrderBy<...>> & FuzzyRelevanceOrderBy<...>?

That alternative resolves to (OrderBy | OrderBy[]) & { _fuzzyRelevance?: ... }. Concretely it would mean:

  • _fuzzyRelevance lives next to orderBy, not inside an element of it
  • You lose case (b) — no per-array tie-breaker; the relevance clause has no array slot to share
  • You lose case (c) — no multi-relevance
  • The runtime parser (buildOrderBy) already treats _fuzzyRelevance as a per-element key alongside _count/_avg/_min/_max and scalar fields — pulling it out would create a type/runtime mismatch
  • Prisma exposes _relevance as an orderBy key with { fields, search, sort }, and Prisma users already expect orderBy to support chained array entries; this keeps the same user-facing mental model without depending on Prisma's exact internal generated type shape

What stops users from putting two keys in the same object?

The Zod refineAtMostOneKey refinement on the orderBy element (factory.ts). So this is rejected at runtime:

// ❌ Throws "Each orderBy element must have at most one key"
orderBy: {
  _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc' },
  id: 'asc',
}

The intersection at the type level is purely there to allow _fuzzyRelevance as a valid key choice on each element; the per-element single-key constraint comes from Zod, exactly like every other orderBy element today.

Cursor pagination guard

Kept the explicit guard, with the renamed key:

throw createNotSupportedError('cursor pagination cannot be combined with "_fuzzyRelevance" ordering');

Heads-up for a follow-up: the guard is shallow (top-level keys only), so a nested { relation: { _fuzzyRelevance: {...} } } inside a to-one relation orderBy would currently slip through. I left this as-is to keep the diff focused, but happy to deepen the check in a follow-up commit if you'd prefer.

Test coverage

fuzzy-search.test.ts went from 35 → 62 tests, all passing on PostgreSQL:

  • A–H: existing tests migrated to the new shape
  • I: fuzzy mode 'word' (formerly fuzzyContains)
  • J–K: mutations + groupBy with the new shape
  • L: threshold — high (0.9) vs low (0.05), edge cases at 0 and 1, threshold + each mode (simple/word/strictWord), per-query independence (proves it's not session-wide)
  • M: mode: 'strictWord' — including the strictness ordering vs 'word'
  • N: unaccent — defaults to false (locked by an explicit contract test asserting fuzzy: { search: 'creme' } does not match 'Crème brûlée'), opt-in unaccent: true finds accented terms, unaccent: false works alongside threshold + mode
  • O: cursor + _fuzzyRelevance rejection
  • P: OrArray + intersection contract — pins the design decision from this review:
    • case (a) single object form orderBy: { _fuzzyRelevance: {...} }
    • single object ⇔ single-element array equivalence (proves enumerate() normalization)
    • case (b) relevance + scalar tie-breaker, with id: 'asc' vs id: 'desc' reversing the order on tied similarity (proves the tie-breaker is genuinely emitted as a chained ORDER BY column)
    • case (b) tie-breaker survives skip/take pagination
    • case (c) multi-relevance: secondary _fuzzyRelevance clause breaks ties on the primary (swapping its search term flips the result order)
    • case (c) multi-relevance + scalar tie-breaker chained together (3 records, primary tied, secondary tied between two, scalar decides)
    • empty {} array element is silently no-op
    • multi-key in a single orderBy element rejected by refineAtMostOneKey Zod refinement
  • Q: _fuzzyRelevance options:
    • mode: 'word' ranks an exact embedded word ("Hot choco drink") above a prefix-only match ("Chocolate")
    • mode: 'strictWord' ranks a word-boundary match above a non-boundary match
    • unaccent: false vs unaccent: true flips the relevance ranking between "Cremezzzz" and "Crème", proving unaccent affects the score used for ordering, not just filtering
      The (b) and (c) OrArray tests force ties by inserting records with identical names, then assert that swapping the tie-breaker direction (or the secondary search term) flips the result order. This is the empirical proof that Kysely's .orderBy() is genuinely additive.

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: 1

🧹 Nitpick comments (1)
tests/e2e/orm/client-api/fuzzy-search.test.ts (1)

781-786: ⚡ Quick win

Reject empty orderBy elements instead of locking in a silent no-op.

This test currently turns {} into supported behavior, which makes malformed orderBy payloads indistinguishable from valid ones. I’d prefer tightening the runtime check to require exactly one key per element and flipping this to an error assertion instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/orm/client-api/fuzzy-search.test.ts` around lines 781 - 786, The
test currently treats an empty object element in orderBy as a silent no-op;
change the runtime validation (where buildOrderBy processes orderBy elements) to
reject any array element that does not have exactly one key and throw a clear
error, then update this test (fuzzy-search.test.ts) to assert that
client.flavor.findMany({ orderBy: [{}, { id: 'asc' }] }) throws the expected
validation error instead of matching IDs against the ref, referencing
buildOrderBy and the client.flavor.findMany call to locate the spots to modify.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/e2e/orm/client-api/fuzzy-search.test.ts`:
- Around line 789-801: The test currently uses a broad await
expect(...).rejects.toThrow(); tighten it to assert the Zod refinement failure
from refineAtMostOneKey in zod/factory.ts by expecting the specific error
message or error path: call client.flavor.findMany(...) as before but replace
the generic rejects.toThrow() with an assertion that the thrown error includes
the refineAtMostOneKey message (or the Zod issue path for the _fuzzyRelevance
field), e.g., expect(...).rejects.toThrow(/refineAtMostOneKey|at most one key/)
or inspect the error.errors array for the exact path to assert the single-key
rule. Ensure the test still targets the same input used in the current it block.

---

Nitpick comments:
In `@tests/e2e/orm/client-api/fuzzy-search.test.ts`:
- Around line 781-786: The test currently treats an empty object element in
orderBy as a silent no-op; change the runtime validation (where buildOrderBy
processes orderBy elements) to reject any array element that does not have
exactly one key and throw a clear error, then update this test
(fuzzy-search.test.ts) to assert that client.flavor.findMany({ orderBy: [{}, {
id: 'asc' }] }) throws the expected validation error instead of matching IDs
against the ref, referencing buildOrderBy and the client.flavor.findMany call to
locate the spots to modify.
🪄 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: ac15d851-c051-4f42-b5e7-820bbd09a126

📥 Commits

Reviewing files that changed from the base of the PR and between 75e3d4d and 09614be.

📒 Files selected for processing (9)
  • 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/server/test/openapi/baseline/rpc.baseline.yaml
  • tests/e2e/orm/client-api/fuzzy-search.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/orm/src/client/constants.ts

Comment thread tests/e2e/orm/client-api/fuzzy-search.test.ts
Copy link
Copy Markdown
Member

@ymc9 ymc9 left a comment

Choose a reason for hiding this comment

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

Thanks for the quick follow-up @docloulou . Overall it's looking great to me. I've attached a few more minor comments. I think we're very close to getting it merged.

Comment thread packages/orm/src/client/crud-types.ts
Comment thread packages/orm/src/client/crud/dialects/base-dialect.ts
Comment thread packages/orm/src/client/zod/factory.ts Outdated
Comment thread packages/orm/src/client/crud-types.ts Outdated
docloulou and others added 3 commits May 2, 2026 23:14
Mirror the existing `providerSupportsCaseSensitivity` pattern: introduce
`providerSupportsFuzzySearch` and use it to add the `fuzzy` operator and
the `_fuzzyRelevance` orderBy schema only when the schema's provider is
postgresql.
…s at type level

Follow the existing `ProviderSupportsDistinct` pattern (review feedback zenstackhq#4):
- Add `ProviderSupportsFuzzy<Schema>` mirroring `ProviderSupportsDistinct`.
- Extract the fuzzy payload into a reusable `FuzzyFilterPayload` type.
- Gate `_fuzzyRelevance` in `SortAndTakeArgs.orderBy` so it is only typed
  when provider is postgresql.
- Gate `fuzzy` on `StringFilter` via a non-invasive `AddFuzzyIfPostgres`
  wrapper applied in `FieldFilter` (which already has `Schema` in scope).
  The wrapper returns `Base` unchanged for non-postgres to preserve
  nullability (intersecting with `{}` would strip null/undefined).
- Update the e2e fuzzy test suite to override the static sqlite provider
  type: the test only runs on postgres at runtime but typechecks against
  the basic schema's compile-time provider.
Copy link
Copy Markdown
Member

@ymc9 ymc9 left a comment

Choose a reason for hiding this comment

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

LGTM! I'm merging and will release it with v3.7.

@ymc9 ymc9 merged commit 679f91f into zenstackhq:dev May 4, 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.

3 participants