feat(search): match titles regardless of accents - #417
Conversation
"prenom" returned nothing while "prénom" found "Le Prénom". SQLite's LIKE ignores case for ASCII but never diacritics, so 4 882 of the 40 971 titles in a French catalogue — 12 % — were unreachable to anyone who did not type the accent, which on a TV remote is most of the time. Titles are now stored a second time in a folded form (NFD, combining marks dropped, lowercased with Locale.ROOT) and queries are folded the same way before they reach the DAO. The comparison stays symmetric: accented or not, either side matches. normalizeForSearch is the single definition of that fold. The migration backfills existing rows through it in Kotlin rather than through a stack of SQL REPLACE calls, so the two can never drift — and a drift here is silent, rows simply stop matching. titleNormalized sits outside the constructor on purpose: a data class only copies constructor parameters, so copy(title = …) recomputes it instead of carrying a stale value that would drop the channel out of every search. Folding happens in the repositories rather than at each call site, so every screen searching channels or categories behaves the same way. No index on the column: '%query%' cannot use one, and it would only slow the bulk inserts a resubscription performs on tens of thousands of rows. Verified on a 40 971-row database: migration to v27 completed at startup, every row backfilled, and "prenom" and "AMELIE" now return the accented titles on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // performs on tens of thousands of rows. | ||
| @ColumnInfo(name = "title_normalized", defaultValue = "''") | ||
| @Exclude | ||
| var titleNormalized: String = title.normalizeForSearch() |
There was a problem hiding this comment.
maybe normalized titles should not be stored into the database?
There was a problem hiding this comment.
You're right on principle — it's a derived column, so it's duplication that has to be kept in sync. I went looking for the alternative and measured what it would cost.
FTS5 isn't available. Probed through the Android SQLite library itself (not the shell binary — they're compiled separately) on an Android 9 device:
sqlite_version 3.22.0
fts5 (any tokenizer) no such module: fts5
fts4 + unicode61 OK
minSdk here is 26, and Room only offers @Fts4 — consistent with FTS5 not being guaranteed by the platform. The trigram tokenizer, which would have given substring matching, needs 3.34+ anyway.
FTS4 would change what search finds. Measured on a real 40 971-channel catalogue, searching nom:
anywhere in the title 74 hits ← current behaviour
at a word boundary 31 hits ← what FTS would give
lost 43 (58 %)
nom would stop finding prénom, men would stop finding X-Men.
What the column costs, same catalogue: 0.96 MB out of a 57.41 MB database — 1.7 %, and queries stay around 6 ms including one returning 29 087 rows.
Doing it without a stored column isn't possible either: SQLite has no accent-folding function and Room can't register one, so it would mean loading all 40 971 titles into memory and filtering in Kotlin on every keystroke.
One thing I'll grant: on a catalogue ten times larger the LIKE '%…%' scan grows tenfold. No index can serve a leading wildcard, so that's true with or without the column.
The field sits outside the constructor so it can't go stale — a data class only copies constructor parameters, so copy(title = …) recomputes it.
If you'd still rather not carry the column, I'll take FTS4 and the narrower matching — your call, it's your codebase.
There was a problem hiding this comment.
Thanks for the detailed analysis. Preserving arbitrary substring matching is important to me, so the stored normalized column makes sense under these constraints. My only remaining concern is the public mutable property on an @immutable model—could we avoid that?
|
btw. I think you don’t need to handle the database migration in PRs. I’ll consolidate the schema changes and migrations when preparing the next release, to avoid conflicts between multiple PRs. |
Two review points from @oxyroid. The property is now a constructor parameter defaulting to title.normalizeForSearch(), so the @immutable contract holds. It sat outside the constructor to survive copy(title = …), which keeps the old value and would silently drop a channel out of every search — but nothing in the codebase copies a channel with a new title today, the two call sites in PlaylistRepositoryImpl only reassign ids. Migration, schema and migration test are out, to be consolidated with the other PRs.
|
Fair point — it's a constructor It sat outside the constructor to survive Migration, schema file and migration test are out too. One caveat worth flagging: with the entity carrying the new column but no version bump, a build rewrites the existing |
oxyroid
left a comment
There was a problem hiding this comment.
Thanks for your contribution! One small request: before asking for a review, please take some time to manually review the changes first.
We’re absolutely open to AI-assisted coding, but we don’t want to leave everything to AI. It’s important that contributors understand and stay in control of the code they introduce. It doesn’t have to be perfect, but obvious issues should ideally be caught before review.
| stream.seen AS seen, | ||
| stream.relation_id AS relation_id | ||
| stream.relation_id AS relation_id, | ||
| -- Present only because Room requires every non-null field to be |
There was a problem hiding this comment.
don't leaves comments in SQL statement
Searching "prenom" returns nothing while "prénom" finds "Le Prénom".
SQLite's
LIKEignores case for ASCII but never diacritics, so on a Frenchcatalogue a large share of titles is unreachable to anyone not typing the
accent — which, on a TV remote, is most of the time. Measured on a 40 971
channel catalogue: 4 882 titles carry a diacritic, 12 % of the whole.
Approach
Titles are stored a second time in a folded form — NFD, combining marks
dropped, lowercased with
Locale.ROOT— and queries are folded the same waybefore reaching the DAO. The comparison stays symmetric: accented or not,
either side matches.
normalizeForSearchis the single definition of that fold. The migrationbackfills existing rows through it, in Kotlin, rather than through a stack of
SQL
REPLACEcalls: the two would drift the first time either side is touched,and a drift here is silent — rows simply stop matching.
Locale.ROOTis deliberate. A Turkish locale lowercasesIto a dotlessı,which would quietly make those titles unsearchable for those users.
titleNormalizedsits outside the constructor on purpose. A data class onlycopies constructor parameters, so
copy(title = …)recomputes it instead ofcarrying a stale value that would drop the channel out of every search — the
invariant holds by construction rather than by remembering to maintain it.
No index on the column:
'%query%'cannot use one, and it would only slow thebulk inserts a resubscription performs on tens of thousands of rows.
Folding happens in the repositories rather than at each call site, so every
screen searching channels or categories behaves the same way.
Tests
Five unit tests on the fold — accents, case under a Turkish default locale,
scripts without combining marks, punctuation, empty input — and a migration
test asserting existing rows are backfilled, including past the first batch.
The backfill matters most for users who have run the app for months and never
re-import their catalogue.
Verified on device against 40 971 rows: migration completes at startup, every
row backfilled, and "prenom" and "AMELIE" return the accented titles.