diff --git a/admin/tool/dataprivacy/classes/external.php b/admin/tool/dataprivacy/classes/external.php index 15cfc191617..6938126c590 100644 --- a/admin/tool/dataprivacy/classes/external.php +++ b/admin/tool/dataprivacy/classes/external.php @@ -708,7 +708,7 @@ class external extends external_api { $fields .= ',' . implode(',', $extrafields); } - list($sql, $params) = users_search_sql($query, '', false, $extrafields, $excludedusers); + list($sql, $params) = users_search_sql($query, '', USER_SEARCH_STARTS_WITH, $extrafields, $excludedusers); $users = $DB->get_records_select('user', $sql, $params, $sort, $fields, 0, 30); $useroptions = []; foreach ($users as $user) { diff --git a/admin/tool/lp/classes/external.php b/admin/tool/lp/classes/external.php index 4d7990ca50c..f5585d19ee2 100644 --- a/admin/tool/lp/classes/external.php +++ b/admin/tool/lp/classes/external.php @@ -874,7 +874,7 @@ class external extends external_api { $fields = $userfieldsapi->get_sql('u', false, '', '', false)->selects; $extrasearchfields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]); - list($wheresql, $whereparams) = users_search_sql($query, 'u', true, $extrasearchfields); + list($wheresql, $whereparams) = users_search_sql($query, 'u', USER_SEARCH_CONTAINS, $extrasearchfields); list($sortsql, $sortparams) = users_order_by_sql('u', $query, $context); $countsql = "SELECT COUNT('x') FROM {user} u WHERE $wheresql AND u.id $filtercapsql"; diff --git a/enrol/ajax.php b/enrol/ajax.php index fab67db13f2..c128b003b74 100644 --- a/enrol/ajax.php +++ b/enrol/ajax.php @@ -58,7 +58,7 @@ $outcome->success = true; $outcome->response = new stdClass(); $outcome->error = ''; -$searchanywhere = get_user_preferences('userselector_searchanywhere', false); +$searchanywhere = get_user_preferences('userselector_searchtype') === USER_SEARCH_CONTAINS ? true : false; switch ($action) { case 'unenrol': diff --git a/enrol/locallib.php b/enrol/locallib.php index beeb013e72d..b706ad230ba 100644 --- a/enrol/locallib.php +++ b/enrol/locallib.php @@ -274,7 +274,7 @@ class course_enrolment_manager { // Search condition. // TODO Does not support custom user profile fields (MDL-70456). $extrafields = fields::get_identity_fields($this->get_context(), false); - list($sql, $params) = users_search_sql($this->searchfilter, 'u', true, $extrafields); + list($sql, $params) = users_search_sql($this->searchfilter, 'u', USER_SEARCH_CONTAINS, $extrafields); // Role condition. if ($this->rolefilter) { diff --git a/enrol/manual/ajax.php b/enrol/manual/ajax.php index 22ccef20ebb..8a358a552f0 100644 --- a/enrol/manual/ajax.php +++ b/enrol/manual/ajax.php @@ -60,7 +60,7 @@ $outcome->response = new stdClass(); $outcome->error = ''; $outcome->count = 0; -$searchanywhere = get_user_preferences('userselector_searchanywhere', false); +$searchanywhere = get_user_preferences('userselector_searchtype') === USER_SEARCH_CONTAINS ? true : false; switch ($action) { case 'enrol': diff --git a/grade/report/history/classes/helper.php b/grade/report/history/classes/helper.php index 189cbf7b022..c4d8b1ba8e6 100644 --- a/grade/report/history/classes/helper.php +++ b/grade/report/history/classes/helper.php @@ -138,7 +138,7 @@ class helper { } $params = array(); if (!empty($search)) { - list($filtersql, $params) = users_search_sql($search, 'u', true, $extrafields); + list($filtersql, $params) = users_search_sql($search, 'u', USER_SEARCH_CONTAINS, $extrafields); $filtersql .= ' AND '; } else { $filtersql = ''; diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 5dca5a85c8d..2fe0a1d9d27 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -2357,7 +2357,10 @@ $string['userpic'] = 'User picture'; $string['users'] = 'Users'; $string['userselectorautoselectunique'] = 'If only one user matches the search, select them automatically'; $string['userselectorpreserveselected'] = 'Keep selected users, even if they no longer match the search'; -$string['userselectorsearchanywhere'] = 'Match the search text anywhere in the displayed fields'; +$string['userselectorsearchmatching'] = 'Matching:'; +$string['userselectorsearchfromstart'] = 'from start'; +$string['userselectorsearchanywhere'] = 'anywhere'; +$string['userselectorsearchexactmatchonly'] = 'exact matches only'; $string['usersnew'] = 'New users'; $string['usersnoaccesssince'] = 'Inactive for more than'; $string['userpreferences'] = 'User preferences'; diff --git a/lib/classes/user.php b/lib/classes/user.php index 7fa691bb1a2..6641c26acdc 100644 --- a/lib/classes/user.php +++ b/lib/classes/user.php @@ -243,7 +243,7 @@ class core_user { } // Start building the WHERE clause based on name. - list ($where, $whereparams) = users_search_sql($query, 'u', false); + list ($where, $whereparams) = users_search_sql($query, 'u'); // We allow users to search with extra identity fields (as well as name) but only if they // have the permission to display those identity fields. @@ -1021,10 +1021,10 @@ class core_user { 'default' => false, 'permissioncallback' => [static::class, 'is_current_user'], ]; - $preferences['userselector_searchanywhere'] = [ - 'type' => PARAM_BOOL, + $preferences['userselector_searchtype'] = [ + 'type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, - 'default' => false, + 'default' => USER_SEARCH_STARTS_WITH, 'permissioncallback' => [static::class, 'is_current_user'], ]; $preferences['question_bank_advanced_search'] = [ diff --git a/lib/datalib.php b/lib/datalib.php index 20a2f66f0f6..2dbaae9cf9a 100644 --- a/lib/datalib.php +++ b/lib/datalib.php @@ -52,6 +52,12 @@ define('MAX_COURSE_CATEGORIES', 10000); if (!defined('LASTACCESS_UPDATE_SECS')) { define('LASTACCESS_UPDATE_SECS', 60); } +/** + * The constant value when we use the search option. + */ +define('USER_SEARCH_STARTS_WITH', 0); +define('USER_SEARCH_CONTAINS', 1); +define('USER_SEARCH_EXACT_MATCH', 2); /** * Returns $user object of the main admin user @@ -216,8 +222,8 @@ function search_users($courseid, $groupid, $searchtext, $sort='', array $excepti * @param string $search the text to search for (empty string = find all) * @param string $u the table alias for the user table in the query being * built. May be ''. - * @param bool $searchanywhere If true (default), searches in the middle of - * names, otherwise only searches at start + * @param int $searchtype If 0(default): searches at start, 1: searches in the middle of names + * 2: search exact match. * @param array $extrafields Array of extra user fields to include in search, must be prefixed with table alias if they are not in * the user table. * @param array $exclude Array of user ids to exclude (empty = don't exclude) @@ -227,7 +233,7 @@ function search_users($courseid, $groupid, $searchtext, $sort='', array $excepti * where clause the query, and an associative array containing any required * parameters (using named placeholders). */ -function users_search_sql(string $search, string $u = 'u', bool $searchanywhere = true, array $extrafields = [], +function users_search_sql(string $search, string $u = 'u', int $searchtype = USER_SEARCH_STARTS_WITH, array $extrafields = [], array $exclude = null, array $includeonly = null): array { global $DB, $CFG; $params = array(); @@ -237,7 +243,6 @@ function users_search_sql(string $search, string $u = 'u', bool $searchanywhere $u .= '.'; } - // If we have a $search string, put a field LIKE '$search%' condition on each field. if ($search) { $conditions = array( $DB->sql_fullname($u . 'firstname', $u . 'lastname'), @@ -247,14 +252,26 @@ function users_search_sql(string $search, string $u = 'u', bool $searchanywhere // Add the table alias for the user table if the field doesn't already have an alias. $conditions[] = strpos($field, '.') !== false ? $field : $u . $field; } - if ($searchanywhere) { - $searchparam = '%' . $search . '%'; - } else { - $searchparam = $search . '%'; + switch ($searchtype) { + case USER_SEARCH_STARTS_WITH: + // Put a field LIKE 'search%' condition on each field. + $searchparam = $search . '%'; + break; + case USER_SEARCH_CONTAINS: + // Put a field LIKE '$search%' condition on each field. + $searchparam = '%' . $search . '%'; + break; + case USER_SEARCH_EXACT_MATCH: + // Match exact the $search string. + $searchparam = $search; + break; } $i = 0; foreach ($conditions as $key => $condition) { $conditions[$key] = $DB->sql_like($condition, ":con{$i}00", false, false); + if ($searchtype === USER_SEARCH_EXACT_MATCH) { + $conditions[$key] = "$condition = :con{$i}00"; + } $params["con{$i}00"] = $searchparam; $i++; } diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index d7c933474a9..b84be3be63c 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -3594,5 +3594,12 @@ privatefiles,moodle|/user/files.php'; upgrade_main_savepoint(true, 2023090200.01); } + if ($oldversion < 2023090800.00) { + // Delete all the searchanywhere prefs in user_preferences table. + $DB->delete_records('user_preferences', ['name' => 'userselector_searchanywhere']); + // Main savepoint reached. + upgrade_main_savepoint(true, 2023090800.00); + } + return true; } diff --git a/lib/tests/datalib_test.php b/lib/tests/datalib_test.php index 9f36dd925f1..134176da19c 100644 --- a/lib/tests/datalib_test.php +++ b/lib/tests/datalib_test.php @@ -77,42 +77,48 @@ class datalib_test extends \advanced_testcase { $user2 = self::getDataGenerator()->create_user($user2); // Search by name (anywhere in text). - list($sql, $params) = users_search_sql('User Test 2', ''); + list($sql, $params) = users_search_sql('User Test 2', '', USER_SEARCH_CONTAINS); $results = $DB->get_records_sql("SELECT id FROM {user} WHERE $sql ORDER BY username", $params); $this->assertFalse(array_key_exists($user1->id, $results)); $this->assertTrue(array_key_exists($user2->id, $results)); // Search by (most of) full name. - list($sql, $params) = users_search_sql('First Name User Test 2 Last Name User', ''); + list($sql, $params) = users_search_sql('First Name User Test 2 Last Name User', '', USER_SEARCH_CONTAINS); $results = $DB->get_records_sql("SELECT id FROM {user} WHERE $sql ORDER BY username", $params); $this->assertFalse(array_key_exists($user1->id, $results)); $this->assertTrue(array_key_exists($user2->id, $results)); // Search by name (start of text) valid or not. - list($sql, $params) = users_search_sql('User Test 2', '', false); + list($sql, $params) = users_search_sql('User Test 2', ''); $results = $DB->get_records_sql("SELECT id FROM {user} WHERE $sql ORDER BY username", $params); $this->assertEquals(0, count($results)); - list($sql, $params) = users_search_sql('First Name User Test 2', '', false); + list($sql, $params) = users_search_sql('First Name User Test 2', ''); $results = $DB->get_records_sql("SELECT id FROM {user} WHERE $sql ORDER BY username", $params); $this->assertFalse(array_key_exists($user1->id, $results)); $this->assertTrue(array_key_exists($user2->id, $results)); // Search by extra fields included or not (address). - list($sql, $params) = users_search_sql('Test Street', '', true); + list($sql, $params) = users_search_sql('Test Street', '', USER_SEARCH_CONTAINS); $results = $DB->get_records_sql("SELECT id FROM {user} WHERE $sql ORDER BY username", $params); $this->assertCount(0, $results); - list($sql, $params) = users_search_sql('Test Street', '', true, array('address')); + list($sql, $params) = users_search_sql('Test Street', '', USER_SEARCH_CONTAINS, array('address')); $results = $DB->get_records_sql("SELECT id FROM {user} WHERE $sql ORDER BY username", $params); $this->assertCount(2, $results); // Exclude user. - list($sql, $params) = users_search_sql('User Test', '', true, array(), array($user1->id)); + list($sql, $params) = users_search_sql('User Test', '', USER_SEARCH_CONTAINS, array(), array($user1->id)); $results = $DB->get_records_sql("SELECT id FROM {user} WHERE $sql ORDER BY username", $params); $this->assertFalse(array_key_exists($user1->id, $results)); $this->assertTrue(array_key_exists($user2->id, $results)); // Include only user. - list($sql, $params) = users_search_sql('User Test', '', true, array(), array(), array($user1->id)); + list($sql, $params) = users_search_sql('User Test', '', USER_SEARCH_CONTAINS, array(), array(), array($user1->id)); + $results = $DB->get_records_sql("SELECT id FROM {user} WHERE $sql ORDER BY username", $params); + $this->assertTrue(array_key_exists($user1->id, $results)); + $this->assertFalse(array_key_exists($user2->id, $results)); + + // Exact match only. + [$sql, $params] = users_search_sql('Last Name User Test 1', '', USER_SEARCH_EXACT_MATCH, [], null, null, true); $results = $DB->get_records_sql("SELECT id FROM {user} WHERE $sql ORDER BY username", $params); $this->assertTrue(array_key_exists($user1->id, $results)); $this->assertFalse(array_key_exists($user2->id, $results)); @@ -120,7 +126,7 @@ class datalib_test extends \advanced_testcase { // Join with another table and use different prefix. set_user_preference('amphibian', 'frog', $user1); set_user_preference('amphibian', 'salamander', $user2); - list($sql, $params) = users_search_sql('User Test 1', 'qq'); + list($sql, $params) = users_search_sql('User Test 1', 'qq', USER_SEARCH_CONTAINS); $results = $DB->get_records_sql(" SELECT up.id, up.value FROM {user} qq @@ -135,7 +141,7 @@ class datalib_test extends \advanced_testcase { // Join with another table and include other table fields in search. set_user_preference('reptile', 'snake', $user1); set_user_preference('reptile', 'lizard', $user2); - list($sql, $params) = users_search_sql('snake', 'qq', true, ['up.value']); + list($sql, $params) = users_search_sql('snake', 'qq', USER_SEARCH_CONTAINS, ['up.value']); $results = $DB->get_records_sql(" SELECT up.id, up.value FROM {user} qq diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 96daf7fce74..f0f1ac656a6 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -169,6 +169,9 @@ being forced open in all behat tests. * Added a new method called exceeds_password_length in moodlelib.php to validate the password length. * The core/modal_factory has been deprecated. From Moodle 4.3 onwards please instantiate new modals using the ModalType.create method instead. Please note that this method does not support the `trigger` option. +* The users_search_sql function parameter $searchanywhere has been change to $searchtype for different type of search. $searchtype is a int parameter and has three constant value: + USER_SEARCH_STARTS_WITH: 0, USER_SEARCH_CONTAINS: 1, USER_SEARCH_EXACT_MATCH: 2 + See MDL-78312 for further information. === 4.2 === diff --git a/user/classes/external/search_identity.php b/user/classes/external/search_identity.php index e4419ee0a8f..45b9e4a65d6 100644 --- a/user/classes/external/search_identity.php +++ b/user/classes/external/search_identity.php @@ -68,7 +68,7 @@ class search_identity extends external_api { $fields = \core_user\fields::for_name()->with_identity($context, false); $extrafields = $fields->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]); - list($searchsql, $searchparams) = users_search_sql($query, '', true, $extrafields); + list($searchsql, $searchparams) = users_search_sql($query, '', USER_SEARCH_CONTAINS, $extrafields); list($sortsql, $sortparams) = users_order_by_sql('', $query, $context); $params = array_merge($searchparams, $sortparams); diff --git a/user/selector/lib.php b/user/selector/lib.php index 1b79cec5056..d69d30a6473 100644 --- a/user/selector/lib.php +++ b/user/selector/lib.php @@ -58,9 +58,9 @@ abstract class user_selector_base { protected $preserveselected = false; /** @var boolean If only one user matches the search, should we select them automatically. */ protected $autoselectunique = false; - /** @var boolean When searching, do we only match the starts of fields (better performance) - * or do we match occurrences anywhere? */ - protected $searchanywhere = false; + /** @var int When searching, do we only match the starts of fields (better performance) + * or do we match occurrences anywhere or do we match exact the fields. */ + protected $searchtype = USER_SEARCH_STARTS_WITH; /** @var mixed This is used by get selected users */ protected $validatinguserids = null; @@ -156,8 +156,7 @@ abstract class user_selector_base { // Read the user prefs / optional_params that we use. $this->preserveselected = $this->initialise_option('userselector_preserveselected', $this->preserveselected); $this->autoselectunique = $this->initialise_option('userselector_autoselectunique', $this->autoselectunique); - $this->searchanywhere = $this->initialise_option('userselector_searchanywhere', $this->searchanywhere); - + $this->searchtype = (int) $this->initialise_option('userselector_searchtype', $this->searchtype, PARAM_INT); if (!empty($CFG->maxusersperpage)) { $this->maxusersperpage = $CFG->maxusersperpage; } @@ -278,7 +277,6 @@ abstract class user_selector_base { $this->name . '_clearbutton" value="' . get_string('clear') . '" class="btn btn-secondary"/>'; // And the search options. - $optionsoutput = false; if (!user_selector_base::$searchoptionsoutput) { $output .= print_collapsible_region_start('', 'userselector_options', get_string('searchoptions'), 'userselector_optionscollapsed', true, true); @@ -286,8 +284,7 @@ abstract class user_selector_base { get_string('userselectorpreserveselected')); $output .= $this->option_checkbox('autoselectunique', $this->autoselectunique, get_string('userselectorautoselectunique')); - $output .= $this->option_checkbox('searchanywhere', $this->searchanywhere, - get_string('userselectorsearchanywhere')); + $output .= $this->output_searchtype_radios(); $output .= print_collapsible_region_end(true); $PAGE->requires->js_init_call('M.core_user.init_user_selector_options_tracker', array(), false, self::$jsmodule); @@ -499,8 +496,7 @@ abstract class user_selector_base { $extrafields = $this->includecustomfields ? array_values($this->userfieldsmappings) : $this->extrafields; - - return users_search_sql($search, $u, $this->searchanywhere, $extrafields, + return users_search_sql($search, $u, $this->searchtype, $extrafields, $this->exclude, $this->validatinguserids); } @@ -644,10 +640,11 @@ abstract class user_selector_base { * * @param string $name * @param mixed $default + * @param string $paramtype allow the option to custom param type. default is bool * @return mixed|null|string */ - private function initialise_option($name, $default) { - $param = optional_param($name, null, PARAM_BOOL); + private function initialise_option($name, $default, $paramtype = PARAM_BOOL) { + $param = optional_param($name, null, $paramtype); if (is_null($param)) { return get_user_preferences($name, $default); } else { @@ -673,7 +670,7 @@ abstract class user_selector_base { $name = 'userselector_' . $name; // For the benefit of brain-dead IE, the id must be different from the name of the hidden form field above. // It seems that document.getElementById('frog') in IE will return and element with name="frog". - $output = '
' . + $output = '
' . '