Skip to content

Commit 11edcc9

Browse files
authored
Merge pull request #2711 from PHPOffice/LookupRef-Sort-Functions
Implementation of the SORT() and SORTBY() Lookup/Reference functions
2 parents 6a349cc + 9019523 commit 11edcc9

File tree

6 files changed

+748
-4
lines changed

6 files changed

+748
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
99

1010
### Added
1111

12-
- Implementation of the FILTER() and UNIQUE() Lookup/Reference (array) function
12+
- Implementation of the FILTER(), SORT(), SORTBY() and UNIQUE() Lookup/Reference (array) functions
1313
- Implementation of the ISREF() Information function.
1414
- Added support for reading "formatted" numeric values from Csv files; although default behaviour of reading these values as strings is preserved.
1515

src/PhpSpreadsheet/Calculation/Calculation.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2282,12 +2282,12 @@ class Calculation
22822282
],
22832283
'SORT' => [
22842284
'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE,
2285-
'functionCall' => [Functions::class, 'DUMMY'],
2286-
'argumentCount' => '1+',
2285+
'functionCall' => [LookupRef\Sort::class, 'sort'],
2286+
'argumentCount' => '1-4',
22872287
],
22882288
'SORTBY' => [
22892289
'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE,
2290-
'functionCall' => [Functions::class, 'DUMMY'],
2290+
'functionCall' => [LookupRef\Sort::class, 'sortBy'],
22912291
'argumentCount' => '2+',
22922292
],
22932293
'SQRT' => [

src/PhpSpreadsheet/Calculation/LookupRef/Filter.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public static function filter($lookupArray, $matchArray, $ifEmpty = null)
1919
return ExcelError::VALUE();
2020
}
2121

22+
$matchArray = self::enumerateArrayKeys($matchArray);
23+
2224
$result = (Matrix::isColumnVector($matchArray))
2325
? self::filterByRow($lookupArray, $matchArray)
2426
: self::filterByColumn($lookupArray, $matchArray);
@@ -30,6 +32,20 @@ public static function filter($lookupArray, $matchArray, $ifEmpty = null)
3032
return array_values($result);
3133
}
3234

