Skip to content

Commit 7f00049

Browse files
author
MarkBaker
committed
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.
1 parent 6a349cc commit 7f00049

File tree

5 files changed

+661
-4
lines changed

5 files changed

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

0 commit comments

Comments
 (0)