[3.0] Case insensitive comparisons (part 1 of 2) — a {ci:} type for the query language - #9596
[3.0] Case insensitive comparisons (part 1 of 2) — a {ci:} type for the query language#9596albertlast wants to merge 2 commits into
Conversation
Whether a string comparison folds case is decided by the database engine:
MySQL folds it in the column's collation, PostgreSQL compares exactly. Callers
handled that themselves by reading Db::$db->case_sensitive and wrapping the
column in LOWER(), which put the decision at every call site and left it out
wherever somebody did not think to add it.
{ci:column} moves it into the query string, where the substitution layer
already lives. It expands to the bare column on MySQL and to LOWER(column) on
PostgreSQL. {ci_string:key} is the matching value type, for the places that
were folding the value in SQL rather than in PHP.
The column is named inline rather than through $db_values, so that a
comparison shows in the query text which column it folds. Only a column name,
optionally qualified by a table alias, is accepted.
Memberlist keeps its own LOWER() loop, because it folds expressions such as
COALESCE(group_name, '') as well as plain columns, and those are not what
{ci:} accepts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
|
I'm not certain if it should be We should support as well |
| } else { | ||
| $query_parts[] = '(' . implode(' LIKE {string:' . $param_name . '_normal} OR ', $param_info['db_fields']) . ' LIKE {string:' . $param_name . '_normal})'; | ||
| } | ||
| $query_parts[] = '({ci:' . implode('} LIKE {string:' . $param_name . '_normal} OR {ci:', $param_info['db_fields']) . '} LIKE {string:' . $param_name . '_normal})'; |
There was a problem hiding this comment.
Shouldn't this use {ci_string}?
The array_ modifier is already a prefix on the types that take a list, so a ci
prefix would leave the list form as {array_ci_string} or {ci_array_string}.
As a suffix it composes with what is there: {string_ci} beside {string}, and
{array_string_ci} beside {array_string}.
{array_string_ci} folds each value in the list the way {string_ci} folds one,
which lets User::addQueryCustomizationsForLoadType() hand the names over as
they came instead of folding them itself.
That last one is a behaviour change, and the only one in this branch. Folding
the names in PHP with strtolower() left them compared against a column folded
by SQL LOWER(), and the two disagree outside ASCII: a member named ÄNNA gives
'änna' on the column and 'Änna' from strtolower(), which never match on
PostgreSQL. Both sides now fold the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
|
Both done, and you are right about the suffix for a reason I had not spotted: Pushed as 6fb2bde. Adding the array type paid for itself immediately: $query_customizations['where'][] = '{ci:mem.member_name} IN ({array_string_ci:users})';
$query_customizations['params']['users'] = $users;That is a behaviour change, and the only one in the branch, so flagging it rather than burying it. The old code folded the names with One naming question I did not want to decide for you: I left the column type as #9597 follows the rename, and |
|
I would like to see some feedback from @Sesquipedalian on this. Its the right way to go codewise to ensure we handle this. But this PR does not address the database performance side of the equation. We still need to address that. Each one of these columns we are natively handling here and has a index in pg, needs that index to be ci transformed. This does create a mismatch in the index creation between mysql and pg, but for performance reasons it should be accepted. |
|
And don't worry on the behaviour change. It sounds like that is a case where we didn't handle UTF-8 properly and now that we have the proper extensions, we are actually handling the query right. |
|
Agreed that this branch does not touch the index side. I measured it rather than guessing, on PostgreSQL 17.10 with 200,000 rows, and one of the three findings narrows the problem quite a lot. 1.
|
| query | plan | time |
|---|---|---|
LOWER(name) = 'user12345' |
Index Scan | 0.17 ms |
LOWER(name) IN ('user12345','user2') |
Bitmap Heap Scan | 0.21 ms |
LOWER(name) LIKE 'user123%' |
Bitmap Heap Scan | 0.64 ms |
So the two columns carrying the autosuggest, the member lookup and the PM recipient resolution need no index work at all.
2. email_address is the real gap, and it is a regression that predates this PR
idx_email_address is created for both engines, on the raw column (Members.php:381-389). A folded comparison cannot use it:
| query | plan | time |
|---|---|---|
email = 'user12345@example.com' |
Index Scan using idx_email_address |
0.101 ms |
LOWER(email) = 'user12345@example.com' |
parallel Seq Scan | 43.4 ms |
About 430x, on the registration duplicate-email check and on every profile email change.
To be fair about where it came from: it arrived in 959007f, which changed email_address = {string:email_address} to {raw:email_address_field} with the LOWER() ternary. This PR only carries it forward as {ci:email_address}, which expands to the same thing. MySQL is unaffected either way, since {ci:} leaves the column bare there.
3. Three ways to close it, two of them measured equivalent
- A functional index,
LOWER(email_address) varchar_pattern_ops, in the PostgreSQL-only block beside the two that are already there → 0.119 ms. This is what you are proposing, and it works. - A stored generated column with a plain index → 0.128 ms. Same result, but no index divergence between the engines, and we already have
GeneratedColumnin the schema layer (Messages.phpuses it). Worth weighing, because the functional index is the less portable of the two: MySQL needs 8.0.13 for functional indexes and MariaDB does not support them at all, whereas an indexed generated column works on all three. - Normalising the address on write. Then the comparison goes back to a plain
=on the index we already create — 0.101 ms, no new index, no divergence, and nothing to keep in sync.
The third one is worth reading next to @Sesquipedalian's comment on #9592, because it answers both questions with one change. He argued there that we should fold the domain part of an address and stop folding the local part, on RFC 5321 grounds. If addresses are normalised on write, the query stops folding anything at run time, which settles the correctness question he raised and the performance question you are raising at the same time.
The other columns
group_name (membergroups is tiny) and website_title / website_url (admin member search only) need nothing. pm.from_name has no index on it at all, before or after this branch, so searching personal messages by author was always a scan — worth knowing, but not a regression. poster_name will want the same look when #9593 is fixed.
One that no index solves: LIKE '%term%' with a leading wildcard cannot use a btree at all, 50 ms measured. That is the memberlist and the admin member search. It is independent of case folding and would need pg_trgm.
I am happy to add the index in whichever form you two prefer, either here or as a follow-up. Holding off until you and @Sesquipedalian have weighed in, since the third option would make the first two unnecessary.
|
Regarding Regarding the email addresses, here's what I want to do:
Basically, these last two points amount to using However, this plan goes beyond the scope of this pull request. So I think that this pull request can proceed with the |
|
In case it wasn't clear, @albertlast, once the change from |
|
In ~ 8h I could deliver the change. |
Description
Whether a string comparison folds case is decided by the database engine. MySQL folds
it in the column's collation; PostgreSQL compares exactly. Today every caller that cares
has to know this, read
Db::$db->case_sensitive, and wrap the column inLOWER()itself:That puts the decision at each call site and defaults to the wrong answer on PostgreSQL
when somebody does not think to add it. It also fails quietly — the query returns fewer
rows rather than erroring, so nothing reaches
smf_log_errors. #9592, #9593 and #9594 areall that shape.
This moves the decision into the query string, where the substitution layer already lives.
{ci:column}expands tocolumnon MySQL andLOWER(column)on PostgreSQL.The column is named inline rather than through
$db_values, so a comparison shows inthe query text which column it folds. Only a column name, optionally qualified by a
table alias, is accepted.
{string_ci:key}and{array_string_ci:key}are the matching value types.The suffix rather than a prefix, because
array_already occupies the prefix positionon the existing types, so
{array_string_ci}composes where{array_ci_string}wouldnot.
Then converts the call sites that were already branching on
Db::$db->case_sensitive.Every conversion expands to exactly what the ternary it replaces produced, on both
engines, with one deliberate exception:
User::addQueryCustomizationsForLoadType()changes behaviour. It used to fold thenames with
strtolower()in PHP and compare them against a column folded by SQLLOWER(). Those disagree outside ASCII —ÄNNAfolds toännaon the column and toÄnnain PHP — so loading that member by name never matched on PostgreSQL. It nowhands the list to
{array_string_ci:}and both sides fold the same way. This is theonly behaviour change in the branch, and it is the last caller of
Db::$db->case_sensitiveoutsideMemberlist.Memberlistkeeps its ownLOWER()loop. It folds expressions such asCOALESCE(group_name, {string:blank_string})as well as plain columns, and anexpression is not what
{ci:}accepts. Only its value side is converted.Not reachable from the unit suite:
replacement__callback()is protected and reachedthrough
quote(), which needs a live connection to escape with. Part 2 adds the testthat is possible without one — a guard over the source files, so that a new comparison
written the old way fails CI instead of being found years later.
Verified by hand on both engines from the Docker environment, and
composer lintandvendor/bin/phpunitare clean.Issues References (Fixes|Related|Closes)
case_sensitivebeing a per-engine constant rather than a property of the column