35+
private static function enumerateArrayKeys(array $sortArray): array
36+
{
37+
array_walk(
38+
$sortArray,
39+
function (&$columns): void {
40+
if (is_array($columns)) {
41+
$columns = array_values($columns);
42+
}
43+
}
44+
);
45+
46+
return array_values($sortArray);
47+
}
48+
3349
private static function filterByRow(array $lookupArray, array $matchArray): array
3450
{
3551
$matchArray = array_values(array_column($matchArray, 0));
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
4+
5+
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
6+
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
7+
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
8+
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
9+
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
10+
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
11+
12+
class Sort extends LookupRefValidations
13+
{
14+
public const ORDER_ASCENDING = 1;
15+
public const ORDER_DESCENDING = -1;
16+
17+
/**
18+
* SORT
19+
* The SORT function returns a sorted array of the elements in an array.
20+
* The returned array is the same shape as the provided array argument.
21+
* Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting.
22+
*
23+
* @param mixed $sortArray The range of cells being sorted
24+
* @param mixed $sortIndex The column or row number within the sortArray to sort on
25+
* @param mixed $sortOrder Flag indicating whether to sort ascending or descending
26+
* Ascending = 1 (self::ORDER_ASCENDING)
27+
* Descending = -1 (self::ORDER_DESCENDING)
28+
* @param mixed $byColumn Whether the sort should be determined by row (the default) or by column
29+
*
30+
* @return mixed The sorted values from the sort range
31+
*/
32+
public static function sort($sortArray, $sortIndex = 1, $sortOrder = self::ORDER_ASCENDING, $byColumn = false)
33+
{
34+
if (!is_array($sortArray)) {
35+
// Scalars are always returned "as is"
36+
return $sortArray;
37+
}
38+
39+
$sortArray = self::enumerateArrayKeys($sortArray);
40+
41+
$byColumn = (bool) $byColumn;
42+
$lookupIndexSize = $byColumn ? count($sortArray) : count($sortArray[0]);
43+
44+
try {
45+
// If $sortIndex and $sortOrder are scalars, then convert them into arrays
46+
if (is_scalar($sortIndex)) {
47+
$sortIndex = [$sortIndex];
48+
$sortOrder = is_scalar($sortOrder) ? [$sortOrder] : $sortOrder;
49+
}
50+
// but the values of those array arguments still need validation
51+
$sortOrder = (empty($sortOrder) ? [self::ORDER_ASCENDING] : $sortOrder);
52+
self::validateArrayArgumentsForSort($sortIndex, $sortOrder, $lookupIndexSize);
53+
} catch (Exception $e) {
54+
return $e->getMessage();
55+
}
56+
57+
// We want a simple, enumrated array of arrays where we can reference column by its index number.
58+
$sortArray = array_values(array_map('array_values', $sortArray));
59+
60+
return ($byColumn === true)
61+
? self::sortByColumn($sortArray, $sortIndex, $sortOrder)
62+
: self::sortByRow($sortArray, $sortIndex, $sortOrder);
63+
}
64+
65+
/**
66+
* SORTBY
67+
* The SORTBY function sorts the contents of a range or array based on the values in a corresponding range or array.
68+
* The returned array is the same shape as the provided array argument.
69+
* Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting.
70+
*
71+
* @param mixed $sortArray The range of cells being sorted
72+
* @param mixed $args
73+
* At least one additional argument must be provided, The vector or range to sort on
74+
* After that, arguments are passed as pairs:
75+
* sort order: ascending or descending
76+
* Ascending = 1 (self::ORDER_ASCENDING)
77+
* Descending = -1 (self::ORDER_DESCENDING)
78+
* additional arrays or ranges for multi-level sorting
79+
*
80+
* @return mixed The sorted values from the sort range
81+
*/
82+
public static function sortBy($sortArray, ...$args)
83+
{
84+
if (!is_array($sortArray)) {
85+
// Scalars are always returned "as is"
86+
return $sortArray;
87+
}
88+
89+
$sortArray = self::enumerateArrayKeys($sortArray);
90+
91+
$lookupArraySize = count($sortArray);
92+
$argumentCount = count($args);
93+
94+
try {
95+
$sortBy = $sortOrder = [];
96+
for ($i = 0; $i < $argumentCount; $i += 2) {
97+
$sortBy[] = self::validateSortVector($args[$i], $lookupArraySize);
98+
$sortOrder[] = self::validateSortOrder($args[$i + 1] ?? self::ORDER_ASCENDING);
99+
}
100+
} catch (Exception $e) {
101+
return $e->getMessage();
102+
}
103+
104+
return self::processSortBy($sortArray, $sortBy, $sortOrder);
105+
}
106+
107+
private static function enumerateArrayKeys(array $sortArray): array
108+
{
109+
array_walk(
110+
$sortArray,
111+
function (&$columns): void {
112+
if (is_array($columns)) {
113+
$columns = array_values($columns);
114+
}
115+
}
116+
);
117+
118+
return array_values($sortArray);
119+
}
120+
121+
/**
122+
* @param mixed $sortIndex
123+
* @param mixed $sortOrder
124+
*/
125+
private static function validateScalarArgumentsForSort(&$sortIndex, &$sortOrder, int $sortArraySize): void
126+
{
127+
if (is_array($sortIndex) || is_array($sortOrder)) {
128+
throw new Exception(ExcelError::VALUE());
129+
}
130+
131+
$sortIndex = self::validatePositiveInt($sortIndex, false);
132+
133+
if ($sortIndex > $sortArraySize) {
134+
throw new Exception(ExcelError::VALUE());
135+
}
136+
137+
$sortOrder = self::validateSortOrder($sortOrder);
138+
}
139+
140+
/**
141+
* @param mixed $sortVector
142+
*/
143+
private static function validateSortVector($sortVector, int $sortArraySize): array
144+
{
145+
if (!is_array($sortVector)) {
146+
throw new Exception(ExcelError::VALUE());
147+
}
148+
149+
// It doesn't matter if it's a row or a column vectors, it works either way
150+
$sortVector = Functions::flattenArray($sortVector);
151+
if (count($sortVector) !== $sortArraySize) {
152+
throw new Exception(ExcelError::VALUE());
153+
}
154+
155+
return $sortVector;
156+
}
157+
158+
/**
159+
* @param mixed $sortOrder
160+
*/
161+
private static function validateSortOrder($sortOrder): int
162+
{
163+
$sortOrder = self::validateInt($sortOrder);
164+
if (($sortOrder == self::ORDER_ASCENDING || $sortOrder === self::ORDER_DESCENDING) === false) {
165+
throw new Exception(ExcelError::VALUE());
166+
}
167+
168+
return $sortOrder;
169+
}
170+
171+
/**
172+
* @param array $sortIndex
173+
* @param mixed $sortOrder
174+
*/
175+
private static function validateArrayArgumentsForSort(&$sortIndex, &$sortOrder, int $sortArraySize): void
176+
{
177+
// It doesn't matter if they're row or column vectors, it works either way
178+
$sortIndex = Functions::flattenArray($sortIndex);
179+
$sortOrder = Functions::flattenArray($sortOrder);
180+
181+
if (
182+
count($sortOrder) === 0 || count($sortOrder) > $sortArraySize ||
183+
(count($sortOrder) > count($sortIndex))
184+
) {
185+
throw new Exception(ExcelError::VALUE());
186+
}
187+
188+
if (count($sortIndex) > count($sortOrder)) {
189+
// If $sortOrder has fewer elements than $sortIndex, then the last order element is repeated.
190+
$sortOrder = array_merge(
191+
$sortOrder,
192+
array_fill(0, count($sortIndex) - count($sortOrder), array_pop($sortOrder))
193+
);
194+
}
195+
196+
foreach ($sortIndex as $key => &$value) {
197+
self::validateScalarArgumentsForSort($value, $sortOrder[$key], $sortArraySize);
198+
}
199+
}
200+
201+
private static function prepareSortVectorValues(array $sortVector): array
202+
{
203+
// Strings should be sorted case-insensitive; with booleans converted to locale-strings
204+
return array_map(
205+
function ($value) {
206+
if (is_bool($value)) {
207+
return ($value) ? Calculation::getTRUE() : Calculation::getFALSE();
208+
} elseif (is_string($value)) {
209+
return StringHelper::strToLower($value);
210+
}
211+
212+
return $value;
213+
},
214+
$sortVector
215+
);
216+
}
217+
218+
/**
219+
* @param array[] $sortIndex
220+
* @param int[] $sortOrder
221+
*/
222+
private static function processSortBy(array $sortArray, array $sortIndex, $sortOrder): array
223+
{
224+
$sortArguments = [];
225+
$sortData = [];
226+
foreach ($sortIndex as $index => $sortValues) {
227+
$sortData[] = $sortValues;
228+
$sortArguments[] = self::prepareSortVectorValues($sortValues);
229+
$sortArguments[] = $sortOrder[$index] === self::ORDER_ASCENDING ? SORT_ASC : SORT_DESC;
230+
}
231+
$sortArguments = self::applyPHP7Patch($sortArray, $sortArguments);
232+
233+
$sortVector = self::executeVectorSortQuery($sortData, $sortArguments);
234+
235+
return self::sortLookupArrayFromVector($sortArray, $sortVector);
236+
}
237+
238+
/**
239+
* @param int[] $sortIndex
240+
* @param int[] $sortOrder
241+
*/
242+
private static function sortByRow(array $sortArray, array $sortIndex, array $sortOrder): array
243+
{
244+
$sortVector = self::buildVectorForSort($sortArray, $sortIndex, $sortOrder);
245+
246+
return self::sortLookupArrayFromVector($sortArray, $sortVector);
247+
}
248+
249+
/**
250+
* @param int[] $sortIndex
251+
* @param int[] $sortOrder
252+
*/
253+
private static function sortByColumn(array $sortArray, array $sortIndex, array $sortOrder): array
254+
{
255+
$sortArray = Matrix::transpose($sortArray);
256+
$result = self::sortByRow($sortArray, $sortIndex, $sortOrder);
257+
258+
return Matrix::transpose($result);
259+
}
260+
261+
/**
262+
* @param int[] $sortIndex
263+
* @param int[] $sortOrder
264+
*/
265+
private static function buildVectorForSort(array $sortArray, array $sortIndex, array $sortOrder): array
266+
{
267+
$sortArguments = [];
268+
$sortData = [];
269+
foreach ($sortIndex as $index => $sortIndexValue) {
270+
$sortValues = array_column($sortArray, $sortIndexValue - 1);
271+
$sortData[] = $sortValues;
272+
$sortArguments[] = self::prepareSortVectorValues($sortValues);
273+
$sortArguments[] = $sortOrder[$index] === self::ORDER_ASCENDING ? SORT_ASC : SORT_DESC;
274+
}
275+
$sortArguments = self::applyPHP7Patch($sortArray, $sortArguments);
276+
277+
$sortData = self::executeVectorSortQuery($sortData, $sortArguments);
278+
279+
return $sortData;
280+
}
281+
282+
private static function executeVectorSortQuery(array $sortData, array $sortArguments): array
283+
{
284+
$sortData = Matrix::transpose($sortData);
285+
286+
// We need to set an index that can be retained, as array_multisort doesn't maintain numeric keys.
287+
$sortDataIndexed = [];
288+
foreach ($sortData as $key => $value) {
289+
$sortDataIndexed[Coordinate::stringFromColumnIndex($key + 1)] = $value;
290+
}
291+
unset($sortData);
292+
293+
$sortArguments[] = &$sortDataIndexed;
294+
295+
array_multisort(...$sortArguments);
296+
297+
// After the sort, we restore the numeric keys that will now be in the correct, sorted order
298+
$sortedData = [];
299+
foreach (array_keys($sortDataIndexed) as $key) {
300+
$sortedData[] = Coordinate::columnIndexFromString($key) - 1;
301+
}
302+
303+
return $sortedData;
304+
}
305+
306+
private static function sortLookupArrayFromVector(array $sortArray, array $sortVector): array
307+
{
308+
// Building a new array in the correct (sorted) order works; but may be memory heavy for larger arrays
309+
$sortedArray = [];
310+
foreach ($sortVector as $index) {
311+
$sortedArray[] = $sortArray[$index];
312+
}
313+
314+
return $sortedArray;
315+
316+
// uksort(
317+
// $lookupArray,
318+
// function (int $a, int $b) use (array $sortVector) {
319+
// return $sortVector[$a] <=> $sortVector[$b];
320+
// }
321+
// );
322+
//
323+
// return $lookupArray;
324+
}
325+
326+
/**
327+
* Hack to handle PHP 7:
328+
* From PHP 8.0.0, If two members compare as equal in a sort, they retain their original order;
329+
* but prior to PHP 8.0.0, their relative order in the sorted array was undefined.
330+
* MS Excel replicates the PHP 8.0.0 behaviour, retaining the original order of matching elements.
331+
* To replicate that behaviour with PHP 7, we add an extra sort based on the row index.
332+
*/
333+
private static function applyPHP7Patch(array $sortArray, array $sortArguments): array
334+
{
335+
if (PHP_VERSION_ID < 80000) {
336+
$sortArguments[] = range(1, count($sortArray));
337+
$sortArguments[] = SORT_ASC;
338+
}
339+
340+
return $sortArguments;
341+
}
342+
}

0 commit comments

Comments
 (0)