diff --git a/Sources/Actions/Admin/Members.php b/Sources/Actions/Admin/Members.php index e022b0e9e7..77ab0e0806 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[] = '({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 3bf58e94dc..0f293e45b4 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 {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'), [ - '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 {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}', [ - '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 c8843d90a4..5e92dbc248 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 {column_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 {string_ci:search}'; $request = Db::$db->query( 'SELECT COUNT(*) diff --git a/Sources/Actions/Register2.php b/Sources/Actions/Register2.php index a650e23cd9..d70f3a486a 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 {column_ci:email_address} = {string:email_address} + OR {column_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 094f35c49c..b8f8167e2a 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 {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}', [ - '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 96542f4729..73a09be6fc 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 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_-]+))?}~', ], @@ -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] === 'column_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 'string_ci': return \sprintf('\'%1$s\'', mysqli_real_escape_string($connection, $this->fix_mb4((string) $replacement))); case 'array_int': @@ -2801,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 c0ba1e0668..e3c39760ab 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 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_-]+))?}~', ], @@ -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] === 'column_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 {column_ci:} has folded. + case 'string_ci': + return \sprintf('LOWER(\'%1$s\')', pg_escape_string($this->connection, (string) $replacement)); + case 'array_int': if (\is_array($replacement)) { if (empty($replacement)) { @@ -2748,6 +2763,24 @@ protected function replacement__callback(array $matches, array $db_values, objec break; + // Each of these folded to match a column that {column_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/PersonalMessage/PM.php b/Sources/PersonalMessage/PM.php index b2f3dcb1c0..a88fc98fa5 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 {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 fb3cf24d9a..5433c07c66 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[] = '{column_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[] = '{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 1e156a8869..0ddd3d12ce 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 {column_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 d8399a01a8..c7567b271f 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 {column_ci:} type folds the column for the engines that need it and + // leaves it alone for the ones that do not. + $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); @@ -5424,13 +5425,8 @@ 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'][] = '{column_ci:mem.member_name} IN ({array_string_ci:users})'; + $query_customizations['params']['users'] = $users; break;