Document Excel's Behavior When Sorting Different DataTypes

This commit is contained in:
oleibman
2025-12-11 17:37:17 -08:00
parent f62034a752
commit d727455173
9 changed files with 144 additions and 12 deletions
+5 -1
View File
@@ -43,4 +43,8 @@ Similar fraction formats have inconsistent results in Excel. For example, if a c
## COUNTIF and Text Cells
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.
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.
## SORT on Different DataTypes
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 (and converted to int 0). 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. Sample samples/LookupRef/SortExcel is 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.
@@ -1207,6 +1207,8 @@ Code like the following can be used:
// rowDividers: true,
// rowHeaders: false,
// columnHeaders: false,
// Starting with release 5.4:
// numbersRight: TextGridRightAlign::numeric,
);
$result = $textGrid->render();
```
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
use PhpOffice\PhpSpreadsheet\Helper\Sample;
use PhpOffice\PhpSpreadsheet\Helper\TextGridRightAlign;
class SortExcel
{
private static function cmp(mixed $a, mixed $b): int
{
// null sorts highest
if (!is_scalar($a)) {
return (is_scalar($b)) ? 1 : 0;
}
if (!is_scalar($b)) {
return -1;
}
// int|float sorts lowest
$numericA = is_int($a) || is_float($a);
$numericB = is_int($b) || is_float($b);
if ($numericA && $numericB) {
if ($a == $b) {
return 0;
}
return ($a < $b) ? -1 : 1;
}
if ($numericA) {
return -1;
}
if ($numericB) {
return 1;
}
// bool sorts higher than string
if (is_bool($a)) {
if (!is_bool($b)) {
return 1;
}
if ($a) {
return $b ? 0 : 1;
}
return $b ? -1 : 0;
}
if (is_bool($b)) {
return -1;
}
// special handling for numeric strings starting with -
$a = (string) preg_replace('/^-(\d)+$/', '$1', $a);
$b = (string) preg_replace('/^-(\d)+$/', '$1', $b);
// strings sort case-insensitive
return strcasecmp($a, $b);
}
/**
* Sort a one-dimensional array in the same order as Excel.
*
* @param array<int, null|bool|float|int|string> $array
*/
public static function sortArray(array &$array): void
{
usort($array, self::cmp(...));
$j = count($array);
while ($j > 0) {
--$j;
if ($array[$j] !== null) {
break;
}
$array[$j] = 0;
}
}
}
require __DIR__ . '/../Header.php';
/** @var Sample $helper */
$helper->log('Emulating how Excel sorts different DataTypes');
/** @param array<int, null|bool|float|int|string> $original */
function displaySorted(array $original, Sample $helper): void
{
$sorted = $original;
SortExcel::sortArray($sorted);
$outArray = [['Original', 'Sorted']];
$count = count($original);
for ($i = 0; $i < $count; ++$i) {
$outArray[] = [$original[$i], $sorted[$i]];
}
$helper->displayGrid($outArray, TextGridRightAlign::floatOrInt);
}
$helper->log('First example');
$original = ['-3', '40', 'A', 'B', true, false, '+3', '1', '10', '2', '25', 1, 0, -1];
displaySorted($original, $helper);
$helper->log('Second example');
$original = ['a', 'A', null, 'x', 'X', true, false, -3, 1];
displaySorted($original, $helper);
@@ -19,6 +19,12 @@ class Sort extends LookupRefValidations
* The returned array is the same shape as the provided array argument.
* Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting.
*
* NOTE: If $sortArray contains a mixture of data types
* (string/int/bool), the results may be unexpected.
* This is also true if the array consists of string
* representations of numbers, especially if there are
* both positive and negative numbers in the mix.
*
* @param mixed $sortArray The range of cells being sorted
* @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
@@ -77,6 +83,12 @@ class Sort extends LookupRefValidations
* My interpretation of this is that by_array must be an
* array which contains exactly one row.
*
* NOTE: If the "byArray" contains a mixture of data types
* (string/int/bool), the results may be unexpected.
* This is also true if the array consists of string
* representations of numbers, especially if there are
* both positive and negative numbers in the mix.
*
* @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
+4 -1
View File
@@ -242,9 +242,12 @@ class Sample
}
/** @param mixed[][] $matrix */
public function displayGrid(array $matrix, ?bool $numbersRight = null): void
public function displayGrid(array $matrix, null|bool|TextGridRightAlign $numbersRight = null): void
{
$renderer = new TextGrid($matrix, $this->isCli());
if (is_bool($numbersRight)) {
$numbersRight = $numbersRight ? TextGridRightAlign::numeric : TextGridRightAlign::none;
}
if ($numbersRight !== null) {
$renderer->setNumbersRight($numbersRight);
}
+6 -6
View File
@@ -25,10 +25,10 @@ class TextGrid
protected bool $columnHeaders = true;
protected bool $numbersRight = false;
protected TextGridRightAlign $numbersRight = TextGridRightAlign::none;
/** @param mixed[][] $matrix */
public function __construct(array $matrix, bool $isCli = true, bool $rowDividers = false, bool $rowHeaders = true, bool $columnHeaders = true, bool $numbersRight = false)
public function __construct(array $matrix, bool $isCli = true, bool $rowDividers = false, bool $rowHeaders = true, bool $columnHeaders = true, TextGridRightAlign $numbersRight = TextGridRightAlign::none)
{
$this->rows = array_keys($matrix);
$this->columns = array_keys($matrix[$this->rows[0]]);
@@ -49,7 +49,7 @@ class TextGrid
$this->numbersRight = $numbersRight;
}
public function setNumbersRight(bool $numbersRight): void
public function setNumbersRight(TextGridRightAlign $numbersRight): void
{
$this->numbersRight = $numbersRight;
}
@@ -100,7 +100,7 @@ class TextGrid
$valueForLength = $this->getString($cell);
$displayCell = $this->isCli ? $valueForLength : htmlentities($valueForLength);
$this->gridDisplay .= '| ';
if ($this->rightAlign($displayCell)) {
if ($this->rightAlign($displayCell, $cell)) {
$this->gridDisplay .= str_repeat(' ', $columnWidths[$column] - $this->strlen($valueForLength)) . $displayCell . ' ';
} else {
$this->gridDisplay .= $displayCell . str_repeat(' ', $columnWidths[$column] - $this->strlen($valueForLength) + 1);
@@ -108,9 +108,9 @@ class TextGrid
}
}
protected function rightAlign(string $displayCell): bool
protected function rightAlign(string $displayCell, mixed $cell = null): bool
{
return $this->numbersRight && is_numeric($displayCell);
return ($this->numbersRight === TextGridRightAlign::numeric && is_numeric($displayCell)) || ($this->numbersRight === TextGridRightAlign::floatOrInt && (is_int($cell) || is_float($cell)));
}
/** @param int[] $columnWidths */
@@ -0,0 +1,10 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Helper;
enum TextGridRightAlign
{
case none;
case numeric;
case floatOrInt;
}
@@ -5,12 +5,13 @@ declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetTests\Helper;
use PhpOffice\PhpSpreadsheet\Helper\TextGrid;
use PhpOffice\PhpSpreadsheet\Helper\TextGridRightAlign;
class TextGridExtended extends TextGrid
{
protected function rightAlign(string $displayCell): bool
protected function rightAlign(string $displayCell, mixed $cell = null): bool
{
// regexp is imperfect, but good enough for test purposes
return $this->numbersRight && preg_match('/^[-+$,.0-9]+$/', $displayCell);
return is_int($cell) || is_float($cell) || ($this->numbersRight === TextGridRightAlign::numeric && preg_match('/^[-+$,.0-9]+$/', $displayCell));
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetTests\Helper;
use PhpOffice\PhpSpreadsheet\Helper\TextGrid;
use PhpOffice\PhpSpreadsheet\Helper\TextGridRightAlign;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -215,7 +216,7 @@ class TextGridTest extends TestCase
rowDividers: false,
rowHeaders: false,
columnHeaders: false,
numbersRight: true,
numbersRight: TextGridRightAlign::numeric,
);
$expected = [
'+-----+-------+--------------+',
@@ -251,7 +252,7 @@ class TextGridTest extends TestCase
rowHeaders: false,
columnHeaders: false,
);
$textGrid->setNumbersRight(true);
$textGrid->setNumbersRight(TextGridRightAlign::numeric);
$expected = [
'+-----+-------+--------------+',
'| 0 | 1.00 | $1,234.56 |',