From 45d1c67a0ba9b041331252bc590e46439eede6f0 Mon Sep 17 00:00:00 2001 From: albertlast Date: Mon, 31 Aug 2026 15:37:23 +0200 Subject: [PATCH 1/7] Adds a {ci:} query type for case insensitive column comparisons 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 Signed-off-by: albertlast --- Sources/Actions/Admin/Members.php | 6 +----- Sources/Actions/AutoSuggest.php | 6 ++---- Sources/Actions/Memberlist.php | 4 +++- Sources/Actions/Register2.php | 5 ++--- Sources/Actions/RequestMembers.php | 3 +-- Sources/Db/APIs/MySQL.php | 13 +++++++++++++ Sources/Db/APIs/PostgreSQL.php | 15 +++++++++++++++ Sources/PersonalMessage/PM.php | 2 +- Sources/PersonalMessage/Search.php | 9 ++------- Sources/Profile.php | 3 +-- Sources/User.php | 19 +++++++++---------- 11 files changed, 50 insertions(+), 35 deletions(-) diff --git a/Sources/Actions/Admin/Members.php b/Sources/Actions/Admin/Members.php index e022b0e9e72..ec4d4e10960 100644 --- a/Sources/Actions/Admin/Members.php +++ b/Sources/Actions/Admin/Members.php @@ -455,11 +455,7 @@ public function view(): void // Replace the wildcard characters ('*' and '?') into MySQL ones. $parameter = strtolower(strtr(Utils::htmlspecialchars($search_params[$param_name], ENT_QUOTES), ['%' => '\\%', '_' => '\\_', '*' => '%', '?' => '_'])); - if (Db::$db->case_sensitive) { - $query_parts[] = '(LOWER(' . implode(') LIKE {string:' . $param_name . '_normal} OR LOWER(', $param_info['db_fields']) . ') LIKE {string:' . $param_name . '_normal})'; - } 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})'; $where_params[$param_name . '_normal'] = '%' . $parameter . '%'; } diff --git a/Sources/Actions/AutoSuggest.php b/Sources/Actions/AutoSuggest.php index 3bf58e94dce..a1a521bd8f9 100644 --- a/Sources/Actions/AutoSuggest.php +++ b/Sources/Actions/AutoSuggest.php @@ -146,12 +146,11 @@ public function member(): array $request = Db::$db->query( 'SELECT id_member, real_name FROM {db_prefix}members - WHERE {raw:real_name} LIKE {string:search}' . (!empty($this->search_param['buddies']) ? ' + WHERE {ci:real_name} LIKE {string:search}' . (!empty($this->search_param['buddies']) ? ' AND id_member IN ({array_int:buddy_list})' : '') . ' AND is_activated IN ({array_int:activated}) LIMIT ' . (Utils::entityStrlen($this->search) <= 2 ? '100' : '800'), [ - 'real_name' => Db::$db->case_sensitive ? 'LOWER(real_name)' : 'real_name', 'buddy_list' => User::$me->buddies, 'search' => $this->search, 'activated' => [User::ACTIVATED, User::ACTIVATED_BANNED], @@ -195,12 +194,11 @@ public function membergroups(): array $request = Db::$db->query( 'SELECT id_group, group_name FROM {db_prefix}membergroups - WHERE {raw:group_name} LIKE {string:search} + WHERE {ci:group_name} LIKE {string:search} AND min_posts = {int:min_posts} AND id_group NOT IN ({array_int:invalid_groups}) AND hidden != {int:hidden}', [ - 'group_name' => Db::$db->case_sensitive ? 'LOWER(group_name)' : 'group_name', 'min_posts' => -1, 'invalid_groups' => [1, 3], 'hidden' => 2, diff --git a/Sources/Actions/Memberlist.php b/Sources/Actions/Memberlist.php index c8843d90a4f..e7029978c53 100644 --- a/Sources/Actions/Memberlist.php +++ b/Sources/Actions/Memberlist.php @@ -521,6 +521,8 @@ public function search(): void $search_fields[] = 'email'; } + // These are expressions as well as plain columns, so they are + // folded here rather than through the {ci:} type. if (Db::$db->case_sensitive) { foreach ($fields as $key => $field) { $fields[$key] = 'LOWER(' . $field . ')'; @@ -545,7 +547,7 @@ public function search(): void ErrorHandler::fatalLang('invalid_search_string', false); } - $query = $_POST['search'] == '' ? '= {string:blank_string}' : (Db::$db->case_sensitive ? 'LIKE LOWER({string:search})' : 'LIKE {string:search}'); + $query = $_POST['search'] == '' ? '= {string:blank_string}' : 'LIKE {ci_string:search}'; $request = Db::$db->query( 'SELECT COUNT(*) diff --git a/Sources/Actions/Register2.php b/Sources/Actions/Register2.php index a650e23cd9a..4a971ff96f9 100644 --- a/Sources/Actions/Register2.php +++ b/Sources/Actions/Register2.php @@ -536,11 +536,10 @@ public static function registerMember(array &$reg_options, bool $return_errors = $request = Db::$db->query( 'SELECT id_member FROM {db_prefix}members - WHERE {raw:email_address_field} = {string:email_address} - OR {raw:email_address_field} = {string:username} + WHERE {ci:email_address} = {string:email_address} + OR {ci:email_address} = {string:username} LIMIT 1', [ - 'email_address_field' => Db::$db->case_sensitive ? 'LOWER(email_address)' : 'email_address', 'email_address' => $reg_options['email'], 'username' => $reg_options['username'], ], diff --git a/Sources/Actions/RequestMembers.php b/Sources/Actions/RequestMembers.php index 094f35c49c2..8a47435310f 100644 --- a/Sources/Actions/RequestMembers.php +++ b/Sources/Actions/RequestMembers.php @@ -74,12 +74,11 @@ public function execute(): void $request = Db::$db->query( 'SELECT real_name FROM {db_prefix}members - WHERE {raw:real_name} LIKE {string:search}' . (isset($_REQUEST['buddies']) ? ' + WHERE {ci:real_name} LIKE {string:search}' . (isset($_REQUEST['buddies']) ? ' AND id_member IN ({array_int:buddy_list})' : '') . ' AND is_activated IN ({array_int:activated}) LIMIT {int:limit}', [ - 'real_name' => Db::$db->case_sensitive ? 'LOWER(real_name)' : 'real_name', 'buddy_list' => User::$me->buddies, 'search' => $this->search, 'activated' => [User::ACTIVATED, User::ACTIVATED_BANNED], diff --git a/Sources/Db/APIs/MySQL.php b/Sources/Db/APIs/MySQL.php index 96542f47297..a97238e8944 100644 --- a/Sources/Db/APIs/MySQL.php +++ b/Sources/Db/APIs/MySQL.php @@ -282,6 +282,11 @@ public function quote(string $db_string, array $db_values, ?object $connection = [ // The literal type can have arbitrary content. '~{(literal):([^}]*)}~', + // The ci type names a column inline rather than by key, so + // that the column a comparison is case folding is visible in + // the query itself. Only a column name, optionally qualified + // by a table alias, is accepted. + '~{(ci):([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?)}~', // Everything else needs to be a key in $db_values. '~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', ], @@ -2753,6 +2758,12 @@ protected function replacement__callback(array $matches, array $db_values, objec return '\'' . mysqli_real_escape_string($connection, $matches[2]) . '\''; } + // MySQL folds case in the column's collation, so a case insensitive + // comparison is what a bare column already does. + if ($matches[1] === 'ci') { + return $matches[2]; + } + if (!\array_key_exists($matches[2], $db_values)) { $this->error_backtrace('The database value you\'re trying to insert does not exist: ' . Utils::htmlspecialchars($matches[2]), '', E_USER_ERROR, __FILE__, __LINE__); } @@ -2777,6 +2788,8 @@ protected function replacement__callback(array $matches, array $db_values, objec case 'string': case 'text': + // MySQL folds the case of the value in the collation as well. + case 'ci_string': return \sprintf('\'%1$s\'', mysqli_real_escape_string($connection, $this->fix_mb4((string) $replacement))); case 'array_int': diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index c0ba1e0668a..28054bd4c37 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -343,6 +343,11 @@ public function quote(string $db_string, array $db_values, ?object $connection = [ // The literal type can have arbitrary content. '~{(literal):([^}]*)}~', + // The ci type names a column inline rather than by key, so + // that the column a comparison is case folding is visible in + // the query itself. Only a column name, optionally qualified + // by a table alias, is accepted. + '~{(ci):([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?)}~', // Everything else needs to be a key in $db_values. '~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', ], @@ -2684,6 +2689,12 @@ protected function replacement__callback(array $matches, array $db_values, objec return '\'' . pg_escape_string($this->connection, $matches[2]) . '\''; } + // PostgreSQL compares strings exactly, so a case insensitive comparison + // folds the column and pairs it with a value folded the same way. + if ($matches[1] === 'ci') { + return 'LOWER(' . $matches[2] . ')'; + } + if (!\array_key_exists($matches[2], $db_values)) { $this->error_backtrace('The database value you\'re trying to insert does not exist: ' . Utils::htmlspecialchars($matches[2]), '', E_USER_ERROR, __FILE__, __LINE__); } @@ -2710,6 +2721,10 @@ protected function replacement__callback(array $matches, array $db_values, objec case 'text': return \sprintf('\'%1$s\'', pg_escape_string($this->connection, (string) $replacement)); + // Folded to match a column that {ci:} has folded. + case 'ci_string': + return \sprintf('LOWER(\'%1$s\')', pg_escape_string($this->connection, (string) $replacement)); + case 'array_int': if (\is_array($replacement)) { if (empty($replacement)) { diff --git a/Sources/PersonalMessage/PM.php b/Sources/PersonalMessage/PM.php index b2f3dcb1c0d..de6e53a2060 100644 --- a/Sources/PersonalMessage/PM.php +++ b/Sources/PersonalMessage/PM.php @@ -1210,7 +1210,7 @@ public static function send(array $recipients, string $subject, string $message, $request = Db::$db->query( 'SELECT id_member, member_name FROM {db_prefix}members - WHERE ' . (Db::$db->case_sensitive ? 'LOWER(member_name)' : 'member_name') . ' IN ({array_string:usernames})', + WHERE {ci:member_name} IN ({array_string:usernames})', [ 'usernames' => array_keys($usernames), ], diff --git a/Sources/PersonalMessage/Search.php b/Sources/PersonalMessage/Search.php index fb3cf24d9a3..a8da23f265b 100644 --- a/Sources/PersonalMessage/Search.php +++ b/Sources/PersonalMessage/Search.php @@ -478,11 +478,7 @@ protected function setUserQuery(): void foreach ($possible_users as $k => $v) { $where_params['name_' . $k] = $v; - $where_clause[] = '{raw:real_name} LIKE {string:name_' . $k . '}'; - - if (!isset($where_params['real_name'])) { - $where_params['real_name'] = Db::$db->case_sensitive ? 'LOWER(real_name)' : 'real_name'; - } + $where_clause[] = '{ci:real_name} LIKE {string:name_' . $k . '}'; } // Who matches those criteria? @@ -498,12 +494,11 @@ protected function setUserQuery(): void if (Db::$db->num_rows($request) > $this->max_members_to_search) { $this->user_query = ''; } else { - $this->searchq_parameters['real_name'] = Db::$db->case_sensitive ? 'LOWER(pm.from_name)' : 'pm.from_name'; $clauses = []; foreach ($possible_users as $k => $v) { $this->searchq_parameters['name_' . $k] = $v; - $clauses[] = '{raw:real_name} LIKE {string:name_' . $k . '}'; + $clauses[] = '{ci:pm.from_name} LIKE {string:name_' . $k . '}'; } if (Db::$db->num_rows($request) == 0) { diff --git a/Sources/Profile.php b/Sources/Profile.php index 1e156a88697..8568ce2db03 100644 --- a/Sources/Profile.php +++ b/Sources/Profile.php @@ -1649,10 +1649,9 @@ public function validateEmail(string $email): bool|string 'SELECT id_member FROM {db_prefix}members WHERE id_member != {int:selected_member} - AND {raw:email_address_field} = {string:email_address} + AND {ci:email_address} = {string:email_address} LIMIT 1', [ - 'email_address_field' => Db::$db->case_sensitive ? 'LOWER(email_address)' : 'email_address', 'selected_member' => $this->id, 'email_address' => $email, ], diff --git a/Sources/User.php b/Sources/User.php index d8399a01a80..22c80efee14 100644 --- a/Sources/User.php +++ b/Sources/User.php @@ -3800,9 +3800,10 @@ public static function find(string|array $names, bool $use_wildcards = false, bo $email_condition = ''; } - // Get the case of the columns right - but only if we need to as things like MySQL will go slow needlessly otherwise. - $member_name = Db::$db->case_sensitive ? 'LOWER(member_name)' : 'member_name'; - $real_name = Db::$db->case_sensitive ? 'LOWER(real_name)' : 'real_name'; + // The {ci:} type folds the column for the engines that need it and + // leaves it alone for the ones that do not. + $member_name = '{ci:member_name}'; + $real_name = '{ci:real_name}'; // Searches. $member_name_search = $member_name . ' ' . $comparison . ' ' . implode(' OR ' . $member_name . ' ' . $comparison . ' ', $names_list); @@ -5424,13 +5425,11 @@ protected static function addQueryCustomizationsForLoadType(array &$query_custom break; case self::LOAD_BY_NAME: - if (Db::$db->case_sensitive) { - $query_customizations['where'][] = 'LOWER(mem.member_name) IN ({array_string:users})'; - $query_customizations['params']['users'] = array_map('strtolower', $users); - } else { - $query_customizations['where'][] = 'mem.member_name IN ({array_string:users})'; - $query_customizations['params']['users'] = $users; - } + $query_customizations['where'][] = '{ci:mem.member_name} IN ({array_string:users})'; + + // An array of values has no {ci:} of its own, so the names are + // folded here for the engines that compare them exactly. + $query_customizations['params']['users'] = Db::$db->case_sensitive ? array_map('strtolower', $users) : $users; break; From 6fb2bde8925dbf6dcec29b5c21f8132306918d89 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 2 Sep 2026 08:05:24 +0200 Subject: [PATCH 2/7] Names the case folding value types by suffix, and adds the array one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: albertlast --- Sources/Actions/Memberlist.php | 2 +- Sources/Db/APIs/MySQL.php | 4 +++- Sources/Db/APIs/PostgreSQL.php | 20 +++++++++++++++++++- Sources/User.php | 7 ++----- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Sources/Actions/Memberlist.php b/Sources/Actions/Memberlist.php index e7029978c53..c33ae72582a 100644 --- a/Sources/Actions/Memberlist.php +++ b/Sources/Actions/Memberlist.php @@ -547,7 +547,7 @@ public function search(): void ErrorHandler::fatalLang('invalid_search_string', false); } - $query = $_POST['search'] == '' ? '= {string:blank_string}' : 'LIKE {ci_string:search}'; + $query = $_POST['search'] == '' ? '= {string:blank_string}' : 'LIKE {string_ci:search}'; $request = Db::$db->query( 'SELECT COUNT(*) diff --git a/Sources/Db/APIs/MySQL.php b/Sources/Db/APIs/MySQL.php index a97238e8944..f09faad958b 100644 --- a/Sources/Db/APIs/MySQL.php +++ b/Sources/Db/APIs/MySQL.php @@ -2789,7 +2789,7 @@ protected function replacement__callback(array $matches, array $db_values, objec case 'string': case 'text': // MySQL folds the case of the value in the collation as well. - case 'ci_string': + case 'string_ci': return \sprintf('\'%1$s\'', mysqli_real_escape_string($connection, $this->fix_mb4((string) $replacement))); case 'array_int': @@ -2814,6 +2814,8 @@ protected function replacement__callback(array $matches, array $db_values, objec break; case 'array_string': + // As above, the collation folds each of these too. + case 'array_string_ci': if (\is_array($replacement)) { if (empty($replacement)) { $this->error_backtrace('Database error, given array of string values is empty. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index 28054bd4c37..8df9846b306 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -2722,7 +2722,7 @@ protected function replacement__callback(array $matches, array $db_values, objec return \sprintf('\'%1$s\'', pg_escape_string($this->connection, (string) $replacement)); // Folded to match a column that {ci:} has folded. - case 'ci_string': + case 'string_ci': return \sprintf('LOWER(\'%1$s\')', pg_escape_string($this->connection, (string) $replacement)); case 'array_int': @@ -2763,6 +2763,24 @@ protected function replacement__callback(array $matches, array $db_values, objec break; + // Each of these folded to match a column that {ci:} has folded. + case 'array_string_ci': + if (\is_array($replacement)) { + if (empty($replacement)) { + $this->error_backtrace('Database error, given array of string values is empty. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); + } + + foreach ($replacement as $key => $value) { + $replacement[$key] = \sprintf('LOWER(\'%1$s\')', pg_escape_string($this->connection, (string) $value)); + } + + return implode(', ', $replacement); + } + + $this->error_backtrace('Wrong value type sent to the database. Array of strings expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); + + break; + case 'array_uuid': if (\is_array($replacement)) { if (empty($replacement)) { diff --git a/Sources/User.php b/Sources/User.php index 22c80efee14..75b632edaf6 100644 --- a/Sources/User.php +++ b/Sources/User.php @@ -5425,11 +5425,8 @@ protected static function addQueryCustomizationsForLoadType(array &$query_custom break; case self::LOAD_BY_NAME: - $query_customizations['where'][] = '{ci:mem.member_name} IN ({array_string:users})'; - - // An array of values has no {ci:} of its own, so the names are - // folded here for the engines that compare them exactly. - $query_customizations['params']['users'] = Db::$db->case_sensitive ? array_map('strtolower', $users) : $users; + $query_customizations['where'][] = '{ci:mem.member_name} IN ({array_string_ci:users})'; + $query_customizations['params']['users'] = $users; break; From 9f9f4792661467f1d79d3a302104888801ccd0da Mon Sep 17 00:00:00 2001 From: albertlast Date: Thu, 3 Sep 2026 16:04:30 +0200 Subject: [PATCH 3/7] Names the column type column_ci The token says what it takes. {ci:} left a reader to work out that the thing inside it was a column name rather than a value key, which is the opposite way round from every other type in the query language. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Sources/Actions/Admin/Members.php | 2 +- Sources/Actions/AutoSuggest.php | 4 ++-- Sources/Actions/Memberlist.php | 2 +- Sources/Actions/Register2.php | 4 ++-- Sources/Actions/RequestMembers.php | 2 +- Sources/Db/APIs/MySQL.php | 12 ++++++------ Sources/Db/APIs/PostgreSQL.php | 16 ++++++++-------- Sources/PersonalMessage/PM.php | 2 +- Sources/PersonalMessage/Search.php | 4 ++-- Sources/Profile.php | 2 +- Sources/User.php | 8 ++++---- 11 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Sources/Actions/Admin/Members.php b/Sources/Actions/Admin/Members.php index ec4d4e10960..77ab0e08064 100644 --- a/Sources/Actions/Admin/Members.php +++ b/Sources/Actions/Admin/Members.php @@ -455,7 +455,7 @@ public function view(): void // Replace the wildcard characters ('*' and '?') into MySQL ones. $parameter = strtolower(strtr(Utils::htmlspecialchars($search_params[$param_name], ENT_QUOTES), ['%' => '\\%', '_' => '\\_', '*' => '%', '?' => '_'])); - $query_parts[] = '({ci:' . implode('} LIKE {string:' . $param_name . '_normal} OR {ci:', $param_info['db_fields']) . '} LIKE {string:' . $param_name . '_normal})'; + $query_parts[] = '({column_ci:' . implode('} LIKE {string:' . $param_name . '_normal} OR {column_ci:', $param_info['db_fields']) . '} LIKE {string:' . $param_name . '_normal})'; $where_params[$param_name . '_normal'] = '%' . $parameter . '%'; } diff --git a/Sources/Actions/AutoSuggest.php b/Sources/Actions/AutoSuggest.php index a1a521bd8f9..0f293e45b47 100644 --- a/Sources/Actions/AutoSuggest.php +++ b/Sources/Actions/AutoSuggest.php @@ -146,7 +146,7 @@ public function member(): array $request = Db::$db->query( 'SELECT id_member, real_name FROM {db_prefix}members - WHERE {ci:real_name} LIKE {string:search}' . (!empty($this->search_param['buddies']) ? ' + WHERE {column_ci:real_name} LIKE {string:search}' . (!empty($this->search_param['buddies']) ? ' AND id_member IN ({array_int:buddy_list})' : '') . ' AND is_activated IN ({array_int:activated}) LIMIT ' . (Utils::entityStrlen($this->search) <= 2 ? '100' : '800'), @@ -194,7 +194,7 @@ public function membergroups(): array $request = Db::$db->query( 'SELECT id_group, group_name FROM {db_prefix}membergroups - WHERE {ci:group_name} LIKE {string:search} + WHERE {column_ci:group_name} LIKE {string:search} AND min_posts = {int:min_posts} AND id_group NOT IN ({array_int:invalid_groups}) AND hidden != {int:hidden}', diff --git a/Sources/Actions/Memberlist.php b/Sources/Actions/Memberlist.php index c33ae72582a..5e92dbc248c 100644 --- a/Sources/Actions/Memberlist.php +++ b/Sources/Actions/Memberlist.php @@ -522,7 +522,7 @@ public function search(): void } // These are expressions as well as plain columns, so they are - // folded here rather than through the {ci:} type. + // folded here rather than through the {column_ci:} type. if (Db::$db->case_sensitive) { foreach ($fields as $key => $field) { $fields[$key] = 'LOWER(' . $field . ')'; diff --git a/Sources/Actions/Register2.php b/Sources/Actions/Register2.php index 4a971ff96f9..d70f3a486a2 100644 --- a/Sources/Actions/Register2.php +++ b/Sources/Actions/Register2.php @@ -536,8 +536,8 @@ public static function registerMember(array &$reg_options, bool $return_errors = $request = Db::$db->query( 'SELECT id_member FROM {db_prefix}members - WHERE {ci:email_address} = {string:email_address} - OR {ci:email_address} = {string:username} + WHERE {column_ci:email_address} = {string:email_address} + OR {column_ci:email_address} = {string:username} LIMIT 1', [ 'email_address' => $reg_options['email'], diff --git a/Sources/Actions/RequestMembers.php b/Sources/Actions/RequestMembers.php index 8a47435310f..b8f8167e2a5 100644 --- a/Sources/Actions/RequestMembers.php +++ b/Sources/Actions/RequestMembers.php @@ -74,7 +74,7 @@ public function execute(): void $request = Db::$db->query( 'SELECT real_name FROM {db_prefix}members - WHERE {ci:real_name} LIKE {string:search}' . (isset($_REQUEST['buddies']) ? ' + WHERE {column_ci:real_name} LIKE {string:search}' . (isset($_REQUEST['buddies']) ? ' AND id_member IN ({array_int:buddy_list})' : '') . ' AND is_activated IN ({array_int:activated}) LIMIT {int:limit}', diff --git a/Sources/Db/APIs/MySQL.php b/Sources/Db/APIs/MySQL.php index f09faad958b..73a09be6fce 100644 --- a/Sources/Db/APIs/MySQL.php +++ b/Sources/Db/APIs/MySQL.php @@ -282,11 +282,11 @@ public function quote(string $db_string, array $db_values, ?object $connection = [ // The literal type can have arbitrary content. '~{(literal):([^}]*)}~', - // The ci type names a column inline rather than by key, so - // that the column a comparison is case folding is visible in - // the query itself. Only a column name, optionally qualified - // by a table alias, is accepted. - '~{(ci):([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?)}~', + // The column_ci type names a column inline rather than by key, + // so that the column a comparison is case folding is visible + // in the query itself. Only a column name, optionally + // qualified by a table alias, is accepted. + '~{(column_ci):([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?)}~', // Everything else needs to be a key in $db_values. '~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', ], @@ -2760,7 +2760,7 @@ protected function replacement__callback(array $matches, array $db_values, objec // MySQL folds case in the column's collation, so a case insensitive // comparison is what a bare column already does. - if ($matches[1] === 'ci') { + if ($matches[1] === 'column_ci') { return $matches[2]; } diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index 8df9846b306..e3c39760ab9 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -343,11 +343,11 @@ public function quote(string $db_string, array $db_values, ?object $connection = [ // The literal type can have arbitrary content. '~{(literal):([^}]*)}~', - // The ci type names a column inline rather than by key, so - // that the column a comparison is case folding is visible in - // the query itself. Only a column name, optionally qualified - // by a table alias, is accepted. - '~{(ci):([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?)}~', + // The column_ci type names a column inline rather than by key, + // so that the column a comparison is case folding is visible + // in the query itself. Only a column name, optionally + // qualified by a table alias, is accepted. + '~{(column_ci):([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?)}~', // Everything else needs to be a key in $db_values. '~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', ], @@ -2691,7 +2691,7 @@ protected function replacement__callback(array $matches, array $db_values, objec // PostgreSQL compares strings exactly, so a case insensitive comparison // folds the column and pairs it with a value folded the same way. - if ($matches[1] === 'ci') { + if ($matches[1] === 'column_ci') { return 'LOWER(' . $matches[2] . ')'; } @@ -2721,7 +2721,7 @@ protected function replacement__callback(array $matches, array $db_values, objec case 'text': return \sprintf('\'%1$s\'', pg_escape_string($this->connection, (string) $replacement)); - // Folded to match a column that {ci:} has folded. + // Folded to match a column that {column_ci:} has folded. case 'string_ci': return \sprintf('LOWER(\'%1$s\')', pg_escape_string($this->connection, (string) $replacement)); @@ -2763,7 +2763,7 @@ protected function replacement__callback(array $matches, array $db_values, objec break; - // Each of these folded to match a column that {ci:} has folded. + // Each of these folded to match a column that {column_ci:} has folded. case 'array_string_ci': if (\is_array($replacement)) { if (empty($replacement)) { diff --git a/Sources/PersonalMessage/PM.php b/Sources/PersonalMessage/PM.php index de6e53a2060..a88fc98fa57 100644 --- a/Sources/PersonalMessage/PM.php +++ b/Sources/PersonalMessage/PM.php @@ -1210,7 +1210,7 @@ public static function send(array $recipients, string $subject, string $message, $request = Db::$db->query( 'SELECT id_member, member_name FROM {db_prefix}members - WHERE {ci:member_name} IN ({array_string:usernames})', + WHERE {column_ci:member_name} IN ({array_string:usernames})', [ 'usernames' => array_keys($usernames), ], diff --git a/Sources/PersonalMessage/Search.php b/Sources/PersonalMessage/Search.php index a8da23f265b..5433c07c669 100644 --- a/Sources/PersonalMessage/Search.php +++ b/Sources/PersonalMessage/Search.php @@ -478,7 +478,7 @@ protected function setUserQuery(): void foreach ($possible_users as $k => $v) { $where_params['name_' . $k] = $v; - $where_clause[] = '{ci:real_name} LIKE {string:name_' . $k . '}'; + $where_clause[] = '{column_ci:real_name} LIKE {string:name_' . $k . '}'; } // Who matches those criteria? @@ -498,7 +498,7 @@ protected function setUserQuery(): void foreach ($possible_users as $k => $v) { $this->searchq_parameters['name_' . $k] = $v; - $clauses[] = '{ci:pm.from_name} LIKE {string:name_' . $k . '}'; + $clauses[] = '{column_ci:pm.from_name} LIKE {string:name_' . $k . '}'; } if (Db::$db->num_rows($request) == 0) { diff --git a/Sources/Profile.php b/Sources/Profile.php index 8568ce2db03..0ddd3d12ceb 100644 --- a/Sources/Profile.php +++ b/Sources/Profile.php @@ -1649,7 +1649,7 @@ public function validateEmail(string $email): bool|string 'SELECT id_member FROM {db_prefix}members WHERE id_member != {int:selected_member} - AND {ci:email_address} = {string:email_address} + AND {column_ci:email_address} = {string:email_address} LIMIT 1', [ 'selected_member' => $this->id, diff --git a/Sources/User.php b/Sources/User.php index 75b632edaf6..c7567b271fe 100644 --- a/Sources/User.php +++ b/Sources/User.php @@ -3800,10 +3800,10 @@ public static function find(string|array $names, bool $use_wildcards = false, bo $email_condition = ''; } - // The {ci:} type folds the column for the engines that need it and + // The {column_ci:} type folds the column for the engines that need it and // leaves it alone for the ones that do not. - $member_name = '{ci:member_name}'; - $real_name = '{ci:real_name}'; + $member_name = '{column_ci:member_name}'; + $real_name = '{column_ci:real_name}'; // Searches. $member_name_search = $member_name . ' ' . $comparison . ' ' . implode(' OR ' . $member_name . ' ' . $comparison . ' ', $names_list); @@ -5425,7 +5425,7 @@ protected static function addQueryCustomizationsForLoadType(array &$query_custom break; case self::LOAD_BY_NAME: - $query_customizations['where'][] = '{ci:mem.member_name} IN ({array_string_ci:users})'; + $query_customizations['where'][] = '{column_ci:mem.member_name} IN ({array_string_ci:users})'; $query_customizations['params']['users'] = $users; break; From 322f4c77a27777d30d7f377b021db79795246abb Mon Sep 17 00:00:00 2001 From: albertlast Date: Mon, 31 Aug 2026 15:48:48 +0200 Subject: [PATCH 4/7] Guards the case folding convention in the unit suite A LIKE against text a person typed has to say whether it folds case, because the engines disagree about it. Written as a bare column it says nothing, and the query then matches on MySQL and not on PostgreSQL. That failure returns fewer rows instead of erroring, so it reaches neither the error log nor CI. This counts, per file, the comparisons on the columns holding names, email addresses and hostnames that do not fold case in the query text, and holds the count against a baseline. A new one fails the suite and names the file; fixing an old one means lowering its number in the same commit. The scan covers LIKE only. `member_name = {string:name}` and `$member_name = $string` are the same line to a scanner, and the SET clause of an UPDATE looks like both, so equality is left out rather than guessed at. The two comparisons in Security.php are folded by the 'ban_like' identifier at the point the query runs rather than in the query text, so they stay in the list with a note saying why. Reading every file under Sources costs about 60ms on a normal filesystem. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- tests/Unit/QueryCaseFoldingTest.php | 157 ++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/Unit/QueryCaseFoldingTest.php diff --git a/tests/Unit/QueryCaseFoldingTest.php b/tests/Unit/QueryCaseFoldingTest.php new file mode 100644 index 00000000000..54aa1de671b --- /dev/null +++ b/tests/Unit/QueryCaseFoldingTest.php @@ -0,0 +1,157 @@ + 3, + 'Sources/Actions/Admin/Subscriptions.php' => 1, + 'Sources/Actions/Profile/Summary.php' => 2, + 'Sources/Search/SearchApi.php' => 3, + 'Sources/Security.php' => 2, + ]; + + /**************** + * Public methods + ****************/ + + public function testNoNewComparisonSkipsTheCaseFoldingTypes(): void + { + $found = $this->scan(); + + $this->assertSame( + self::BASELINE, + $found, + "The set of case sensitive comparisons on user text has changed.\n\n" + . "If a count went up, a new comparison is relying on the engine's\n" + . "collation to fold case, which MySQL does and PostgreSQL does not.\n" + . "Write it as {ci:column} LIKE {string:value}, or as\n" + . "{ci:column} LIKE {ci_string:value} when the value is not already\n" + . "folded in PHP.\n\n" + . "If a count went down, the comparison was fixed. Update BASELINE\n" + . 'in this test to match, in the same commit.', + ); + } + + public function testTheScanFindsTheFormsItIsLookingFor(): void + { + // Without this, deleting the body of scan() would leave the test above + // passing against an empty baseline. + $this->assertNotSame([], $this->scan()); + } + + /****************** + * Internal methods + ******************/ + + /** + * Counts, per file, the comparisons on self::COLUMNS that do not fold case. + * + * @return array Paths relative to the repository root. + */ + protected function scan(): array + { + $found = []; + + $columns = '~\b(?:' . implode('|', self::COLUMNS) . ')\b~'; + $folded = '~\{ci:|\{ci_string:|LOWER\s*\(~i'; + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator(Config::$sourcedir, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $path = str_replace(DIRECTORY_SEPARATOR, '/', $file->getPathname()); + $contents = file_get_contents($path); + + // Most files never mention it, and reading them line by line to + // find that out is the whole cost of this scan. + if (!str_contains($contents, 'LIKE')) { + continue; + } + + $relative = 'Sources' . substr($path, \strlen(str_replace(DIRECTORY_SEPARATOR, '/', Config::$sourcedir))); + + foreach (explode("\n", $contents) as $line) { + $trimmed = trim($line); + + // Comments describe comparisons, they do not make them. + if (str_starts_with($trimmed, '*') || str_starts_with($trimmed, '//')) { + continue; + } + + if ( + preg_match('~\bLIKE\b~', $line) + && preg_match($columns, $line) + && !preg_match($folded, $line) + ) { + $found[$relative] = ($found[$relative] ?? 0) + 1; + } + } + } + + ksort($found); + + return $found; + } +} From f2ab1e88b4ca06f20e26bc0513f6475edf1ffaf8 Mon Sep 17 00:00:00 2001 From: albertlast Date: Mon, 31 Aug 2026 16:03:03 +0200 Subject: [PATCH 5/7] Catches a comparison that folds its column but not its value Folding one side is worse than folding neither. A folded column can never equal an unfolded value, so rather than matching too much on PostgreSQL the comparison matches nothing at all, including the row it was looking for. It is also the harder one to see, because the query says {ci:} and reads as handled. The first scan cannot find these, by construction: it treats {ci:} as proof that a decision was made. So a second scan lists the comparisons that fold a column while comparing it against a value the query leaves alone. Those are correct only when the caller folded the value in PHP, which is a claim about code elsewhere, so the list names which callers do and which do not. This one reads equality as well as LIKE. A line carrying {ci:} is SQL, so an = on it is a comparison rather than a PHP assignment, which is what put equality out of reach of the first scan. Five of the eleven entries are known faults: Register2.php and Profile.php are #9594, and PersonalMessage/Search.php is the same fault in the search for a personal message by its author. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- tests/Unit/QueryCaseFoldingTest.php | 116 ++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 5 deletions(-) diff --git a/tests/Unit/QueryCaseFoldingTest.php b/tests/Unit/QueryCaseFoldingTest.php index 54aa1de671b..d0e7825121a 100644 --- a/tests/Unit/QueryCaseFoldingTest.php +++ b/tests/Unit/QueryCaseFoldingTest.php @@ -23,9 +23,16 @@ * needs a live connection to escape with, so the behaviour of {ci:} itself * cannot be reached from here. * - * Equality comparisons are not covered. `member_name = {string:name}` and - * `$member_name = $string` are the same line to a scanner, and the SET clause - * of an UPDATE looks like both, so the reliable signal is LIKE. + * There are two scans, because there are two ways to get this wrong. One looks + * for a comparison that folds neither side, which matches too much on MySQL. + * The other looks for one that folds only its column, which matches nothing at + * all on PostgreSQL, and is the worse of the two for being the one that hides: + * the query says {ci:} and looks handled. + * + * The first scan covers LIKE only, since `member_name = {string:name}` and + * `$member_name = $string` are the same line to a scanner, and an UPDATE's SET + * clause looks like both. The second can cover equality as well, because a + * line carrying {ci:} is SQL and its = is therefore a comparison. */ #[CoversNothing] class QueryCaseFoldingTest extends TestCase @@ -67,6 +74,41 @@ class QueryCaseFoldingTest extends TestCase 'Sources/Security.php' => 2, ]; + /** + * Comparisons where {ci:} folds the column but the value beside it is not + * folded in the query, counted per file. + * + * Folding one side is worse than folding neither. A folded column can + * never equal an unfolded value, so instead of matching too much on + * PostgreSQL the comparison matches nothing at all, including the row it + * was looking for. + * + * These are correct only if the caller folded the value in PHP first, + * which is a claim about code somewhere else and so is listed rather than + * counted. Folded by their callers: + * + * - Admin/Members.php, through strtolower(). + * - AutoSuggest.php, RequestMembers.php, PM.php, through + * Utils::strtolower(). + * - User.php, through array_map(), for the engines that need it. + * + * Not folded by their callers, and so matching nothing on PostgreSQL: + * + * - Register2.php and Profile.php, which is #9594. + * - PersonalMessage/Search.php, which is the same fault in the search for + * a personal message by its author. + */ + public const UNFOLDED_VALUES = [ + 'Sources/Actions/Admin/Members.php' => 1, + 'Sources/Actions/AutoSuggest.php' => 2, + 'Sources/Actions/Register2.php' => 2, + 'Sources/Actions/RequestMembers.php' => 1, + 'Sources/PersonalMessage/PM.php' => 1, + 'Sources/PersonalMessage/Search.php' => 2, + 'Sources/Profile.php' => 1, + 'Sources/User.php' => 1, + ]; + /**************** * Public methods ****************/ @@ -89,11 +131,28 @@ public function testNoNewComparisonSkipsTheCaseFoldingTypes(): void ); } - public function testTheScanFindsTheFormsItIsLookingFor(): void + public function testNoNewComparisonFoldsOnlyItsColumn(): void + { + $found = $this->scanUnfoldedValues(); + + $this->assertSame( + self::UNFOLDED_VALUES, + $found, + "The set of comparisons folding a column but not the value has changed.\n\n" + . "If a count went up, check that the caller folds the value before it\n" + . "reaches the query. If it does, add the file to UNFOLDED_VALUES and\n" + . "say so in the note there. If it does not, the comparison matches\n" + . "nothing on PostgreSQL: fold the value in PHP, or write it as\n" + . '{ci_string:value} and let the query fold it.', + ); + } + + public function testTheScansFindTheFormsTheyAreLookingFor(): void { - // Without this, deleting the body of scan() would leave the test above + // Without these, emptying either scan would leave the tests above // passing against an empty baseline. $this->assertNotSame([], $this->scan()); + $this->assertNotSame([], $this->scanUnfoldedValues()); } /****************** @@ -154,4 +213,51 @@ protected function scan(): array return $found; } + + /** + * Counts, per file, the comparisons that fold the column with {ci:} while + * comparing it against a value the query does not fold. + * + * A line carrying {ci:} is SQL, so an = on it is a comparison rather than + * a PHP assignment. That is what lets this one look at equality, which the + * scan above cannot. + * + * @return array Paths relative to the repository root. + */ + protected function scanUnfoldedValues(): array + { + $found = []; + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator(Config::$sourcedir, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $path = str_replace(DIRECTORY_SEPARATOR, '/', $file->getPathname()); + $contents = file_get_contents($path); + + if (!str_contains($contents, '{ci:')) { + continue; + } + + $relative = 'Sources' . substr($path, \strlen(str_replace(DIRECTORY_SEPARATOR, '/', Config::$sourcedir))); + + foreach (explode("\n", $contents) as $line) { + if ( + str_contains($line, '{ci:') + && preg_match('~\{(?:array_)?string:~', $line) + ) { + $found[$relative] = ($found[$relative] ?? 0) + 1; + } + } + } + + ksort($found); + + return $found; + } } From 0bf6eb862a771e7a9f4c5d320c57fe68c5585446 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 2 Sep 2026 08:07:25 +0200 Subject: [PATCH 6/7] Follows the value types to their suffix names {ci_string} became {string_ci} and gained {array_string_ci}, so the scan that decides whether a comparison declares its case handling has to recognise both. User.php leaves the second list as a result. It hands its names to {array_string_ci:}, which folds every value in the list, so the comparison no longer folds one side only. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- tests/Unit/QueryCaseFoldingTest.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Unit/QueryCaseFoldingTest.php b/tests/Unit/QueryCaseFoldingTest.php index d0e7825121a..adb9458938f 100644 --- a/tests/Unit/QueryCaseFoldingTest.php +++ b/tests/Unit/QueryCaseFoldingTest.php @@ -13,7 +13,7 @@ * * Whether a string comparison folds case is decided by the database engine, so * a LIKE against text a person typed has to say which behaviour it wants. The - * {ci:} and {ci_string:} query types say it; LOWER() in the query text says it; + * {ci:} and {string_ci:} query types say it; LOWER() in the query text says it; * a bare column says nothing and gets whatever the engine does, which is a * match on MySQL and no match on PostgreSQL. * @@ -90,7 +90,9 @@ class QueryCaseFoldingTest extends TestCase * - Admin/Members.php, through strtolower(). * - AutoSuggest.php, RequestMembers.php, PM.php, through * Utils::strtolower(). - * - User.php, through array_map(), for the engines that need it. + * + * User.php is not here because it hands its list to {array_string_ci:}, + * which folds every value in it. * * Not folded by their callers, and so matching nothing on PostgreSQL: * @@ -106,7 +108,6 @@ class QueryCaseFoldingTest extends TestCase 'Sources/PersonalMessage/PM.php' => 1, 'Sources/PersonalMessage/Search.php' => 2, 'Sources/Profile.php' => 1, - 'Sources/User.php' => 1, ]; /**************** @@ -124,7 +125,7 @@ public function testNoNewComparisonSkipsTheCaseFoldingTypes(): void . "If a count went up, a new comparison is relying on the engine's\n" . "collation to fold case, which MySQL does and PostgreSQL does not.\n" . "Write it as {ci:column} LIKE {string:value}, or as\n" - . "{ci:column} LIKE {ci_string:value} when the value is not already\n" + . "{ci:column} LIKE {string_ci:value} when the value is not already\n" . "folded in PHP.\n\n" . "If a count went down, the comparison was fixed. Update BASELINE\n" . 'in this test to match, in the same commit.', @@ -143,7 +144,7 @@ public function testNoNewComparisonFoldsOnlyItsColumn(): void . "reaches the query. If it does, add the file to UNFOLDED_VALUES and\n" . "say so in the note there. If it does not, the comparison matches\n" . "nothing on PostgreSQL: fold the value in PHP, or write it as\n" - . '{ci_string:value} and let the query fold it.', + . '{string_ci:value} and let the query fold it.', ); } @@ -169,7 +170,7 @@ protected function scan(): array $found = []; $columns = '~\b(?:' . implode('|', self::COLUMNS) . ')\b~'; - $folded = '~\{ci:|\{ci_string:|LOWER\s*\(~i'; + $folded = '~\{ci:|\{(?:array_)?string_ci:|LOWER\s*\(~i'; $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator(Config::$sourcedir, \FilesystemIterator::SKIP_DOTS), From bd845844e3d6b1df5d6221daff9fb160d0525d5e Mon Sep 17 00:00:00 2001 From: albertlast Date: Thu, 3 Sep 2026 16:06:39 +0200 Subject: [PATCH 7/7] Follows the column type to its clearer name {ci:} became {column_ci:}, so the scan that decides whether a comparison declares its case handling has to look for the new spelling. Getting this wrong fails loudly rather than quietly: every converted call site would stop looking folded at once, and the baseline would grow by all of them. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- tests/Unit/QueryCaseFoldingTest.php | 38 ++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/Unit/QueryCaseFoldingTest.php b/tests/Unit/QueryCaseFoldingTest.php index adb9458938f..a1ea9ee8caa 100644 --- a/tests/Unit/QueryCaseFoldingTest.php +++ b/tests/Unit/QueryCaseFoldingTest.php @@ -13,26 +13,26 @@ * * Whether a string comparison folds case is decided by the database engine, so * a LIKE against text a person typed has to say which behaviour it wants. The - * {ci:} and {string_ci:} query types say it; LOWER() in the query text says it; - * a bare column says nothing and gets whatever the engine does, which is a - * match on MySQL and no match on PostgreSQL. + * {column_ci:} and {string_ci:} query types say it, and so does a LOWER() in + * the query text; a bare column says nothing and gets whatever the engine + * does, which is a match on MySQL and no match on PostgreSQL. * * A comparison written the second way returns fewer rows rather than failing, * so nothing is written to the error log and nothing in CI notices. This test * is what notices. It is not a unit test of any class: the substitution layer - * needs a live connection to escape with, so the behaviour of {ci:} itself - * cannot be reached from here. + * needs a live connection to escape with, so the behaviour of {column_ci:} + * itself cannot be reached from here. * * There are two scans, because there are two ways to get this wrong. One looks * for a comparison that folds neither side, which matches too much on MySQL. * The other looks for one that folds only its column, which matches nothing at * all on PostgreSQL, and is the worse of the two for being the one that hides: - * the query says {ci:} and looks handled. + * the query says {column_ci:} and looks handled. * * The first scan covers LIKE only, since `member_name = {string:name}` and * `$member_name = $string` are the same line to a scanner, and an UPDATE's SET * clause looks like both. The second can cover equality as well, because a - * line carrying {ci:} is SQL and its = is therefore a comparison. + * line carrying {column_ci:} is SQL and its = is therefore a comparison. */ #[CoversNothing] class QueryCaseFoldingTest extends TestCase @@ -75,8 +75,8 @@ class QueryCaseFoldingTest extends TestCase ]; /** - * Comparisons where {ci:} folds the column but the value beside it is not - * folded in the query, counted per file. + * Comparisons where {column_ci:} folds the column but the value beside it + * is not folded in the query, counted per file. * * Folding one side is worse than folding neither. A folded column can * never equal an unfolded value, so instead of matching too much on @@ -124,8 +124,8 @@ public function testNoNewComparisonSkipsTheCaseFoldingTypes(): void "The set of case sensitive comparisons on user text has changed.\n\n" . "If a count went up, a new comparison is relying on the engine's\n" . "collation to fold case, which MySQL does and PostgreSQL does not.\n" - . "Write it as {ci:column} LIKE {string:value}, or as\n" - . "{ci:column} LIKE {string_ci:value} when the value is not already\n" + . "Write it as {column_ci:column} LIKE {string:value}, or as\n" + . "{column_ci:column} LIKE {string_ci:value} when the value is not already\n" . "folded in PHP.\n\n" . "If a count went down, the comparison was fixed. Update BASELINE\n" . 'in this test to match, in the same commit.', @@ -170,7 +170,7 @@ protected function scan(): array $found = []; $columns = '~\b(?:' . implode('|', self::COLUMNS) . ')\b~'; - $folded = '~\{ci:|\{(?:array_)?string_ci:|LOWER\s*\(~i'; + $folded = '~\{column_ci:|\{(?:array_)?string_ci:|LOWER\s*\(~i'; $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator(Config::$sourcedir, \FilesystemIterator::SKIP_DOTS), @@ -216,12 +216,12 @@ protected function scan(): array } /** - * Counts, per file, the comparisons that fold the column with {ci:} while - * comparing it against a value the query does not fold. + * Counts, per file, the comparisons that fold the column with {column_ci:} + * while comparing it against a value the query does not fold. * - * A line carrying {ci:} is SQL, so an = on it is a comparison rather than - * a PHP assignment. That is what lets this one look at equality, which the - * scan above cannot. + * A line carrying {column_ci:} is SQL, so an = on it is a comparison rather + * than a PHP assignment. That is what lets this one look at equality, which + * the scan above cannot. * * @return array Paths relative to the repository root. */ @@ -241,7 +241,7 @@ protected function scanUnfoldedValues(): array $path = str_replace(DIRECTORY_SEPARATOR, '/', $file->getPathname()); $contents = file_get_contents($path); - if (!str_contains($contents, '{ci:')) { + if (!str_contains($contents, '{column_ci:')) { continue; } @@ -249,7 +249,7 @@ protected function scanUnfoldedValues(): array foreach (explode("\n", $contents) as $line) { if ( - str_contains($line, '{ci:') + str_contains($line, '{column_ci:') && preg_match('~\{(?:array_)?string:~', $line) ) { $found[$relative] = ($found[$relative] ?? 0) + 1;