Search on SQLite: Postulo's Unicode LIKE spends about 1 microsecond per row and column, twice what an ASCII fast path needs #1134
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#1134
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What exists today
core/sqlite_like.py:36-51replaces SQLite'slikeon every connection (#505) with a Python function so thaticontains,iexact,istartswithandiendswithfold case in every script. Each call converts both arguments withstr(), looks the pattern up in anlru_cacheof compiled regular expressions (_compile, lines 19-33, which turns%term%into.*term.*withre.IGNORECASE | re.DOTALL) and runsfullmatch. SQLite calls it once per row per searched column, so a list filter that searches several columns calls it rows times columns.The scout named
str(value)as the cost; that is wrong.str()of astrreturns the same object and costs a few nanoseconds. The cost is the Unicode case-insensitive regular expression with a leading.*, which for a miss tries every start position.Measurement (
work/s2.pyin the audit scratchpad,timeit, 200,000 calls each, the project's.venv, while other agents were running):%zurich%, best of 15A variant that keeps the regular expression but uses
searchwithout the.*wrappers was also tried: no gain (47.1 ms on the same table), so the regex engine's Unicode case folding is the cost, not the backtracking.What would change
In
like, before the regular expression: when the value is astrandvalue.isascii()(constant time in CPython) and the pattern, parsed with the same escape rule as_compile, is%literal%with no other unescaped wildcard and an ASCII literal, answerliteral.lower() in value.lower(). The parsed literal is cached beside_compile.literal%(istartswith),%literal(iendswith) and a bare literal (iexact) takestartswith,endswithand==the same way. Everything else, including any non-ASCII value or pattern, takes the current path unchanged.Behaviour is identical: for strings that contain only ASCII, Python's Unicode
IGNORECASEfolds exactly A-Z to a-z, which is whatstr.lower()does to ASCII; characters whose folding crosses into ASCII (the Kelvin sign, long s, dotted capital I,ß) are non-ASCII and so never reach the fast path. Checked: 0 differences between the current function and the fast path over 100,000 random ASCII pattern and value pairs including%,_and the escape character; the test files below were not run.tests/test_search.pyandtests/test_tables.py, which hold the #505 examples École and Αθηναϊκή, must keep passing. A unit test oflikecomparing both paths over a fixed table of ASCII, escape and non-ASCII cases guards the equivalence.No library is needed; SQLite's ICU extension would replace the function entirely but is not in the Python standard library build.
What it saves or protects
small: a text search on SQLite about 40 % faster where the text is ASCII (1,000 ns down to about 400 ns per row and column; 48 ms down to 29 ms over 20,000 rows and 2 columns); none on PostgreSQL
Accessibility
none
Decisions
istartswith,iendswithandiexacttoo? Recommended: yes, the same parse gives all four shapes.Found by the efficiency and reliability audit of 2026-10-30 (finding S2; index postulo/postulo#1108). Checked against
mainatdaee30b15(2026-10-30).