Repository navigation
fix(search,core): a word's length decided whether its accents mattered - #815
Conversation
6d1ca53 to
810fef0
Compare
📎 Spec referencesThis PR cites the following spec identifiers:
Updated automatically on each push. |
810fef0 to
6b76c0a
Compare
|
Both blockers addressed in
The first is not hypothetical:
The four Search files still show a handful of uncovered lines ( No assertion-free tests. All four newly-covered branches are reached with real objects, not mocks:
The determinism case asserts too: SQLite refuses a non-deterministic function inside an index expression, so One thing that surfaced on the way: Gates re-run whole: Noted on the flaky |
Search picks its matching arm by the needle's LENGTH -- three characters is FTS5's trigram minimum, and anything shorter falls back to a LIKE over the same indexed body. The two arms did not compare alike. FTS5's tokenizer folds case over the whole of Unicode; SQLite's LIKE and LOWER() fold ASCII and stop, and no pragma widens them. So over a body holding `MÖRK BAR`, `örk` found the row and `ör` found nothing, and the screen offered no reason. "Ignore case" is now one function. `Core::UnicodeFolding::of()` is the rule; `beatrax_fold` is that same method registered as a SQLite user function, and `LikeNeedle` folds both sides of every predicate through it. The ASCII fold is gone rather than matched -- there is no LOWER() left in `Modules/`, and a contract guard keeps it that way. The palette's PHP-side matching reads the same method, so the counterparty section and the goal, pot, category and recurring sections stop disagreeing about `ölkanne`. A user function is per-connection, which is the whole risk. It is bound in one place, on `ConnectionEstablished` -- the event every way of getting a connection passes through -- plus a booted walk for one the replay over already-open connections has to skip, because that replay re-writes pragmas SQLite refuses inside a transaction. A connection that somehow missed it refuses the query by name rather than returning fewer rows: a fallback to LOWER() would be the defect this removes, hidden behind the fix. Spec: B9-R19 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Wessel Verheij <info@nightworks.io>
The refusal was a bare RuntimeException, so a caller could only catch it by catching everything (php:S112, twice). UnicodeFoldingUnavailableException is the dedicated type, and it carries the connection name as a readonly property rather than only inside the sentence: which connection came up short is the one question a caller has, and a message is a poor place to keep an answer. Two named constructors for the two ways to arrive. notASqlitePdo() is the real one: `new PDO` hands back a base PDO with no createFunction(), which is how Laravel builds a connection below PHP 8.4 and how anything opening the file outside the framework does at any version. driverRefused() is SQLite itself declining. Both were uncovered, along with the provider's non-SQLite early return and the fold's null argument, which is most of what left new-line coverage at 76.5%. All four are now reached with real objects rather than mocks -- a plain PDO, a Pdo\Sqlite subclass that returns false, a connection configured with another driver, and a NULL column. The three new Core files measure 100% of their statements from these tests alone. The determinism case asserts rather than decorates: SQLite refuses a non-deterministic function inside an index expression, so building an index over beatrax_fold(body) is what proves the flag was passed. Spec: B9-R19 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Wessel Verheij <info@nightworks.io>
…ernal PublicSurfaceArchTest was right: nothing outside Modules/Core names this exception, so nothing about it was public. It is not a contract a neighbour depends on, it is how Core stops. Checked rather than assumed before moving it. The only method that throws is UnicodeFolding::registerOn(), and the guard added earlier in this branch pins that method to exactly one caller -- the provider beside this file -- so a consumer in another module cannot appear without failing that guard. The two things an outside catcher could want are answered elsewhere anyway: falling back to ASCII folding is the defect this branch removes, and reporting a broken install is Core's own doctor. So Internal/, not a pin: the pinned list only shrinks, and a class written in the same PR is the weakest case for adding to it. UnicodeFolding itself stays Public and stays consumed -- Modules/Search reads of() and sql(). A Public method throwing its own module's Internal exception is the shape twenty-two other Public classes here already have, so the fold and the registration that installs it stay in one file. Spec: B9-R19 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Wessel Verheij <info@nightworks.io>
6b76c0a to
5cff1a1
Compare
|
Why Internal and not a pin. The question was whether an outside consumer exists that a fully-qualified-name scan cannot see. It does not, and that is checkable rather than a matter of taste: the only method that throws is expect($scan['hits']['registrations'])
->toBe(['Modules/Core/Internal/Providers/UnicodeFoldingProvider.php']);— so a consumer in another module cannot appear without failing a guard on this same branch. The two things an outside catcher could plausibly want are answered elsewhere anyway: falling back to ASCII folding is the defect this branch removes, and reporting a broken install is Core's own doctor seam, which is in Core. The pinned list only shrinks, and a class written in this PR is the weakest possible case for adding to it. Two things checked rather than assumed while moving it:
Verification, run at the directory level this time rather than at the guards my diff suggested:
Coverage is unaffected by the move — One process note for the record: I edited the docs page after launching the contracts run, so that batch predated the edit. Re-ran Point taken on scope generally. A rule keyed on where a file lives is invisible to a search of the diff, which is exactly why the directory-level run is the right default. |
|
#815) * fix(search,core): a word's length decided whether its accents mattered Search picks its matching arm by the needle's LENGTH -- three characters is FTS5's trigram minimum, and anything shorter falls back to a LIKE over the same indexed body. The two arms did not compare alike. FTS5's tokenizer folds case over the whole of Unicode; SQLite's LIKE and LOWER() fold ASCII and stop, and no pragma widens them. So over a body holding `MÖRK BAR`, `örk` found the row and `ör` found nothing, and the screen offered no reason. "Ignore case" is now one function. `Core::UnicodeFolding::of()` is the rule; `beatrax_fold` is that same method registered as a SQLite user function, and `LikeNeedle` folds both sides of every predicate through it. The ASCII fold is gone rather than matched -- there is no LOWER() left in `Modules/`, and a contract guard keeps it that way. The palette's PHP-side matching reads the same method, so the counterparty section and the goal, pot, category and recurring sections stop disagreeing about `ölkanne`. A user function is per-connection, which is the whole risk. It is bound in one place, on `ConnectionEstablished` -- the event every way of getting a connection passes through -- plus a booted walk for one the replay over already-open connections has to skip, because that replay re-writes pragmas SQLite refuses inside a transaction. A connection that somehow missed it refuses the query by name rather than returning fewer rows: a fallback to LOWER() would be the defect this removes, hidden behind the fix. Spec: B9-R19 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Wessel Verheij <info@nightworks.io> * fix(core): name the exception a connection that cannot fold raises The refusal was a bare RuntimeException, so a caller could only catch it by catching everything (php:S112, twice). UnicodeFoldingUnavailableException is the dedicated type, and it carries the connection name as a readonly property rather than only inside the sentence: which connection came up short is the one question a caller has, and a message is a poor place to keep an answer. Two named constructors for the two ways to arrive. notASqlitePdo() is the real one: `new PDO` hands back a base PDO with no createFunction(), which is how Laravel builds a connection below PHP 8.4 and how anything opening the file outside the framework does at any version. driverRefused() is SQLite itself declining. Both were uncovered, along with the provider's non-SQLite early return and the fold's null argument, which is most of what left new-line coverage at 76.5%. All four are now reached with real objects rather than mocks -- a plain PDO, a Pdo\Sqlite subclass that returns false, a connection configured with another driver, and a NULL column. The three new Core files measure 100% of their statements from these tests alone. The determinism case asserts rather than decorates: SQLite refuses a non-deterministic function inside an index expression, so building an index over beatrax_fold(body) is what proves the flag was passed. Spec: B9-R19 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Wessel Verheij <info@nightworks.io> * refactor(core): the folding refusal is Core's own, so it lives in Internal PublicSurfaceArchTest was right: nothing outside Modules/Core names this exception, so nothing about it was public. It is not a contract a neighbour depends on, it is how Core stops. Checked rather than assumed before moving it. The only method that throws is UnicodeFolding::registerOn(), and the guard added earlier in this branch pins that method to exactly one caller -- the provider beside this file -- so a consumer in another module cannot appear without failing that guard. The two things an outside catcher could want are answered elsewhere anyway: falling back to ASCII folding is the defect this branch removes, and reporting a broken install is Core's own doctor. So Internal/, not a pin: the pinned list only shrinks, and a class written in the same PR is the weakest case for adding to it. UnicodeFolding itself stays Public and stays consumed -- Modules/Search reads of() and sql(). A Public method throwing its own module's Internal exception is the shape twenty-two other Public classes here already have, so the fold and the registration that installs it stay in one file. Spec: B9-R19 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Wessel Verheij <info@nightworks.io> --------- Signed-off-by: Wessel Verheij <info@nightworks.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>



Search picks its matching arm by the needle's length — three characters is FTS5's trigram minimum, and anything shorter falls back to a
LIKEover the same indexed body. The two arms did not compare alike. FTS5's tokeniser folds case over the whole of Unicode; SQLite'sLIKEandLOWER()fold ASCII and stop, and no pragma widens them (PRAGMA case_sensitive_likecan only turn folding off, andicu_load_collationanswersno such functionin this build).So the length of a word decided whether its accents mattered, and nothing on the screen said why.
Before and after
Over one transaction whose counterparty is
MÖRK BAR, throughSearchQuery::search():örkMATCH[1][1]örLIKE[][1]Same two row sets,
ÖRK/ÖR— both found before and after, which is the half that always worked and is why nobody caught this.Non-ASCII beyond Latin-1, measured on the shipped SQLite 3.53.3 with a 50,000-row table:
body LIKE '%n%'LOWER(body) LIKE LOWER('%n%')beatrax_fold(body) LIKE beatrax_fold('%n%')örölkαθηмоскThe middle column is what the
LIKEarm did.The change
"Ignore case" is now one function.
Modules/Core/Public/Support/UnicodeFolding::of()is the whole rule (mb_strtolower, UTF-8);beatrax_foldis that same method registered as a SQLite user function so SQL can call it.LikeNeedlefolds both sides of every predicate it builds through it.The ASCII fold is gone, not matched:
LOWER(no longer appears in any SQL underModules/, and a contract case keeps it that way.startsWithAnyCaselost its suffix because every predicate now folds, so the distinction it named no longer exists.The palette's PHP-side matching (
EntityNameSearch,SearchTokenFilters,DidYouMeanSuggester) reads the same method, so the counterparty section and the goal / pot / category / recurring sections stop disagreeing aboutölkanne— the second split the docs recorded.Where it is bound, and every connection path covered
A user function is per-connection, which is the whole risk of this approach. It is bound in exactly one place —
Modules/Core/Internal/Providers/UnicodeFoldingProvider— onConnectionEstablished, which is the one event every way of getting a connection passes through. Enumerated againstIlluminate\Database\DatabaseManager, not guessed:DatabaseManager::connection()connectUsing()/build()DatabaseManager::reconnect()Connection::run()→reconnectIfMissingConnection()→ the manager's reconnectornativephpin its ownboot()and the resolved connection is cached)AlreadyOpenConnectionsProviderPRAGMA journal_mode, illegal inside a transactionApplication::booted()walkartisan,sync:serve, mobile and desktop shellsThe one PDO nothing reaches is
Modules/DevMode/Internal/Sql/IsolatedSelectProcess, which opens the file itself in a child process to run one developer-typedSELECTunder a timeout. It already carries the same exemption forPRAGMA busy_timeout; a folded query typed into that panel is refused by name, and the exemption is stated in the guard and the docs.What happens when it is missing
The query fails. It does not fall back. A fallback to
LOWER()would be the ASCII-only comparison this removes — fewer rows, no signal, the original defect hidden behind its own fix.UnicodeFolding::registerOn()refuses at connect time if the PDO cannot carry a user function, and a connection that somehow escaped registration raises SQLite's ownno such function: beatrax_fold, naming the connection. Both are loud; neither is an empty result set.Proof — the two inversions
1. The search test is red without the fix. Reverting
LikeNeedle::predicate()tocolumn LIKE ? ESCAPE '\'and undoing theEntityNameSearchfold:The ASCII control stays green, so the four failures are the fold and not the fixture.
2. The guard is red with the registration removed. Commenting out
$this->app->register(UnicodeFoldingProvider::class)— the registration actually taken out, not reasoned about:Each failure reads
SQLSTATE[HY000]: General error: 1 no such function: beatrax_fold (Connection: …)— which is the refusal, demonstrated rather than described.tests/Contracts/AFoldedQueryCannotRunOnAnUnfoldedConnectionArchTest.phpenumerates connections fromconfig/database.phprather than naming them (each redirected at:memory:so it never opens the reader's own ledger), pins the resulting list so a new connection has to be looked at, carries its own negative control on the replay case, and gives the tree walk a positive control so "no offenders" and "read nothing" cannot look the same.Cost
Folding a column defeats an index on it. Neither arm ever used one —
LIKE '%needle%'cannot, and the previousLOWER(name) LIKE 'needle%'could not either. What it adds is one PHP call per row already being read: measured over a 50,000-row table scanned end to end, 24.2 ms plain against 56.2 ms folded. TheLIKEarm's scan is bounded byFtsCandidateResolver::LIKE_FALLBACK_CANDIDATE_CAPand is only reached by a needle too short to tokenise.What still folds independently, named
Modules/Counterparties/.../CounterpartySlugResolverusesstrtolower(self::toAscii(...)). Deliberate and unchanged: it derives a URL slug, where ASCII is the point, not a reader's search needle.Modules/Ingestionnormalisation lower-cases and strips diacritics perA3-R3. A different rule for a different job — search folds case and keeps accents.LIKEarm folds throughmb_strtolower, the FTS arm through FTS5's own table. They agree across Latin, Greek and Cyrillic. They differ where a lowercase mapping expands —mb_strtolower('İ')isiplus a combining dot, FTS5 leavesİalone — and neither arm findsistanbulinİSTANBUL. A gap in both, not a disagreement between them, recorded in the docs.Spec
The spec said nothing about case folding in B9 — the page described two arms and never said how either compares. Since "search ignores case, including accents" is reader-visible behaviour, beatrax-app/spec#112 added
B9-R19and is merged:Spec: B9-R19
Docs
.docs/architecture/case-folding-is-one-function.md— the measurement, the function, every connection path, the refusal-over-fallback decision, the residuals, the cost..docs/features/search/architecture.md— "Where the two arms disagree" recorded this as known and deliberately not fixed, and named a UDF as the fix somebody would have to take as a decision about the application's connections. Rewritten as "How the two arms compare".Gates
pint --test,phpstan analyse(level 10 strict),composer analyse:depsall clean. Tests run by file: the wholeModules/Searchsuite (178 passed), the new folding test, the new contract guard,BoundaryArchTest,CommentPolicyArchTest,CommentsNameSymbolsThatExistArchTest,OneConnectionHoldsOneBusyTimeoutArchTest, and the Core connection/pragma tests.