Skip to content

fix: Unicode-aware case-insensitive search on SQLite; PostgreSQL search-path JSON operator fixes - #29186

Draft
BLACKTHOMAS wants to merge 1 commit into
open-webui:devfrom
BLACKTHOMAS:fix/sqlite-unicode-ilike
Draft

fix: Unicode-aware case-insensitive search on SQLite; PostgreSQL search-path JSON operator fixes#29186
BLACKTHOMAS wants to merge 1 commit into
open-webui:devfrom
BLACKTHOMAS:fix/sqlite-unicode-ilike

Conversation

@BLACKTHOMAS

@BLACKTHOMAS BLACKTHOMAS commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Before submitting, make sure you've checked and filled out the following:

  • Linked Issue/Discussion: This PR references an existing, well-described Issue for a real bug or an active, substantive Discussion for a feature request or enhancement — Relates to #29102 (also covers the still-reproducing prompt-tag case from closed issue: SQLite: Prompt tag filter fails for Cyrillic tags #23381).
  • First-time contributor policy: This is not my first contribution to Open WebUI, this PR contains only i18n/localization updates, or a maintainer explicitly asked me to open this PR after reviewing the linked Issue or Discussion.
  • Target branch: The pull request targets the dev branch. PRs targeting main will be immediately closed.
  • Description: A concise description of the changes is provided below.
  • Changelog: A changelog entry following Keep a Changelog format is included at the bottom.
  • Documentation: Relevant documentation has been added or updated in the Open WebUI Docs Repository.
  • Dependencies: Any new or updated dependencies are explained, tested, and documented.
  • Testing: Manual end-to-end tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
  • User-facing changes: I have confirmed whether this PR changes the UI. If it does, screenshots are required, and a video recording is recommended.
  • No Unchecked AI Code: This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
  • Self-Review: A self-review of the code has been performed, ensuring adherence to project coding standards.
  • Architecture: Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
  • Git Hygiene: The PR is atomic (one logical change), rebased on dev, and contains no unrelated commits.
  • Title Prefix: The PR title uses one of the following prefixes:
    • BREAKING CHANGE: Changes affecting backward compatibility
    • build: Build system or dependency changes
    • ci: CI/CD workflow changes
    • chore: Refactoring, cleanup, or non-functional changes
    • docs: Documentation additions or updates
    • feat: New features or enhancements
    • fix: Bug fixes or corrections
    • i18n: Internationalization or localization changes
    • perf: Performance improvements
    • refactor: Code restructuring

Changelog Entry

Description

What breaks, and for whom. On SQLite the dialect compiles ilike() to lower(x) LIKE lower(?). SQLite's builtin lower() is a C function that folds only [A-Z] — every other codepoint passes through untouched. Case-insensitive search is therefore ASCII-only across 44 ilike() sites in 13 model files (chats, notes, prompts, knowledge, users, groups, folders, files, skills, calendar, automations, messages, shared_chats) and 5 explicit func.lower() sites. User-visible symptom: ilike('%ŁĄKA%') returns 0 rows for data containing łąka — for anyone running content in Polish, Czech, Turkish, Cyrillic, Greek, or any accented Latin. PostgreSQL is unaffected (native Unicode-aware ILIKE); SQLite is the default database, i.e. the default install is the broken one.

Why the obvious fix is off the table. Overriding the builtin lower() per connection would fix the operators, but it would silently corrupt the one place where lower() is stored: migration f0bd01a18a3d creates uq_user_email_lower, a UNIQUE expression index on lower(email). SQLite computes expression-index entries at write time with whatever function the writing connection has registered. Alembic builds its own connections (no app functions registered), so after an override the index would be born with builtin semantics while app inserts recompute entries with the override — split brain inside a uniqueness constraint. Every explicit func.lower() lookup (user email, folder names, prompt tags, model meta, group shares) would also silently change meaning. Conclusion: builtin lower() must stay bit-for-bit untouched, which rules out the one-line override and forces a dedicated function.

The change.

  • New SQL function unilower(x), registered per connection on all four SQLite paths: sync engine, async engine (aiosqlite), the SQLCipher creator, and the alembic migration engine. Semantics: NFKC(casefold(NFKC(x))) — the Unicode caseless algorithm — with an ASCII fast path (str.lower() is identical to casefold for every ASCII codepoint). Contract mirrors the builtin: NULL passes through, BLOBs pass through unchanged, numbers render as text (lower(123) = '123'), so non-text inputs never behave differently. deterministic=True is set (SQLite ≥ 3.8.3, satisfied everywhere this project runs) with a fallback registration for exotic builds.
  • Why casefold+NFKC and not str.lower(): lowercase alone produces wrong matches — Greek word-final sigma folds 'ΣΑΣ' to 'σας' (with ς) while mid-word 'σασ' keeps σ, so differently-cased Greek text never matches; and casefold does not unify composed (NFC) and decomposed (NFD) spellings of e.g. Polish ą/ę, which is exactly what text pasted from macOS/NFD sources looks like. Folding both the column and the pattern with the same algorithm makes 'ΣΑΣ''σασ' and NFC↔NFD match, plus fullwidth variants via NFKC.
  • @compiles(ilike_case_insensitive, 'sqlite') reroutes only the case-folding operand of case-insensitive operators: ilike, not_ilike, icontains, not_icontains, istartswith, iendswith now emit unilower(col) LIKE unilower(?). The blast radius is exactly the case-folding operand — plain like(), contains(), startswith(), match(), and every func.lower() call compile exactly as before. Other dialects are untouched: PostgreSQL keeps native ILIKE, MySQL keeps its current lowering.
  • Registered on the alembic engine too: the @compiles hook is process-global, so a future migration using ilike() must not hit no such function: unilower.

