Skip to content

feat: portable text search with contains, startsWith and endsWith - #114

Open
artile wants to merge 3 commits into
MapleTechLabs:mainfrom
artile:feat/text-search
Open

artile wants to merge 3 commits into
MapleTechLabs:mainfrom
artile:feat/text-search

Conversation

@artile

@artile artile commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

A .like() pattern built from user text treats % and _ in that text as wildcards. A search for "50%" also finds "500", and there is no portable way to say "case-insensitive contains".

Closes #83.
Part of #52.

Changes

  • New string methods on Expr: .contains(value), .startsWith(value), .endsWith(value). The value is text, not a pattern. %, _ and \ in it are escaped with a backslash, and the helper adds the wildcards.
  • A string value is escaped in JS, then bound (Postgres) or written as a literal (ClickHouse). A param or expression value is escaped in SQL with three replace() calls, because its value is known only at run time.
  • { insensitive: true } (TextMatchOptions) writes ILIKE on both dialects. On ClickHouse 26.8, ILIKE folds UTF-8. On Postgres it folds by the database LC_CTYPE (ASCII only in a C locale, as in PGlite). The docs say this.
  • Postgres gets an explicit ESCAPE '\'. ClickHouse uses the backslash as its LIKE escape already, so it gets no clause (this also keeps older ClickHouse versions working).
  • New export escapeLike(text): the escape step on its own, for a pattern you build for .like().
  • Tests: compile-shape tests (src/ch/text-match.test.ts), type tests (expr.test-d.ts), a docs example test, and three new core cases (text-match, text-match-param, text-match-insensitive). The core cases run live on Postgres (PGlite) and ClickHouse, on rows that contain %, _ and \. Each case has a row that an unescaped pattern would also match, so a missing escape fails the case.
  • Docs: a "Text search" section in docs/expressions.md, and docs/reference.md updates. Changeset: minor.

Left out

  • SQLite, MySQL and D1. Upstream has no such dialect yet. SQLite needs the ESCAPE clause (it has no default escape) and its LIKE ignores case for ASCII only. MySQL needs ESCAPE '\\' and its case rules follow the collation. The escape clause is written only for non-ClickHouse dialects, so a new dialect gets it, but insensitive on those dialects needs a decision (ILIKE does not exist there; lower() on both sides is one choice).
  • No lower()-based fallback for Postgres. ILIKE keeps the Postgres semantics users expect, and a lower() rewrite would not use the same indexes (pg_trgm supports ILIKE directly).
  • No ClickHouse positionCaseInsensitiveUTF8 / startsWith() rendering. LIKE / ILIKE with escaping gives the same results, ClickHouse already rewrites LIKE '%x%' to a substring search, and one SQL shape for both dialects is simpler to review.
  • No notContains and friends. CH.not(...) covers it.

Validation

Run in the PR worktree, with a live ClickHouse 26.8.2.7 (the CI version) and EFFECT_ORM_CLICKHOUSE_* set, so no suite is skipped:

  • bun run build: pass.
  • bun run typecheck: pass.
  • bunx vitest run --testTimeout=60000 --hookTimeout=60000: 61 files passed, 1059 tests passed, 0 skipped. (The long timeouts are only for a loaded machine.)
  • node scripts/check-doc-citations.mjs: ok (25 files).
  • node scripts/check-exports-documented.mjs: ok (289 /clickhouse, 207 /postgres exports).
  • node scripts/check-doc-examples.mjs: ok (21 files typechecked).
  • bun scripts/check-package.ts: isolated tarball and CommonJS consumer ok.

Upstream CI for a fork PR waits for a maintainer to approve the run.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

artile and others added 3 commits October 25, 2026 17:36
The value is text, not a pattern: %, _ and the backslash are escaped.
A string is escaped before it is bound; a param or expression is escaped
in SQL with replace(). { insensitive: true } writes ILIKE. Postgres gets
ESCAPE '\'; ClickHouse already escapes with a backslash. escapeLike()
is the escape step on its own.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 23 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 82ad72ab-40eb-4394-881b-16e05211eacc
Commits

Reviewing files that changed from the base of the PR and between c5fa679 and da3869d.

Files ignored due to path filters: 1
  • tests/__snapshots__/core-sql.test.ts.snap is excluded by !**/*.snap
Files selected for processing: 11
  • .changeset/text-search.md
  • docs/expressions.md
  • docs/reference.md
  • src/ch/expr.test-d.ts
  • src/ch/expr.ts
  • src/ch/param.ts
  • src/ch/text-match.test.ts
  • src/core.ts
  • src/docs-examples.test.ts
  • src/expr.ts
  • tests/core-cases.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

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.

Portable text search: case-insensitive contains and LIKE escaping

1 participant