Skip to content

Commit 8f6acb6

Browse files
authored
Merge pull request #4743 from oleibman/sortsortby
Better Tests for SORT and SORTBY
2 parents 60aa970 + 11d60f6 commit 8f6acb6

File tree

14 files changed

+905
-35
lines changed

14 files changed

+905
-35
lines changed

docs/topics/Excel Anomalies.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,8 @@ Similar fraction formats have inconsistent results in Excel. For example, if a c
4343

4444
## COUNTIF and Text Cells
4545

46-
In Excel, COUNTIF appears to ignore text cells, behavior which doesn't seem to be documented anywhere. See [this issue](https://github.com/PHPOffice/PhpSpreadsheet/issues/3802), which remains open because, in the absence of usable documentation, we aren't sure how to handle things.
46+
In Excel, COUNTIF appears to ignore text cells, behavior which doesn't seem to be documented anywhere. See [this issue](https://github.com/PHPOffice/PhpSpreadsheet/issues/3802), which remains open because, in the absence of usable documentation, we aren't sure how to handle things.
47+
48+
## SORT on Different DataTypes
49+
50+
Excel appears to sort so that numbers are lowest in sort order, strings are next, booleans are next (LibreOffice treats booleans as ints), and null is highest. In addition, if your sort includes a numeric string with a leading plus or minus sign, the plus sign will be considered part of the string (so that `"+1"` will sort before `"0"`), but the minus sign will be ignored (so that `"-3"` will sort between `"25"` and `"40"`). There might be nuances I haven't thought of yet. PhpSpreadsheet will not necessarily duplicate Excel's behavior. The best advice we can offer is to make sure that arrays you wish to sort consist of a single datatype, and don't contain numeric strings. Samples samples/LookupRef/SortExcel and SortExcelCols are added to give an idea of how you might emulate Excel's behavior. I am not yet convinced that there is a use case for adding it as a class member in the src tree.