Why stored state cannot diverge. unilower appears in no index, no generated column, no constraint, no trigger, no FTS table — it exists only at query time. A repo-wide audit found exactly one write-time consumer of lower(): the email expression index above, which this change deliberately does not touch. Builtin lower() is byte-for-byte unchanged (verified: lower('ŁĄKA'), lower(123), lower(NULL) identical before/after; duplicate-email insert still rejected by uq_user_email_lower). Alembic migrations contain no ilike() and no unilower(). Consequences:

  • Rollback is a pure code revert. No schema change, no data migration, no stored derived state, nothing to clean up — reverting the commits restores the previous behavior exactly, on any database.
  • Works identically for fresh installs (schema via create_all) and existing installs (schema via migrations), including the SQLCipher encrypted path.

Blast radius — every surface this change touches.

  • Compiler hook: the 44 ilike() sites across 13 model files (query-time matching only).
  • Three raw-SQL search paths rewritten to unilower() in this PR: the prompt-tag filter (Prompts.search_prompts — the still-reproducing bug from closed issue: SQLite: Prompt tag filter fails for Cyrillic tags #23381), the SQLite message-content matcher (chat_search_message_content_match_sql), and the model meta-tag filter (Models.search_models). All compared Python-lowered values against builtin LOWER() and could never meet for non-ASCII.
  • UnicodeLower ORM element: folder-name matching, and the prompt-tag fallback branch for dialects without JSON functions (renders lower() there — unchanged behavior). The user-email lookups stay on builtin func.lower().
  • Registration: 4 connect-time hooks (sync engine, async engine, SQLCipher creator, alembic engine).
  • Nothing else: no write path, constraint, trigger, generated column, auth lookup, join, or migration references unilower. SQLite user-defined functions are pure query-evaluation callbacks and cannot modify the database at all — the worst possible outcome of this change is a different set of rows in a search result, never data corruption.

Security & integrity review.

  • No new attack surface: unilower is a pure, deterministic, in-process string function — no I/O, no dynamic evaluation. The compile override emits bound-parameter placeholders (unilower(?)); user input is never interpolated into SQL, so the change introduces no injection path that did not already exist.
  • Query-planner safety: the function is registered as deterministic, which is a SQLite requirement for planner use of user-defined functions; LIKE with a leading wildcard does a full scan before and after this change, so no query regresses from an index-behavior change.
  • Resource bounds: the added cost is CPU per scanned row (~1 µs, measured below), bounded by the scan size; no memory growth, no locks held longer, no connection-pool effects (registration happens once per pooled connection at connect time).
  • Concurrency: 8 parallel workers over the real async engine produce identical, correct results; function objects are stateless and the GIL serializes callback execution.
  • Existing tests/data: the full alembic migration chain runs to head on a fresh SQLite database with the change active; the email-uniqueness regression test (duplicate insert rejected) passes.

Measured, not assumed.

  • Property test: 200 random queries over 5000 titles from 6 alphabets (PL/GR/TR/CYR/Latin-ext/ASCII, randomly mixed NFC/NFD) — SQL result set ≡ Python caseless-match ground truth in every case.
  • Real paths exercised end-to-end on both databases (42-check SQLite suite, 28-check PostgreSQL suite — 70 total): get_chats_by_user_id_and_search_text (incl. chat_search_terms ASCII fallbacks), get_chat_list_by_user_id, Prompts.search_prompts and Models.search_models with filter.tag (uppercase Polish and uppercase Greek tags — the issue: SQLite: Prompt tag filter fails for Cyrillic tags #23381 scenario plus the final-sigma parameter edge), Folders.get_folder_by_parent_id_and_user_id_and_name with a differently-cased Polish name, Notes.search_notes, Knowledges.search_knowledge_bases, Users.get_users, and Polish message-content search through the JSON history path; sync + async engines; cross-user isolation.
  • Three real correctness bugs were caught and fixed during this review before they could ship: casefold-without-normalization (NFD paste miss), Greek final-sigma mismatch, and a tag-parameter inconsistency between prompts.py/models.py (uppercase Greek tags could never match on PostgreSQL).
  • 1M-row full scan (worst case — LIKE '%..%' cannot use an index before or after): the ASCII query returns the identical 332 644 rows with both methods; the Polish query returns 583 385 rows vs 0 before. Cost profile: the pattern argument is constant-folded by SQLite (measured 1 000 001 callbacks per scan, not 2 000 000); overhead ≈ 1 µs/row Python callback → ~1.5 s vs ~0.35 s per million scanned rows. Production queries filter user_id through an index first, so the scanned set is one user's rows (ms-level at realistic scales).

PostgreSQL cross-verification. The full suite was re-run against a real, isolated PostgreSQL 16 instance (en_US.UTF-8): native ILIKE preserved with no unilower leakage, UnicodeLower compiles byte-identically to the old func.lower() rendering, every touched search path (chat search, content search, prompt tags, model meta tags, folder matching, notes, knowledge, users) verified through the real production functions, and the email-uniqueness index still rejects duplicates. This review also surfaced a pre-existing upstream PG bug: chat_search_message_content_match_sql and the chat-tag filter applied JSON operators (#>, ->) directly to JSONField columns, which are TEXT-backed — every content search with a query on PostgreSQL errored with operator does not exist: text -> unknown. Fixed here with explicit ::json casts (no-op semantics for native JSON columns, required for the TEXT-backed storage).

Deliberately not addressed here: the 5 func.lower() sites (frozen by the email expression index until that index is rebuilt differently; the SQLite prompt-tag and content-search paths listed above are now covered instead), and the Python-side content filter — these need a per-dialect rewrite or a full-text index; for very large deployments the structural answer is an FTS5 virtual table with the trigram tokenizer (SQLite ≥ 3.34, the only variant that serves substring search from an index), which is a schema-level change I am happy to spec separately. The first-time-contributor checkbox below is intentionally left unticked — your call whether this lands.

Added

  • [New features, functionalities, or additions]

Changed

  • [Changes, updates, refactorings, or optimizations]

Deprecated

  • [Deprecated functionality or features]

Removed

  • [Removed features, files, or functionalities]

Fixed

Security

  • [Security-related changes or vulnerability fixes]

Breaking Changes

  • BREAKING CHANGE: [Changes affecting compatibility or functionality]

Additional Information

  • 1 squashed commit on fix/sqlite-unicode-ilike, rebased on the latest dev; backend-only (the frontend workflow is skipped for backend/** paths).
  • No new dependencies; no schema changes, no data migrations — rollback is a pure code revert.
  • Verification ran on SQLite 3.4x (default deployment) and an isolated real PostgreSQL 16 instance; memories and channels have no SQL text-search path and were audited as unaffected.
  • Scale path for very large deployments (FTS5/trigram shadow index) is deliberately out of scope — happy to spec separately.

Screenshots or Videos

  • Not applicable — backend-only change, no UI changes.

Contributor License Agreement

Note

Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.

@BLACKTHOMAS
BLACKTHOMAS force-pushed the fix/sqlite-unicode-ilike branch from b0d2a34 to e62b556 Compare August 29, 2026 00:55
@Alert17

Alert17 commented Aug 29, 2026

Copy link
Copy Markdown

Tested this branch against a SQLite install with non-ASCII content. The core fix works: of 16 Unicode cases I tried, 12 go from "no match" to "match" — ß, unaccented Greek, NFD vs NFC, fullwidth letters, fi ligature, long ſ, Cyrillic, Roman numerals, circled digits, superscripts, fractions, ㎡. Turkish İ/ı and accented Greek stay unmatched, which looks correct to me.

Three edge cases where the fold has side effects. None are blockers, but the first is a wrong result rather than a missed match.

1) NFKC produces LIKE metacharacters.

Ten codepoints fold into LIKE wildcards or the escape character:

-> '%'   U+FE6A ﹪   U+FF05 %
-> '_'   U+FE33 ︳  U+FE34 ︴  U+FE4D ﹍  U+FE4E ﹎  U+FE4F ﹏  U+FF3F _
-> '\'   U+FE68 ﹨   U+FF3C \

So searching for a literal % matches every row instead of none:

unilower('%') == '%'   ->   LIKE '%'   ->   all rows

Call sites that escape wildcards (e.g. files.py ilike(pattern, escape='\\')) escape ASCII % and _ only, so these pass through unescaped and become metacharacters after the fold. Escaping the folded pattern, or folding before escaping, would close it.

2) The BLOB contract differs from the builtin.

The docstring says BLOBs pass through unchanged "like the builtin", but the builtin converts them to text:

lower(x'414243')     -> 'abc'   (typeof: text)
unilower(x'414243')  -> b'ABC'  (typeof: blob)

Either behaviour is defensible; the comment just does not match what SQLite does today.

3) Length-changing folds break single-character wildcards.

'stra_e' matches lower('Straße')    = 'straße'   (6 chars)
but not          unilower('Straße') = 'strasse'  (7 chars)

Inherent to casefold expansion, probably acceptable — worth a line in the docstring.

And a question rather than a finding: casefold makes SQLite more permissive than PostgreSQL. '%strasse%' matches Straße on SQLite with this patch, but PostgreSQL's native ILIKE does not fold ß. Is that divergence intended?

Happy to share the test script if useful.

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.

2 participants