From 7f00049fe81c43d5f9a2e38cbbf1cbdcff3c4c39 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Tue, 22 Mar 2022 21:51:52 +0100 Subject: [PATCH 1/2] Initial work on the SORT() and SORTBY() Lookup/Reference functions The code could stil do with some cleaning up, and better optimisation for memory usage; but all tests are passing... that's for full multi-level sorting (including direction), and allowing for correct sorting of sting/numeric datatypes. --- CHANGELOG.md | 2 +- .../Calculation/Calculation.php | 6 +- .../Calculation/LookupRef/Sort.php | 318 ++++++++++++++++++ .../Functions/LookupRef/SortByTest.php | 129 +++++++ .../Functions/LookupRef/SortTest.php | 210 ++++++++++++ 5 files changed, 661 insertions(+), 4 deletions(-) create mode 100644 src/PhpSpreadsheet/Calculation/LookupRef/Sort.php create mode 100644 tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php create mode 100644 tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac8c9a9..c7ae1854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Added -- Implementation of the FILTER() and UNIQUE() Lookup/Reference (array) function +- Implementation of the FILTER(), SORT(), SORTBY() and UNIQUE() Lookup/Reference (array) functions - Implementation of the ISREF() Information function. - Added support for reading "formatted" numeric values from Csv files; although default behaviour of reading these values as strings is preserved. diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 0f94a7e7..b336920c 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -2282,12 +2282,12 @@ class Calculation ], 'SORT' => [ 'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE, - 'functionCall' => [Functions::class, 'DUMMY'], - 'argumentCount' => '1+', + 'functionCall' => [LookupRef\Sort::class, 'sort'], + 'argumentCount' => '1-4', ], 'SORTBY' => [ 'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE, - 'functionCall' => [Functions::class, 'DUMMY'], + 'functionCall' => [LookupRef\Sort::class, 'sortBy'], 'argumentCount' => '2+', ], 'SQRT' => [ diff --git a/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php b/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php new file mode 100644 index 00000000..48dc3388 --- /dev/null +++ b/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php @@ -0,0 +1,318 @@ +getMessage(); + } + + // We want a simple, enumrated array of arrays where we can reference column by its index number. + $sortArray = array_values(array_map('array_values', $sortArray)); + + return ($byColumn === true) + ? self::sortByColumn($sortArray, $sortIndex, $sortOrder) + : self::sortByRow($sortArray, $sortIndex, $sortOrder); + } + + /** + * SORTBY + * The SORTBY function sorts the contents of a range or array based on the values in a corresponding range or array. + * The returned array is the same shape as the provided array argument. + * Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting. + * + * @param mixed $sortArray The range of cells being sorted + * @param mixed $args + * + * @return mixed The sorted values from the sort range + */ + public static function sortBy($sortArray, ...$args) + { + if (!is_array($sortArray)) { + // Scalars are always returned "as is" + return $sortArray; + } + + $lookupArraySize = count($sortArray); + $argumentCount = count($args); + + try { + $sortBy = $sortOrder = []; + for ($i = 0; $i < $argumentCount; $i += 2) { + $sortBy[] = self::validateSortVector($args[$i], $lookupArraySize); + $sortOrder[] = self::validateSortOrder($args[$i + 1] ?? self::ORDER_ASCENDING); + } + } catch (Exception $e) { + return $e->getMessage(); + } + + return self::processSortBy($sortArray, $sortBy, $sortOrder); + } + + /** + * @param mixed $sortIndex + * @param mixed $sortOrder + */ + private static function validateScalarArgumentsForSort(&$sortIndex, &$sortOrder, int $lookupIndexSize): void + { + if (is_array($sortIndex) || is_array($sortOrder)) { + throw new Exception(ExcelError::VALUE()); + } + + $sortIndex = self::validatePositiveInt($sortIndex, false); + + if ($sortIndex > $lookupIndexSize) { + throw new Exception(ExcelError::VALUE()); + } + + $sortOrder = self::validateSortOrder($sortOrder); + } + + /** + * @param mixed $sortVector + */ + private static function validateSortVector($sortVector, int $lookupArraySize): array + { + if (!is_array($sortVector)) { + throw new Exception(ExcelError::VALUE()); + } + + // It doesn't matter if it's a row or a column vectors, it works either way + $sortVector = Functions::flattenArray($sortVector); + if (count($sortVector) !== $lookupArraySize) { + throw new Exception(ExcelError::VALUE()); + } + + return $sortVector; + } + + /** + * @param mixed $sortOrder + */ + private static function validateSortOrder($sortOrder): int + { + $sortOrder = self::validateInt($sortOrder); + if (($sortOrder == self::ORDER_ASCENDING || $sortOrder === self::ORDER_DESCENDING) === false) { + throw new Exception(ExcelError::VALUE()); + } + + return $sortOrder; + } + + /** + * @param array $sortIndex + * @param mixed $sortOrder + */ + private static function validateArrayArgumentsForSort(&$sortIndex, &$sortOrder, int $lookupIndexSize): void + { + // It doesn't matter if they're row or column vectors, it works either way + $sortIndex = Functions::flattenArray($sortIndex); + $sortOrder = Functions::flattenArray($sortOrder); + + if ( + count($sortOrder) === 0 || count($sortOrder) > $lookupIndexSize || + (count($sortOrder) > count($sortIndex)) + ) { + throw new Exception(ExcelError::VALUE()); + } + + if (count($sortIndex) > count($sortOrder)) { + // If $sortOrder has fewer elements than $sortIndex, then the last order element is repeated. + $sortOrder = array_merge( + $sortOrder, + array_fill(0, count($sortIndex) - count($sortOrder), array_pop($sortOrder)) + ); + } + + foreach ($sortIndex as $key => &$value) { + self::validateScalarArgumentsForSort($value, $sortOrder[$key], $lookupIndexSize); + } + } + + private static function prepareSortVectorValues(array $sortVector): array + { + // Strings should be sorted case-insensitive; with booleans converted to locale-strings + return array_map( + function ($value) { + if (is_bool($value)) { + return ($value) ? Calculation::getTRUE() : Calculation::getFALSE(); + } elseif (is_string($value)) { + return StringHelper::strToLower($value); + } + + return $value; + }, + $sortVector + ); + } + + /** + * @param array[] $sortIndex + * @param int[] $sortOrder + */ + private static function processSortBy(array $lookupArray, array $sortIndex, $sortOrder): array + { + $sortArguments = []; + $sortData = []; + foreach ($sortIndex as $index => $sortValues) { + $sortData[] = $sortValues; + $sortArguments[] = self::prepareSortVectorValues($sortValues); + $sortArguments[] = $sortOrder[$index] === self::ORDER_ASCENDING ? SORT_ASC : SORT_DESC; + } + $sortArguments = self::applyPHP7Patch($lookupArray, $sortArguments); + + $sortVector = self::executeVectorSortQuery($sortData, $sortArguments); + + return self::sortLookupArrayFromVector($lookupArray, $sortVector); + } + + /** + * @param int[] $sortIndex + * @param int[] $sortOrder + */ + private static function sortByRow(array $lookupArray, array $sortIndex, array $sortOrder): array + { + $sortVector = self::buildVectorForSort($lookupArray, $sortIndex, $sortOrder); + + return self::sortLookupArrayFromVector($lookupArray, $sortVector); + } + + /** + * @param int[] $sortIndex + * @param int[] $sortOrder + */ + private static function sortByColumn(array $lookupArray, array $sortIndex, array $sortOrder): array + { + $lookupArray = Matrix::transpose($lookupArray); + $result = self::sortByRow($lookupArray, $sortIndex, $sortOrder); + + return Matrix::transpose($result); + } + + /** + * @param int[] $sortIndex + * @param int[] $sortOrder + */ + private static function buildVectorForSort(array $lookupArray, array $sortIndex, array $sortOrder): array + { + $sortArguments = []; + $sortData = []; + foreach ($sortIndex as $index => $sortIndexValue) { + $sortValues = array_column($lookupArray, $sortIndexValue - 1); + $sortData[] = $sortValues; + $sortArguments[] = self::prepareSortVectorValues($sortValues); + $sortArguments[] = $sortOrder[$index] === self::ORDER_ASCENDING ? SORT_ASC : SORT_DESC; + } + $sortArguments = self::applyPHP7Patch($lookupArray, $sortArguments); + + $sortData = self::executeVectorSortQuery($sortData, $sortArguments); + + return $sortData; + } + + private static function executeVectorSortQuery(array $sortData, array $sortArguments): array + { + $sortData = Matrix::transpose($sortData); + + // We need to set an index that can be retained, as array_multisort doesn't maintain numeric keys. + $sortDataIndexed = []; + foreach ($sortData as $key => $value) { + $sortDataIndexed[Coordinate::stringFromColumnIndex($key + 1)] = $value; + } + unset($sortData); + + $sortArguments[] = &$sortDataIndexed; + + array_multisort(...$sortArguments); + + // After the sort, we restore the numeric keys that will now be in the correct, sorted order + $sortedData = []; + foreach (array_keys($sortDataIndexed) as $key) { + $sortedData[] = Coordinate::columnIndexFromString($key) - 1; + } + + return $sortedData; + } + + private static function sortLookupArrayFromVector(array $lookupArray, array $sortVector): array + { + // Building a new array in the correct (sorted) order works; but may be memory heavy for larger arrays + $sortedArray = []; + foreach ($sortVector as $index) { + $sortedArray[] = $lookupArray[$index]; + } + + return $sortedArray; + +// uksort( +// $lookupArray, +// function (int $a, int $b) use (array $sortVector) { +// return $sortVector[$a] <=> $sortVector[$b]; +// } +// ); +// +// return $lookupArray; + } + + /** + * Hack to handle PHP 7: + * From PHP 8.0.0, If two members compare as equal in a sort, they retain their original order; + * but prior to PHP 8.0.0, their relative order in the sorted array was undefined. + * MS Excel replicates the PHP 8.0.0 behaviour, retaining the original order of matching elements. + * To replicate that behaviour with PHP 7, we add an extra sort based on the row index. + */ + private static function applyPHP7Patch(array $lookupArray, array $sortArguments): array + { + if (PHP_VERSION_ID < 80000) { + $sortArguments[] = range(1, count($lookupArray)); + $sortArguments[] = SORT_ASC; + } + + return $sortArguments; + } +} diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php new file mode 100644 index 00000000..3acc8b6a --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php @@ -0,0 +1,129 @@ + ['A', 1], + 'Mismatched sortIndex count' => [[1, 2, 3, 4], 1], + 'Non-numeric sortOrder' => [[1, 2, 3], 'A'], + 'Invalid negative sortOrder' => [[1, 2, 3], -2], + 'Zero sortOrder' => [[1, 2, 3], 0], + 'Invalid positive sortOrder' => [[1, 2, 3], 2], + ]; + } + + /** + * @dataProvider providerSortByRow + */ + public function testSortByRow(array $expectedResult, array $matrix, array $sortIndex, int $sortOrder = Sort::ORDER_ASCENDING): void + { + $result = Sort::sortBy($matrix, $sortIndex, $sortOrder); + self::assertSame($expectedResult, $result); + } + + public function providerSortByRow(): array + { + return [ + [ + [ + ['Fritz', 19], + ['Xi', 19], + ['Amy', 22], + ['Srivan', 39], + ['Tom', 52], + ['Fred', 65], + ['Hector', 66], + ['Sal', 73], + ], + $this->sampleDataForRow(), + array_column($this->sampleDataForRow(), 1), + ], + [ + [ + ['Amy', 22], + ['Fred', 65], + ['Fritz', 19], + ['Hector', 66], + ['Sal', 73], + ['Srivan', 39], + ['Tom', 52], + ['Xi', 19], + ], + $this->sampleDataForRow(), + array_column($this->sampleDataForRow(), 0), + ], + [ + [ + ['Amy', 22], + ['Fred', 65], + ['Fritz', 19], + ['Hector', 66], + ['Sal', 73], + ['Srivan', 39], + ['Tom', 52], + ['Xi', 19], + ], + $this->sampleDataForRow(), + ['Tom', 'Fred', 'Amy', 'Sal', 'Fritz', 'Srivan', 'Xi', 'Hector'], + ], + [ + [ + ['Amy', 22], + ['Fred', 65], + ['Fritz', 19], + ['Hector', 66], + ['Sal', 73], + ['Srivan', 39], + ['Tom', 52], + ['Xi', 19], + ], + $this->sampleDataForRow(), + [['Tom'], ['Fred'], ['Amy'], ['Sal'], ['Fritz'], ['Srivan'], ['Xi'], ['Hector']], + ], + ]; + } + + private function sampleDataForRow(): array + { + return [ + ['Tom', 52], + ['Fred', 65], + ['Amy', 22], + ['Sal', 73], + ['Fritz', 19], + ['Srivan', 39], + ['Xi', 19], + ['Hector', 66], + ]; + } +} diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortTest.php new file mode 100644 index 00000000..bd120e30 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortTest.php @@ -0,0 +1,210 @@ + [-1, -1], + 'Non-numeric sortIndex' => ['A', -1], + 'Zero sortIndex' => [0, -1], + 'Too high sortIndex' => [3, -1], + 'Non-numeric sortOrder' => [1, 'A'], + 'Invalid negative sortOrder' => [1, -2], + 'Zero sortOrder' => [1, 0], + 'Invalid positive sortOrder' => [1, 2], + 'Too many sortOrders (scalar and array)' => [1, [-1, 1]], + 'Too many sortOrders (both array)' => [[1, 2], [1, 2, 3]], + 'Zero positive sortIndex in vector' => [[0, 1]], + 'Too high sortIndex in vector' => [[1, 3]], + 'Invalid sortOrder in vector' => [[1, 2], [1, -2]], + ]; + } + + /** + * @dataProvider providerSortByRow + */ + public function testSortByRow(array $expectedResult, array $matrix, int $sortIndex, int $sortOrder = Sort::ORDER_ASCENDING): void + { + $result = Sort::sort($matrix, $sortIndex, $sortOrder); + self::assertSame($expectedResult, $result); + } + + public function providerSortByRow(): array + { + return [ + [ + [[142], [378], [404], [445], [483], [622], [650], [691], [783], [961]], + $this->sampleDataForRow(), + 1, + ], + [ + [[961], [783], [691], [650], [622], [483], [445], [404], [378], [142]], + $this->sampleDataForRow(), + 1, + Sort::ORDER_DESCENDING, + ], + [ + [['Peaches', 25], ['Cherries', 29], ['Grapes', 31], ['Lemons', 34], ['Oranges', 36], ['Apples', 38], ['Pears', 40]], + [['Apples', 38], ['Cherries', 29], ['Grapes', 31], ['Lemons', 34], ['Oranges', 36], ['Peaches', 25], ['Pears', 40]], + 2, + ], + ]; + } + + /** + * @dataProvider providerSortByRowMultiLevel + */ + public function testSortByRowMultiLevel(array $expectedResult, array $matrix, array $sortIndex, int $sortOrder = Sort::ORDER_ASCENDING): void + { + $result = Sort::sort($matrix, $sortIndex, $sortOrder); + self::assertSame($expectedResult, $result); + } + + public function providerSortByRowMultiLevel(): array + { + return [ + [ + [ + ['East', 'Grapes', 31], + ['East', 'Lemons', 36], + ['North', 'Cherries', 29], + ['North', 'Grapes', 27], + ['North', 'Peaches', 25], + ['South', 'Apples', 38], + ['South', 'Cherries', 28], + ['South', 'Oranges', 36], + ['South', 'Pears', 40], + ['West', 'Apples', 30], + ['West', 'Lemons', 34], + ['West', 'Oranges', 25], + ], + $this->sampleDataForMultiRow(), + [1, 2], + ], + [ + [ + ['East', 'Grapes', 31], + ['East', 'Lemons', 36], + ['North', 'Peaches', 25], + ['North', 'Grapes', 27], + ['North', 'Cherries', 29], + ['South', 'Cherries', 28], + ['South', 'Oranges', 36], + ['South', 'Apples', 38], + ['South', 'Pears', 40], + ['West', 'Oranges', 25], + ['West', 'Apples', 30], + ['West', 'Lemons', 34], + ], + $this->sampleDataForMultiRow(), + [1, 3], + ], + [ + [ + ['West', 'Apples', 30], + ['South', 'Apples', 38], + ['South', 'Cherries', 28], + ['North', 'Cherries', 29], + ['North', 'Grapes', 27], + ['East', 'Grapes', 31], + ['West', 'Lemons', 34], + ['East', 'Lemons', 36], + ['West', 'Oranges', 25], + ['South', 'Oranges', 36], + ['North', 'Peaches', 25], + ['South', 'Pears', 40], + ], + $this->sampleDataForMultiRow(), + [2, 3], + ], + ]; + } + + /** + * @dataProvider providerSortByColumn + */ + public function testSortByColumn(array $expectedResult, array $matrix, int $sortIndex, int $sortOrder): void + { + $result = Sort::sort($matrix, $sortIndex, $sortOrder, true); + self::assertSame($expectedResult, $result); + } + + public function providerSortByColumn(): array + { + return [ + [ + [[142, 378, 404, 445, 483, 622, 650, 691, 783, 961]], + $this->sampleDataForColumn(), + 1, + Sort::ORDER_ASCENDING, + ], + [ + [[961, 783, 691, 650, 622, 483, 445, 404, 378, 142]], + $this->sampleDataForColumn(), + 1, + Sort::ORDER_DESCENDING, + ], + ]; + } + + public function sampleDataForRow(): array + { + return [ + [622], [961], [691], [445], [378], [483], [650], [783], [142], [404], + ]; + } + + public function sampleDataForMultiRow(): array + { + return [ + ['South', 'Pears', 40], + ['South', 'Apples', 38], + ['South', 'Oranges', 36], + ['East', 'Lemons', 36], + ['West', 'Lemons', 34], + ['East', 'Grapes', 31], + ['West', 'Apples', 30], + ['North', 'Cherries', 29], + ['South', 'Cherries', 28], + ['North', 'Grapes', 27], + ['North', 'Peaches', 25], + ['West', 'Oranges', 25], + ]; + } + + public function sampleDataForColumn(): array + { + return [ + [622, 961, 691, 445, 378, 483, 650, 783, 142, 404], + ]; + } +} From 9019523efc2151153776b0be689d09d040755471 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Thu, 24 Mar 2022 17:08:22 +0100 Subject: [PATCH 2/2] Additional unit testing And a quick bugfix for cell ranges applied to both sort functions and to FILTER() --- .../Calculation/LookupRef/Filter.php | 16 ++++ .../Calculation/LookupRef/Sort.php | 74 +++++++++++------- .../Functions/LookupRef/SortByTest.php | 75 +++++++++++++++---- 3 files changed, 126 insertions(+), 39 deletions(-) diff --git a/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php b/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php index a5e7dc17..6d201531 100644 --- a/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php +++ b/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php @@ -19,6 +19,8 @@ class Filter return ExcelError::VALUE(); } + $matchArray = self::enumerateArrayKeys($matchArray); + $result = (Matrix::isColumnVector($matchArray)) ? self::filterByRow($lookupArray, $matchArray) : self::filterByColumn($lookupArray, $matchArray); @@ -30,6 +32,20 @@ class Filter return array_values($result); } + private static function enumerateArrayKeys(array $sortArray): array + { + array_walk( + $sortArray, + function (&$columns): void { + if (is_array($columns)) { + $columns = array_values($columns); + } + } + ); + + return array_values($sortArray); + } + private static function filterByRow(array $lookupArray, array $matchArray): array { $matchArray = array_values(array_column($matchArray, 0)); diff --git a/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php b/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php index 48dc3388..ff78fbea 100644 --- a/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php +++ b/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php @@ -21,7 +21,7 @@ class Sort extends LookupRefValidations * Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting. * * @param mixed $sortArray The range of cells being sorted - * @param mixed $sortIndex Whether the uniqueness should be determined by row (the default) or by column + * @param mixed $sortIndex The column or row number within the sortArray to sort on * @param mixed $sortOrder Flag indicating whether to sort ascending or descending * Ascending = 1 (self::ORDER_ASCENDING) * Descending = -1 (self::ORDER_DESCENDING) @@ -29,13 +29,15 @@ class Sort extends LookupRefValidations * * @return mixed The sorted values from the sort range */ - public static function sort($sortArray, $sortIndex = [1], $sortOrder = self::ORDER_ASCENDING, $byColumn = false) + public static function sort($sortArray, $sortIndex = 1, $sortOrder = self::ORDER_ASCENDING, $byColumn = false) { if (!is_array($sortArray)) { // Scalars are always returned "as is" return $sortArray; } + $sortArray = self::enumerateArrayKeys($sortArray); + $byColumn = (bool) $byColumn; $lookupIndexSize = $byColumn ? count($sortArray) : count($sortArray[0]); @@ -68,6 +70,12 @@ class Sort extends LookupRefValidations * * @param mixed $sortArray The range of cells being sorted * @param mixed $args + * At least one additional argument must be provided, The vector or range to sort on + * After that, arguments are passed as pairs: + * sort order: ascending or descending + * Ascending = 1 (self::ORDER_ASCENDING) + * Descending = -1 (self::ORDER_DESCENDING) + * additional arrays or ranges for multi-level sorting * * @return mixed The sorted values from the sort range */ @@ -78,6 +86,8 @@ class Sort extends LookupRefValidations return $sortArray; } + $sortArray = self::enumerateArrayKeys($sortArray); + $lookupArraySize = count($sortArray); $argumentCount = count($args); @@ -94,11 +104,25 @@ class Sort extends LookupRefValidations return self::processSortBy($sortArray, $sortBy, $sortOrder); } + private static function enumerateArrayKeys(array $sortArray): array + { + array_walk( + $sortArray, + function (&$columns): void { + if (is_array($columns)) { + $columns = array_values($columns); + } + } + ); + + return array_values($sortArray); + } + /** * @param mixed $sortIndex * @param mixed $sortOrder */ - private static function validateScalarArgumentsForSort(&$sortIndex, &$sortOrder, int $lookupIndexSize): void + private static function validateScalarArgumentsForSort(&$sortIndex, &$sortOrder, int $sortArraySize): void { if (is_array($sortIndex) || is_array($sortOrder)) { throw new Exception(ExcelError::VALUE()); @@ -106,7 +130,7 @@ class Sort extends LookupRefValidations $sortIndex = self::validatePositiveInt($sortIndex, false); - if ($sortIndex > $lookupIndexSize) { + if ($sortIndex > $sortArraySize) { throw new Exception(ExcelError::VALUE()); } @@ -116,7 +140,7 @@ class Sort extends LookupRefValidations /** * @param mixed $sortVector */ - private static function validateSortVector($sortVector, int $lookupArraySize): array + private static function validateSortVector($sortVector, int $sortArraySize): array { if (!is_array($sortVector)) { throw new Exception(ExcelError::VALUE()); @@ -124,7 +148,7 @@ class Sort extends LookupRefValidations // It doesn't matter if it's a row or a column vectors, it works either way $sortVector = Functions::flattenArray($sortVector); - if (count($sortVector) !== $lookupArraySize) { + if (count($sortVector) !== $sortArraySize) { throw new Exception(ExcelError::VALUE()); } @@ -148,14 +172,14 @@ class Sort extends LookupRefValidations * @param array $sortIndex * @param mixed $sortOrder */ - private static function validateArrayArgumentsForSort(&$sortIndex, &$sortOrder, int $lookupIndexSize): void + private static function validateArrayArgumentsForSort(&$sortIndex, &$sortOrder, int $sortArraySize): void { // It doesn't matter if they're row or column vectors, it works either way $sortIndex = Functions::flattenArray($sortIndex); $sortOrder = Functions::flattenArray($sortOrder); if ( - count($sortOrder) === 0 || count($sortOrder) > $lookupIndexSize || + count($sortOrder) === 0 || count($sortOrder) > $sortArraySize || (count($sortOrder) > count($sortIndex)) ) { throw new Exception(ExcelError::VALUE()); @@ -170,7 +194,7 @@ class Sort extends LookupRefValidations } foreach ($sortIndex as $key => &$value) { - self::validateScalarArgumentsForSort($value, $sortOrder[$key], $lookupIndexSize); + self::validateScalarArgumentsForSort($value, $sortOrder[$key], $sortArraySize); } } @@ -195,7 +219,7 @@ class Sort extends LookupRefValidations * @param array[] $sortIndex * @param int[] $sortOrder */ - private static function processSortBy(array $lookupArray, array $sortIndex, $sortOrder): array + private static function processSortBy(array $sortArray, array $sortIndex, $sortOrder): array { $sortArguments = []; $sortData = []; @@ -204,32 +228,32 @@ class Sort extends LookupRefValidations $sortArguments[] = self::prepareSortVectorValues($sortValues); $sortArguments[] = $sortOrder[$index] === self::ORDER_ASCENDING ? SORT_ASC : SORT_DESC; } - $sortArguments = self::applyPHP7Patch($lookupArray, $sortArguments); + $sortArguments = self::applyPHP7Patch($sortArray, $sortArguments); $sortVector = self::executeVectorSortQuery($sortData, $sortArguments); - return self::sortLookupArrayFromVector($lookupArray, $sortVector); + return self::sortLookupArrayFromVector($sortArray, $sortVector); } /** * @param int[] $sortIndex * @param int[] $sortOrder */ - private static function sortByRow(array $lookupArray, array $sortIndex, array $sortOrder): array + private static function sortByRow(array $sortArray, array $sortIndex, array $sortOrder): array { - $sortVector = self::buildVectorForSort($lookupArray, $sortIndex, $sortOrder); + $sortVector = self::buildVectorForSort($sortArray, $sortIndex, $sortOrder); - return self::sortLookupArrayFromVector($lookupArray, $sortVector); + return self::sortLookupArrayFromVector($sortArray, $sortVector); } /** * @param int[] $sortIndex * @param int[] $sortOrder */ - private static function sortByColumn(array $lookupArray, array $sortIndex, array $sortOrder): array + private static function sortByColumn(array $sortArray, array $sortIndex, array $sortOrder): array { - $lookupArray = Matrix::transpose($lookupArray); - $result = self::sortByRow($lookupArray, $sortIndex, $sortOrder); + $sortArray = Matrix::transpose($sortArray); + $result = self::sortByRow($sortArray, $sortIndex, $sortOrder); return Matrix::transpose($result); } @@ -238,17 +262,17 @@ class Sort extends LookupRefValidations * @param int[] $sortIndex * @param int[] $sortOrder */ - private static function buildVectorForSort(array $lookupArray, array $sortIndex, array $sortOrder): array + private static function buildVectorForSort(array $sortArray, array $sortIndex, array $sortOrder): array { $sortArguments = []; $sortData = []; foreach ($sortIndex as $index => $sortIndexValue) { - $sortValues = array_column($lookupArray, $sortIndexValue - 1); + $sortValues = array_column($sortArray, $sortIndexValue - 1); $sortData[] = $sortValues; $sortArguments[] = self::prepareSortVectorValues($sortValues); $sortArguments[] = $sortOrder[$index] === self::ORDER_ASCENDING ? SORT_ASC : SORT_DESC; } - $sortArguments = self::applyPHP7Patch($lookupArray, $sortArguments); + $sortArguments = self::applyPHP7Patch($sortArray, $sortArguments); $sortData = self::executeVectorSortQuery($sortData, $sortArguments); @@ -279,12 +303,12 @@ class Sort extends LookupRefValidations return $sortedData; } - private static function sortLookupArrayFromVector(array $lookupArray, array $sortVector): array + private static function sortLookupArrayFromVector(array $sortArray, array $sortVector): array { // Building a new array in the correct (sorted) order works; but may be memory heavy for larger arrays $sortedArray = []; foreach ($sortVector as $index) { - $sortedArray[] = $lookupArray[$index]; + $sortedArray[] = $sortArray[$index]; } return $sortedArray; @@ -306,10 +330,10 @@ class Sort extends LookupRefValidations * MS Excel replicates the PHP 8.0.0 behaviour, retaining the original order of matching elements. * To replicate that behaviour with PHP 7, we add an extra sort based on the row index. */ - private static function applyPHP7Patch(array $lookupArray, array $sortArguments): array + private static function applyPHP7Patch(array $sortArray, array $sortArguments): array { if (PHP_VERSION_ID < 80000) { - $sortArguments[] = range(1, count($lookupArray)); + $sortArguments[] = range(1, count($sortArray)); $sortArguments[] = SORT_ASC; } diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php index 3acc8b6a..345f732b 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php @@ -12,7 +12,7 @@ class SortByTest extends TestCase { $value = 'NON-ARRAY'; - $result = Sort::sort($value, [1]); + $result = Sort::sortBy($value); self::assertSame($value, $result); } @@ -45,16 +45,16 @@ class SortByTest extends TestCase /** * @dataProvider providerSortByRow */ - public function testSortByRow(array $expectedResult, array $matrix, array $sortIndex, int $sortOrder = Sort::ORDER_ASCENDING): void + public function testSortByRow(array $expectedResult, array $matrix, ...$args): void { - $result = Sort::sortBy($matrix, $sortIndex, $sortOrder); + $result = Sort::sortBy($matrix, ...$args); self::assertSame($expectedResult, $result); } public function providerSortByRow(): array { return [ - [ + 'Simple sort by age' => [ [ ['Fritz', 19], ['Xi', 19], @@ -65,10 +65,10 @@ class SortByTest extends TestCase ['Hector', 66], ['Sal', 73], ], - $this->sampleDataForRow(), - array_column($this->sampleDataForRow(), 1), + $this->sampleDataForSimpleSort(), + array_column($this->sampleDataForSimpleSort(), 1), ], - [ + 'Simple sort by name' => [ [ ['Amy', 22], ['Fred', 65], @@ -79,10 +79,10 @@ class SortByTest extends TestCase ['Tom', 52], ['Xi', 19], ], - $this->sampleDataForRow(), - array_column($this->sampleDataForRow(), 0), + $this->sampleDataForSimpleSort(), + array_column($this->sampleDataForSimpleSort(), 0), ], - [ + 'Row vector' => [ [ ['Amy', 22], ['Fred', 65], @@ -93,10 +93,10 @@ class SortByTest extends TestCase ['Tom', 52], ['Xi', 19], ], - $this->sampleDataForRow(), + $this->sampleDataForSimpleSort(), ['Tom', 'Fred', 'Amy', 'Sal', 'Fritz', 'Srivan', 'Xi', 'Hector'], ], - [ + 'Column vector' => [ [ ['Amy', 22], ['Fred', 65], @@ -107,13 +107,46 @@ class SortByTest extends TestCase ['Tom', 52], ['Xi', 19], ], - $this->sampleDataForRow(), + $this->sampleDataForSimpleSort(), [['Tom'], ['Fred'], ['Amy'], ['Sal'], ['Fritz'], ['Srivan'], ['Xi'], ['Hector']], ], + 'Sort by region asc, name asc' => [ + [ + ['East', 'Fritz', 19], + ['East', 'Tom', 52], + ['North', 'Amy', 22], + ['North', 'Xi', 19], + ['South', 'Hector', 66], + ['South', 'Sal', 73], + ['West', 'Fred', 65], + ['West', 'Srivan', 39], + ], + $this->sampleDataForMultiSort(), + array_column($this->sampleDataForMultiSort(), 0), + Sort::ORDER_ASCENDING, + array_column($this->sampleDataForMultiSort(), 1), + ], + 'Sort by region asc, age desc' => [ + [ + ['East', 'Tom', 52], + ['East', 'Fritz', 19], + ['North', 'Amy', 22], + ['North', 'Xi', 19], + ['South', 'Sal', 73], + ['South', 'Hector', 66], + ['West', 'Fred', 65], + ['West', 'Srivan', 39], + ], + $this->sampleDataForMultiSort(), + array_column($this->sampleDataForMultiSort(), 0), + Sort::ORDER_ASCENDING, + array_column($this->sampleDataForMultiSort(), 2), + Sort::ORDER_DESCENDING, + ], ]; } - private function sampleDataForRow(): array + private function sampleDataForSimpleSort(): array { return [ ['Tom', 52], @@ -126,4 +159,18 @@ class SortByTest extends TestCase ['Hector', 66], ]; } + + private function sampleDataForMultiSort(): array + { + return [ + ['North', 'Amy', 22], + ['West', 'Fred', 65], + ['East', 'Fritz', 19], + ['South', 'Hector', 66], + ['South', 'Sal', 73], + ['West', 'Srivan', 39], + ['East', 'Tom', 52], + ['North', 'Xi', 19], + ]; + } }