docs/topics/reading-and-writing-to-file.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,8 @@ Code like the following can be used:
12071207
// rowDividers: true,
12081208
// rowHeaders: false,
12091209
// columnHeaders: false,
1210+
// Starting with release 5.4:
1211+
// numbersRight: TextGridRightAlign::numeric,
12101212
);
12111213
$result = $textGrid->render();
12121214
```

samples/LookupRef/SortExcel.php

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
4+
5+
use Exception;
6+
use PhpOffice\PhpSpreadsheet\Helper\Sample;
7+
use PhpOffice\PhpSpreadsheet\Helper\TextGridRightAlign;
8+
use Stringable;
9+
10+
class SortExcel
11+
{
12+
public const ASCENDING = 1;
13+
public const DESCENDING = -1;
14+
15+
private int $arrayCol;
16+
17+
private int $ascending;
18+
19+
private function cmp(mixed $rowA, mixed $rowB): int
20+
{
21+
$a = is_array($rowA) ? $rowA[$this->arrayCol] : $rowA;
22+
$b = is_array($rowB) ? $rowB[$this->arrayCol] : $rowB;
23+
if ($a instanceof Stringable) {
24+
$a = (string) $a;
25+
}
26+
if ($b instanceof Stringable) {
27+
$b = (string) $b;
28+
}
29+
if (is_array($a) || is_object($a) || is_resource($a) || is_array($b) || is_object($b) || is_resource($b)) {
30+
throw new Exception('Invalid datatype');
31+
}
32+
// null sorts highest
33+
if ($a === null) {
34+
return ($b === null) ? 0 : $this->ascending;
35+
}
36+
if ($b === null) {
37+
return -$this->ascending;
38+
}
39+
// int|float sorts lowest
40+
$numericA = is_int($a) || is_float($a);
41+
$numericB = is_int($b) || is_float($b);
42+
if ($numericA && $numericB) {
43+
if ($a == $b) {
44+
return 0;
45+
}
46+
47+
return ($a < $b) ? -$this->ascending : $this->ascending;
48+
}
49+
if ($numericA) {
50+
return -$this->ascending;
51+
}
52+
if ($numericB) {
53+
return $this->ascending;
54+
}
55+
// bool sorts higher than string
56+
if (is_bool($a)) {
57+
if (!is_bool($b)) {
58+
return $this->ascending;
59+
}
60+
if ($a) {
61+
return $b ? 0 : $this->ascending;
62+
}
63+
64+
return $b ? -$this->ascending : 0;
65+
}
66+
if (is_bool($b)) {
67+
return -$this->ascending;
68+
}
69+
// special handling for numeric strings starting with -
70+
/** @var string $a */
71+
$a2 = (string) preg_replace('/^-(\d)+$/', '$1', $a);
72+
/** @var string $b */
73+
$b2 = (string) preg_replace('/^-(\d)+$/', '$1', $b);
74+
75+
// strings sort case-insensitive
76+
return $this->ascending * strcasecmp($a2, $b2);
77+
}
78+
79+
/**
80+
* @param mixed[] $array
81+
*/
82+
public function sortArray(array &$array, int $ascending = self::ASCENDING, int $arrayCol = 0): void
83+
{
84+
if ($ascending !== 1 && $ascending !== -1) {
85+
throw new Exception('ascending must be 1 or -1');
86+
}
87+
$this->ascending = $ascending;
88+
$this->arrayCol = $arrayCol;
89+
usort($array, $this->cmp(...));
90+
}
91+
}
92+
93+
require __DIR__ . '/../Header.php';
94+
/** @var Sample $helper */
95+
$helper->log('Emulating how Excel sorts different DataTypes');
96+
97+
/** @param mixed[] $original */
98+
function displaySorted(array $original, Sample $helper): void
99+
{
100+
$sorted = $original;
101+
$sortExcel = new SortExcel();
102+
$sortExcel->sortArray($sorted);
103+
$outArray = [['Original', 'Sorted']];
104+
$count = count($original);
105+
for ($i = 0; $i < $count; ++$i) {
106+
$outArray[] = [$original[$i], $sorted[$i]];
107+
}
108+
$helper->displayGrid($outArray, TextGridRightAlign::floatOrInt);
109+
}
110+
111+
$helper->log('First example');
112+
$original = ['-3', '40', 'A', 'B', true, false, '+3', '1', '10', '2', '25', 1, 0, -1];
113+
displaySorted($original, $helper);
114+
115+
$helper->log('Second example');
116+
$original = ['a', 'A', null, 'x', 'X', true, false, -3, 1];
117+
displaySorted($original, $helper);
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
4+
5+
use Exception;
6+
use PhpOffice\PhpSpreadsheet\Helper\Sample;
7+
use PhpOffice\PhpSpreadsheet\Helper\TextGridRightAlign;
8+
use Stringable;
9+
10+
// this is the same class as in sortExcel
11+
class SortExcelCols
12+
{
13+
public const ASCENDING = 1;
14+
public const DESCENDING = -1;
15+
16+
private int $arrayCol;
17+
18+
private int $ascending;
19+
20+
private function cmp(mixed $rowA, mixed $rowB): int
21+
{
22+
$a = is_array($rowA) ? $rowA[$this->arrayCol] : $rowA;
23+
$b = is_array($rowB) ? $rowB[$this->arrayCol] : $rowB;
24+
if ($a instanceof Stringable) {
25+
$a = (string) $a;
26+
}
27+
if ($b instanceof Stringable) {
28+
$b = (string) $b;
29+
}
30+
if (is_array($a) || is_object($a) || is_resource($a) || is_array($b) || is_object($b) || is_resource($b)) {
31+
throw new Exception('Invalid datatype');
32+
}
33+
// null sorts highest
34+
if ($a === null) {
35+
return ($b === null) ? 0 : $this->ascending;
36+
}
37+
if ($b === null) {
38+
return -$this->ascending;
39+
}
40+
// int|float sorts lowest
41+
$numericA = is_int($a) || is_float($a);
42+
$numericB = is_int($b) || is_float($b);
43+
if ($numericA && $numericB) {
44+
if ($a == $b) {
45+
return 0;
46+
}
47+
48+
return ($a < $b) ? -$this->ascending : $this->ascending;
49+
}
50+
if ($numericA) {
51+
return -$this->ascending;
52+
}
53+
if ($numericB) {
54+
return $this->ascending;
55+
}
56+
// bool sorts higher than string
57+
if (is_bool($a)) {
58+
if (!is_bool($b)) {
59+
return $this->ascending;
60+
}
61+
if ($a) {
62+
return $b ? 0 : $this->ascending;
63+
}
64+
65+
return $b ? -$this->ascending : 0;
66+
}
67+
if (is_bool($b)) {
68+
return -$this->ascending;
69+
}
70+
// special handling for numeric strings starting with -
71+
/** @var string $a */
72+
$a2 = (string) preg_replace('/^-(\d)+$/', '$1', $a);
73+
/** @var string $b */
74+
$b2 = (string) preg_replace('/^-(\d)+$/', '$1', $b);
75+
76+
// strings sort case-insensitive
77+
return $this->ascending * strcasecmp($a2, $b2);
78+
}
79+
80+
/**
81+
* @param mixed[] $array
82+
*/
83+
public function sortArray(array &$array, int $ascending = self::ASCENDING, int $arrayCol = 0): void
84+
{
85+
if ($ascending !== 1 && $ascending !== -1) {
86+
throw new Exception('ascending must be 1 or -1');
87+
}
88+
$this->ascending = $ascending;
89+
$this->arrayCol = $arrayCol;
90+
usort($array, $this->cmp(...));
91+
}
92+
}
93+
94+
require __DIR__ . '/../Header.php';
95+
/** @var Sample $helper */
96+
$helper->log('Emulating how Excel sorts different DataTypes by Column');
97+
98+
$array = [
99+
['a', 'a', 'a'],
100+
['a', 'a', 'b'],
101+
['a', null, 'c'],
102+
['b', 'b', 1],
103+
['b', 'c', 2],
104+
['b', 'c', true],
105+
['c', 1, false],
106+
['c', 1, 'a'],
107+
['c', 2, 'b'],
108+
[1, 2, 'c'],
109+
[1, true, 1],
110+
[1, true, 2],
111+
[2, false, true],
112+
[2, false, false],
113+
[2, 'a', false],
114+
[true, 'b', true],
115+
[true, 'c', 2],
116+
[true, 1, 1],
117+
[false, 2, 'a'],
118+
[false, true, 'b'],
119+
[false, false, 'c'],
120+
];
121+
122+
/** @param array<int, array<int, mixed>> $original */
123+
function displaySortedCols(array $original, Sample $helper): void
124+
{
125+
$sorted = $original;
126+
$sortExcelCols = new SortExcelCols();
127+
$helper->log('Sort by least significant column (descending)');
128+
$sortExcelCols->sortArray($sorted, arrayCol: 2, ascending: -1);
129+
$helper->log('Sort by middle column (ascending)');
130+
$sortExcelCols->sortArray($sorted, arrayCol: 1, ascending: 1);
131+
$helper->log('Sort by most significant column (descending)');
132+
$sortExcelCols->sortArray($sorted, arrayCol: 0, ascending: -1);
133+
$outArray = [['Original', '', '', 'Sorted', '', '']];
134+
$count = count($original);
135+
/** @var string[][] $sorted */
136+
for ($i = 0; $i < $count; ++$i) {
137+
$outArray[] = [
138+
$original[$i][0],
139+
$original[$i][1],
140+
$original[$i][2],
141+
$sorted[$i][0],
142+
$sorted[$i][1],
143+
$sorted[$i][2],
144+
];
145+
}
146+
$helper->displayGrid($outArray, TextGridRightAlign::floatOrInt);
147+
}
148+
149+
displaySortedCols($array, $helper);

0 commit comments

Comments
 (0)