diff --git a/lib/datalib.php b/lib/datalib.php index 81adff36a5e..b8f61119a42 100644 --- a/lib/datalib.php +++ b/lib/datalib.php @@ -1852,3 +1852,97 @@ function get_max_courses_in_category() { return $CFG->maxcoursesincategory; } } + +/** + * Prepare a safe ORDER BY statement from user interactable requests. + * + * This allows safe user specified sorting (ORDER BY), by abstracting the SQL from the value being requested by the user. + * A standard string (and optional direction) can be specified, which will be mapped to a predefined allow list of SQL ordering. + * The mapping can optionally include a 'default', which will be used if the key provided is invalid. + * + * Example usage: + * -If $orderbymap = [ + * 'courseid' => 'c.id', + * 'somecustomvalue'=> 'c.startdate, c.shortname', + * 'default' => 'c.fullname', + * ] + * -A value from the map array's keys can be passed in by a user interaction (eg web service) along with an optional direction. + * -get_safe_orderby($orderbymap, 'courseid', 'DESC') would return: ORDER BY c.id DESC + * -get_safe_orderby($orderbymap, 'somecustomvalue') would return: ORDER BY c.startdate, c.shortname + * -get_safe_orderby($orderbymap, 'invalidblah', 'DESC') would return: ORDER BY c.fullname DESC + * -If no default key was specified in $orderbymap, the invalidblah example above would return empty string. + * + * @param array $orderbymap An array in the format [keystring => sqlstring]. A default fallback can be set with the key 'default'. + * @param string $orderbykey A string to be mapped to a key in $orderbymap. + * @param string $direction Optional ORDER BY direction (ASC/DESC, case insensitive). + * @param bool $useprefix Whether ORDER BY is prefixed to the output (true by default). This should not be modified in most cases. + * It is included to enable get_safe_orderby_multiple() to use this function multiple times. + * @return string The ORDER BY statement, or empty string if $orderbykey is invalid and no default is mapped. + */ +function get_safe_orderby(array $orderbymap, string $orderbykey, string $direction = '', bool $useprefix = true): string { + $orderby = $useprefix ? ' ORDER BY ' : ''; + $output = ''; + + // Only include an order direction if ASC/DESC is explicitly specified (case insensitive). + $direction = strtoupper($direction); + if (!in_array($direction, ['ASC', 'DESC'], true)) { + $direction = ''; + } else { + $direction = " {$direction}"; + } + + // Prepare the statement if the key maps to a defined sort parameter. + if (isset($orderbymap[$orderbykey])) { + $output = "{$orderby}{$orderbymap[$orderbykey]}{$direction}"; + } else if (array_key_exists('default', $orderbymap)) { + // Fall back to use the default if one is specified. + $output = "{$orderby}{$orderbymap['default']}{$direction}"; + } + + return $output; +} + +/** + * Prepare a safe ORDER BY statement from user interactable requests using multiple values. + * + * This allows safe user specified sorting (ORDER BY) similar to get_safe_orderby(), but supports multiple keys and directions. + * This is useful in cases where combinations of columns are needed and/or each item requires a specified direction (ASC/DESC). + * The mapping can optionally include a 'default', which will be used if the key provided is invalid. + * + * Example usage: + * -If $orderbymap = [ + * 'courseid' => 'c.id', + * 'fullname'=> 'c.fullname', + * 'default' => 'c.startdate', + * ] + * -An array of values from the map's keys can be passed in by a user interaction (eg web service), with optional directions. + * -get_safe_orderby($orderbymap, ['courseid', 'fullname'], ['DESC', 'ASC']) would return: ORDER BY c.id DESC, c.fullname ASC + * -get_safe_orderby($orderbymap, ['courseid', 'invalidblah'], ['aaa', 'DESC']) would return: ORDER BY c.id, c.startdate DESC + * -If no default key was specified in $orderbymap, the invalidblah example above would return: ORDER BY c.id + * + * @param array $orderbymap An array in the format [keystring => sqlstring]. A default fallback can be set with the key 'default'. + * @param array $orderbykeys An array of strings to be mapped to keys in $orderbymap. + * @param array $directions Optional array of ORDER BY direction (ASC/DESC, case insensitive). + * The array keys should match array keys in $orderbykeys. + * @return string The ORDER BY statement, or empty string if $orderbykeys contains no valid items and no default is mapped. + */ +function get_safe_orderby_multiple(array $orderbymap, array $orderbykeys, array $directions = []): string { + $output = ''; + + // Check each key for a valid mapping and add to the ORDER BY statement (invalid entries will be empty strings). + foreach ($orderbykeys as $index => $orderbykey) { + $direction = $directions[$index] ?? ''; + $safeorderby = get_safe_orderby($orderbymap, $orderbykey, $direction, false); + + if (!empty($safeorderby)) { + $output .= ", {$safeorderby}"; + } + } + + // Prefix with ORDER BY if any valid ordering is specified (and remove comma from the start). + if (!empty($output)) { + $output = ' ORDER BY' . ltrim($output, ','); + } + + return $output; +} diff --git a/lib/tests/datalib_test.php b/lib/tests/datalib_test.php index b81ddefdab6..8891a62f73f 100644 --- a/lib/tests/datalib_test.php +++ b/lib/tests/datalib_test.php @@ -894,4 +894,205 @@ class core_datalib_testcase extends advanced_testcase { $results = array_diff_key($results, $existingids); $this->assertEquals([$userids[1], $userids[3]], array_keys($results)); } + + /** + * Data provider for test_get_safe_orderby(). + * + * @return array + */ + public function get_safe_orderby_provider(): array { + $orderbymap = [ + 'courseid' => 'c.id', + 'somecustomvalue' => 'c.startdate, c.shortname', + 'default' => 'c.fullname', + ]; + $orderbymapnodefault = [ + 'courseid' => 'c.id', + 'somecustomvalue' => 'c.startdate, c.shortname', + ]; + + return [ + 'Valid option, no direction specified' => [ + $orderbymap, + 'somecustomvalue', + '', + ' ORDER BY c.startdate, c.shortname', + ], + 'Valid option, valid direction specified' => [ + $orderbymap, + 'courseid', + 'DESC', + ' ORDER BY c.id DESC', + ], + 'Valid option, valid lowercase direction specified' => [ + $orderbymap, + 'courseid', + 'asc', + ' ORDER BY c.id ASC', + ], + 'Valid option, invalid direction specified' => [ + $orderbymap, + 'courseid', + 'BOOP', + ' ORDER BY c.id', + ], + 'Valid option, invalid lowercase direction specified' => [ + $orderbymap, + 'courseid', + 'boop', + ' ORDER BY c.id', + ], + 'Invalid option default fallback, with valid direction' => [ + $orderbymap, + 'thisdoesnotexist', + 'ASC', + ' ORDER BY c.fullname ASC', + ], + 'Invalid option default fallback, with invalid direction' => [ + $orderbymap, + 'thisdoesnotexist', + 'BOOP', + ' ORDER BY c.fullname', + ], + 'Invalid option without default, with valid direction' => [ + $orderbymapnodefault, + 'thisdoesnotexist', + 'ASC', + '', + ], + 'Invalid option without default, with invalid direction' => [ + $orderbymapnodefault, + 'thisdoesnotexist', + 'NOPE', + '', + ], + ]; + } + + /** + * Tests the get_safe_orderby function. + * + * @dataProvider get_safe_orderby_provider + * @param array $orderbymap The ORDER BY parameter mapping array. + * @param string $orderbykey The string key being provided, to check against the map. + * @param string $direction The optional direction to order by. + * @param string $expected The expected string output of the method. + */ + public function test_get_safe_orderby(array $orderbymap, string $orderbykey, string $direction, string $expected): void { + $actual = get_safe_orderby($orderbymap, $orderbykey, $direction); + $this->assertEquals($expected, $actual); + } + + /** + * Data provider for test_get_safe_orderby_multiple(). + * + * @return array + */ + public function get_safe_orderby_multiple_provider(): array { + $orderbymap = [ + 'courseid' => 'c.id', + 'firstname' => 'u.firstname', + 'default' => 'c.startdate', + ]; + $orderbymapnodefault = [ + 'courseid' => 'c.id', + 'firstname' => 'u.firstname', + ]; + + return [ + 'Valid options, no directions specified' => [ + $orderbymap, + ['courseid', 'firstname'], + [], + ' ORDER BY c.id, u.firstname', + ], + 'Valid options, some direction specified' => [ + $orderbymap, + ['courseid', 'firstname'], + ['DESC'], + ' ORDER BY c.id DESC, u.firstname', + ], + 'Valid options, all directions specified' => [ + $orderbymap, + ['courseid', 'firstname'], + ['ASC', 'desc'], + ' ORDER BY c.id ASC, u.firstname DESC', + ], + 'Valid options, valid and invalid directions specified' => [ + $orderbymap, + ['courseid', 'firstname'], + ['BOOP', 'DESC'], + ' ORDER BY c.id, u.firstname DESC', + ], + 'Valid options, all invalid directions specified' => [ + $orderbymap, + ['courseid', 'firstname'], + ['BOOP', 'SNOOT'], + ' ORDER BY c.id, u.firstname', + ], + 'Valid and invalid option default fallback, with valid directions' => [ + $orderbymap, + ['thisdoesnotexist', 'courseid'], + ['asc', 'DESC'], + ' ORDER BY c.startdate ASC, c.id DESC', + ], + 'Valid and invalid option default fallback, with invalid direction' => [ + $orderbymap, + ['courseid', 'thisdoesnotexist'], + ['BOOP', 'SNOOT'], + ' ORDER BY c.id, c.startdate', + ], + 'Valid and invalid option without default, with valid direction' => [ + $orderbymapnodefault, + ['thisdoesnotexist', 'courseid'], + ['ASC', 'DESC'], + ' ORDER BY c.id DESC', + ], + 'Valid and invalid option without default, with invalid direction' => [ + $orderbymapnodefault, + ['thisdoesnotexist', 'courseid'], + ['BOOP', 'SNOOT'], + ' ORDER BY c.id', + ], + 'Invalid option only without default, with valid direction' => [ + $orderbymapnodefault, + ['thisdoesnotexist'], + ['ASC'], + '', + ], + 'Invalid option only without default, with invalid direction' => [ + $orderbymapnodefault, + ['thisdoesnotexist'], + ['BOOP'], + '', + ], + 'Single valid option, direction specified' => [ + $orderbymap, + ['firstname'], + ['ASC'], + ' ORDER BY u.firstname ASC', + ], + 'Single valid option, direction not specified' => [ + $orderbymap, + ['firstname'], + [], + ' ORDER BY u.firstname', + ], + ]; + } + + /** + * Tests the get_safe_orderby_multiple function. + * + * @dataProvider get_safe_orderby_multiple_provider + * @param array $orderbymap The ORDER BY parameter mapping array. + * @param array $orderbykeys The array of string keys being provided, to check against the map. + * @param array $directions The optional directions to order by. + * @param string $expected The expected string output of the method. + */ + public function test_get_safe_orderby_multiple(array $orderbymap, array $orderbykeys, array $directions, + string $expected): void { + $actual = get_safe_orderby_multiple($orderbymap, $orderbykeys, $directions); + $this->assertEquals($expected, $actual); + } } diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 637bcf92adf..e7595ef98da 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -1,6 +1,12 @@ This files describes API changes in core libraries and APIs, information provided here is intended especially for developers. +=== 3.11.5 === +* Two new helper functions have been added to lib/datalib.php, for safely preparing SQL ORDER BY statements where user + interactions define sort parameters (see the respective docblocks for full details and examples): + -get_safe_orderby() - where a single sort parameter is required. + -get_safe_orderby_multiple() - where multiple sort parameters are required. + === 3.11.4 === * A new option dontforcesvgdownload has been added to the $options parameter of the send_file() function. Note: This option overrides the forced download of directly accessed SVGs, so should only be used where the calling method is