diff --git a/docs/topics/Excel Anomalies.md b/docs/topics/Excel Anomalies.md index d41337a5f5..55a5f293b3 100644 --- a/docs/topics/Excel Anomalies.md +++ b/docs/topics/Excel Anomalies.md @@ -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. \ No newline at end of file +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. 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. diff --git a/docs/topics/reading-and-writing-to-file.md b/docs/topics/reading-and-writing-to-file.md index 49845e9930..1242b7921f 100644 --- a/docs/topics/reading-and-writing-to-file.md +++ b/docs/topics/reading-and-writing-to-file.md @@ -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(); ``` diff --git a/samples/LookupRef/SortExcel.php b/samples/LookupRef/SortExcel.php new file mode 100644 index 0000000000..b5e8d8b327 --- /dev/null +++ b/samples/LookupRef/SortExcel.php @@ -0,0 +1,117 @@ +arrayCol] : $rowA; + $b = is_array($rowB) ? $rowB[$this->arrayCol] : $rowB; + if ($a instanceof Stringable) { + $a = (string) $a; + } + if ($b instanceof Stringable) { + $b = (string) $b; + } + if (is_array($a) || is_object($a) || is_resource($a) || is_array($b) || is_object($b) || is_resource($b)) { + throw new Exception('Invalid datatype'); + } + // null sorts highest + if ($a === null) { + return ($b === null) ? 0 : $this->ascending; + } + if ($b === null) { + return -$this->ascending; + } + // 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) ? -$this->ascending : $this->ascending; + } + if ($numericA) { + return -$this->ascending; + } + if ($numericB) { + return $this->ascending; + } + // bool sorts higher than string + if (is_bool($a)) { + if (!is_bool($b)) { + return $this->ascending; + } + if ($a) { + return $b ? 0 : $this->ascending; + } + + return $b ? -$this->ascending : 0; + } + if (is_bool($b)) { + return -$this->ascending; + } + // special handling for numeric strings starting with - + /** @var string $a */ + $a2 = (string) preg_replace('/^-(\d)+$/', '$1', $a); + /** @var string $b */ + $b2 = (string) preg_replace('/^-(\d)+$/', '$1', $b); + + // strings sort case-insensitive + return $this->ascending * strcasecmp($a2, $b2); + } + + /** + * @param mixed[] $array + */ + public function sortArray(array &$array, int $ascending = self::ASCENDING, int $arrayCol = 0): void + { + if ($ascending !== 1 && $ascending !== -1) { + throw new Exception('ascending must be 1 or -1'); + } + $this->ascending = $ascending; + $this->arrayCol = $arrayCol; + usort($array, $this->cmp(...)); + } +} + +require __DIR__ . '/../Header.php'; +/** @var Sample $helper */ +$helper->log('Emulating how Excel sorts different DataTypes'); + +/** @param mixed[] $original */ +function displaySorted(array $original, Sample $helper): void +{ + $sorted = $original; + $sortExcel = new SortExcel(); + $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); diff --git a/samples/LookupRef/SortExcelCols.php b/samples/LookupRef/SortExcelCols.php new file mode 100644 index 0000000000..b65ff09eb3 --- /dev/null +++ b/samples/LookupRef/SortExcelCols.php @@ -0,0 +1,149 @@ +arrayCol] : $rowA; + $b = is_array($rowB) ? $rowB[$this->arrayCol] : $rowB; + if ($a instanceof Stringable) { + $a = (string) $a; + } + if ($b instanceof Stringable) { + $b = (string) $b; + } + if (is_array($a) || is_object($a) || is_resource($a) || is_array($b) || is_object($b) || is_resource($b)) { + throw new Exception('Invalid datatype'); + } + // null sorts highest + if ($a === null) { + return ($b === null) ? 0 : $this->ascending; + } + if ($b === null) { + return -$this->ascending; + } + // 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) ? -$this->ascending : $this->ascending; + } + if ($numericA) { + return -$this->ascending; + } + if ($numericB) { + return $this->ascending; + } + // bool sorts higher than string + if (is_bool($a)) { + if (!is_bool($b)) { + return $this->ascending; + } + if ($a) { + return $b ? 0 : $this->ascending; + } + + return $b ? -$this->ascending : 0; + } + if (is_bool($b)) { + return -$this->ascending; + } + // special handling for numeric strings starting with - + /** @var string $a */ + $a2 = (string) preg_replace('/^-(\d)+$/', '$1', $a); + /** @var string $b */ + $b2 = (string) preg_replace('/^-(\d)+$/', '$1', $b); + + // strings sort case-insensitive + return $this->ascending * strcasecmp($a2, $b2); + } + + /** + * @param mixed[] $array + */ + public function sortArray(array &$array, int $ascending = self::ASCENDING, int $arrayCol = 0): void + { + if ($ascending !== 1 && $ascending !== -1) { + throw new Exception('ascending must be 1 or -1'); + } + $this->ascending = $ascending; + $this->arrayCol = $arrayCol; + usort($array, $this->cmp(...)); + } +} + +require __DIR__ . '/../Header.php'; +/** @var Sample $helper */ +$helper->log('Emulating how Excel sorts different DataTypes by Column'); + +$array = [ + ['a', 'a', 'a'], + ['a', 'a', 'b'], + ['a', null, 'c'], + ['b', 'b', 1], + ['b', 'c', 2], + ['b', 'c', true], + ['c', 1, false], + ['c', 1, 'a'], + ['c', 2, 'b'], + [1, 2, 'c'], + [1, true, 1], + [1, true, 2], + [2, false, true], + [2, false, false], + [2, 'a', false], + [true, 'b', true], + [true, 'c', 2], + [true, 1, 1], + [false, 2, 'a'], + [false, true, 'b'], + [false, false, 'c'], +]; + +/** @param array> $original */ +function displaySortedCols(array $original, Sample $helper): void +{ + $sorted = $original; + $sortExcelCols = new SortExcelCols(); + $helper->log('Sort by least significant column (descending)'); + $sortExcelCols->sortArray($sorted, arrayCol: 2, ascending: -1); + $helper->log('Sort by middle column (ascending)'); + $sortExcelCols->sortArray($sorted, arrayCol: 1, ascending: 1); + $helper->log('Sort by most significant column (descending)'); + $sortExcelCols->sortArray($sorted, arrayCol: 0, ascending: -1); + $outArray = [['Original', '', '', 'Sorted', '', '']]; + $count = count($original); + /** @var string[][] $sorted */ + for ($i = 0; $i < $count; ++$i) { + $outArray[] = [ + $original[$i][0], + $original[$i][1], + $original[$i][2], + $sorted[$i][0], + $sorted[$i][1], + $sorted[$i][2], + ]; + } + $helper->displayGrid($outArray, TextGridRightAlign::floatOrInt); +} + +displaySortedCols($array, $helper); diff --git a/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php b/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php index b640f7d9af..e013da79ca 100644 --- a/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php +++ b/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php @@ -2,7 +2,6 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef; -use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Exception; use PhpOffice\PhpSpreadsheet\Calculation\Functions; use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; @@ -20,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 @@ -32,8 +37,7 @@ class Sort extends LookupRefValidations public static function sort(mixed $sortArray, mixed $sortIndex = 1, mixed $sortOrder = self::ORDER_ASCENDING, mixed $byColumn = false): mixed { if (!is_array($sortArray)) { - // Scalars are always returned "as is" - return $sortArray; + $sortArray = [[$sortArray]]; } /** @var mixed[][] */ @@ -72,6 +76,18 @@ public static function sort(mixed $sortArray, mixed $sortIndex = 1, mixed $sortO * 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. + * Microsoft doesn't even bother documenting that a column sort + * is possible. However, it is. According to: + * https://exceljet.net/functions/sortby-function + * When by_array is a horizontal range, SORTBY sorts horizontally by columns. + * 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 @@ -87,8 +103,16 @@ public static function sort(mixed $sortArray, mixed $sortIndex = 1, mixed $sortO public static function sortBy(mixed $sortArray, mixed ...$args): mixed { if (!is_array($sortArray)) { - // Scalars are always returned "as is" - return $sortArray; + $sortArray = [[$sortArray]]; + } + $transpose = false; + $args0 = $args[0] ?? null; + if (is_array($args0) && count($args0) === 1) { + $args0 = reset($args0); + if (is_array($args0) && count($args0) > 1) { + $transpose = true; + $sortArray = Matrix::transpose($sortArray); + } } $sortArray = self::enumerateArrayKeys($sortArray); @@ -99,14 +123,23 @@ public static function sortBy(mixed $sortArray, mixed ...$args): mixed try { $sortBy = $sortOrder = []; for ($i = 0; $i < $argumentCount; $i += 2) { - $sortBy[] = self::validateSortVector($args[$i], $lookupArraySize); + $argsI = $args[$i]; + if (!is_array($argsI)) { + $argsI = [[$argsI]]; + } + $sortBy[] = self::validateSortVector($argsI, $lookupArraySize); $sortOrder[] = self::validateSortOrder($args[$i + 1] ?? self::ORDER_ASCENDING); } } catch (Exception $e) { return $e->getMessage(); } - return self::processSortBy($sortArray, $sortBy, $sortOrder); + $temp = self::processSortBy($sortArray, $sortBy, $sortOrder); + if ($transpose) { + $temp = Matrix::transpose($temp); + } + + return $temp; } /** @@ -130,10 +163,6 @@ function (&$columns): void { private static function validateScalarArgumentsForSort(mixed &$sortIndex, mixed &$sortOrder, int $sortArraySize): void { - if (is_array($sortIndex) || is_array($sortOrder)) { - throw new Exception(ExcelError::VALUE()); - } - $sortIndex = self::validatePositiveInt($sortIndex, false); if ($sortIndex > $sortArraySize) { @@ -143,13 +172,13 @@ private static function validateScalarArgumentsForSort(mixed &$sortIndex, mixed $sortOrder = self::validateSortOrder($sortOrder); } - /** @return mixed[] */ - private static function validateSortVector(mixed $sortVector, int $sortArraySize): array + /** + * @param mixed[] $sortVector + * + * @return mixed[] + */ + private static function validateSortVector(array $sortVector, int $sortArraySize): 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) !== $sortArraySize) { @@ -203,12 +232,19 @@ private static function validateArrayArgumentsForSort(array &$sortIndex, mixed & */ private static function prepareSortVectorValues(array $sortVector): array { - // Strings should be sorted case-insensitive; with booleans converted to locale-strings + // Strings should be sorted case-insensitive. + // Booleans are a complete mess. Excel always seems to sort + // booleans in a mixed vector at either the top or the bottom, + // so converting them to string or int doesn't really work. + // Best advice is to use them in a boolean-only vector. + // Code below chooses int conversion, which is sensible, + // and, as a bonus, compatible with LibreOffice. return array_map( function ($value) { if (is_bool($value)) { - return ($value) ? Calculation::getTRUE() : Calculation::getFALSE(); - } elseif (is_string($value)) { + return (int) $value; + } + if (is_string($value)) { return StringHelper::strToLower($value); } diff --git a/src/PhpSpreadsheet/Helper/Sample.php b/src/PhpSpreadsheet/Helper/Sample.php index 5933f74695..b1283bb0b9 100644 --- a/src/PhpSpreadsheet/Helper/Sample.php +++ b/src/PhpSpreadsheet/Helper/Sample.php @@ -242,9 +242,12 @@ public function titles(string $category, string $functionName, ?string $descript } /** @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); } diff --git a/src/PhpSpreadsheet/Helper/TextGrid.php b/src/PhpSpreadsheet/Helper/TextGrid.php index d9b78a0975..cd6fb2f215 100644 --- a/src/PhpSpreadsheet/Helper/TextGrid.php +++ b/src/PhpSpreadsheet/Helper/TextGrid.php @@ -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 @@ function (&$row): void { $this->numbersRight = $numbersRight; } - public function setNumbersRight(bool $numbersRight): void + public function setNumbersRight(TextGridRightAlign $numbersRight): void { $this->numbersRight = $numbersRight; } @@ -100,7 +100,7 @@ protected function renderCells(array $rowData, array $columnWidths): void $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 @@ protected function renderCells(array $rowData, array $columnWidths): void } } - 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 */ diff --git a/src/PhpSpreadsheet/Helper/TextGridRightAlign.php b/src/PhpSpreadsheet/Helper/TextGridRightAlign.php new file mode 100644 index 0000000000..2921e5cd62 --- /dev/null +++ b/src/PhpSpreadsheet/Helper/TextGridRightAlign.php @@ -0,0 +1,10 @@ +spreadsheet->disconnectWorksheets(); + unset($this->spreadsheet); + } + + /** @param mixed[] $values */ + public function getSheet(array $values): Worksheet + { + $this->spreadsheet = new Spreadsheet(); + $this->spreadsheet->returnArrayAsArray(); + $sheet = $this->spreadsheet->getActiveSheet(); + $sheet->fromArray($values); + $this->maxRow = $sheet->getHighestDataRow(); + $this->maxCol = $sheet->getHighestDataColumn(); + $this->range = "A1:{$this->maxCol}{$this->maxRow}"; + + return $sheet; + } + + public function testSortOnScalar(): void + { + $value = 'NON-ARRAY'; + $sheet = $this->getSheet([$value]); + $sheet->getCell('Z1')->setValue('=SORT(A1, 1, -1)'); + $sheet->getCell('Z2')->setValue('=SORT(A1:A1, 1, -1)'); + $sheet->getCell('Z3')->setValue('=SORT(A1, "A", -1)'); + + $result = $sheet->getCell('Z1')->getCalculatedValue(); + self::assertSame([[$value]], $result); + $result = $sheet->getCell('Z2')->getCalculatedValue(); + self::assertSame([[$value]], $result); + $result = $sheet->getCell('Z3')->getCalculatedValue(); + self::assertSame(ExcelError::VALUE(), $result); + } + + #[DataProvider('providerSortWithScalarArgumentErrorReturns')] + public function testSortWithScalarArgumentErrorReturns(int|string $sortIndex, int|string $sortOrder = 1): void + { + $value = [[1, 2], [3, 4], [5, 6]]; + $sheet = $this->getSheet($value); + $formula = "=SORT({$this->range}, $sortIndex, $sortOrder)"; + $sheet->getCell('Z1')->setValue($formula); + $result = $sheet->getCell('Z1')->getCalculatedValue(); + self::assertSame(ExcelError::VALUE(), $result); + } + + public static function providerSortWithScalarArgumentErrorReturns(): array + { + return [ + 'Negative sortIndex' => [-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}'], + ]; + } + + /** + * @param mixed[] $expectedResult + * @param mixed[] $matrix + */ + #[DataProvider('providerSortByRow')] + public function testSortByRow(array $expectedResult, array $matrix, int $sortIndex, int $sortOrder = Sort::ORDER_ASCENDING): void + { + $sheet = $this->getSheet($matrix); + $formula = "=SORT({$this->range}, $sortIndex, $sortOrder)"; + $sheet->getCell('Z1')->setValue($formula); + $result = $sheet->getCell('Z1')->getCalculatedValue(); + self::assertSame($expectedResult, $result); + } + + /** @return mixed[] */ + public static function providerSortByRow(): array + { + return [ + [ + [[142], [378], [404], [445], [483], [622], [650], [691], [783], [961]], + self::sampleDataForRow(), + 1, + ], + [ + [[961], [783], [691], [650], [622], [483], [445], [404], [378], [142]], + self::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, + ], + ]; + } + + /** + * @param mixed[] $expectedResult + * @param mixed[] $matrix + */ + #[DataProvider('providerSortByRowMultiLevel')] + public function testSortByRowMultiLevel(array $expectedResult, array $matrix, string $sortIndex, int $sortOrder = Sort::ORDER_ASCENDING): void + { + $sheet = $this->getSheet($matrix); + $formula = "=SORT({$this->range}, $sortIndex, $sortOrder)"; + $sheet->getCell('Z1')->setValue($formula); + $result = $sheet->getCell('Z1')->getCalculatedValue(); + self::assertSame($expectedResult, $result); + } + + public static 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], + ], + self::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], + ], + self::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], + ], + self::sampleDataForMultiRow(), + '{2, 3}', + ], + ]; + } + + /** + * @param mixed[] $expectedResult + * @param mixed[] $matrix + */ + #[DataProvider('providerSortByColumn')] + public function testSortByColumn(array $expectedResult, array $matrix, int $sortIndex, int $sortOrder): void + { + $sheet = $this->getSheet($matrix); + $formula = "=SORT({$this->range}, $sortIndex, $sortOrder, TRUE)"; + $sheet->getCell('Z1')->setValue($formula); + $result = $sheet->getCell('Z1')->getCalculatedValue(); + self::assertSame($expectedResult, $result); + } + + public static function providerSortByColumn(): array + { + return [ + [ + [[142, 378, 404, 445, 483, 622, 650, 691, 783, 961]], + self::sampleDataForColumn(), + 1, + Sort::ORDER_ASCENDING, + ], + [ + [[961, 783, 691, 650, 622, 483, 445, 404, 378, 142]], + self::sampleDataForColumn(), + 1, + Sort::ORDER_DESCENDING, + ], + ]; + } + + /** @return array */ + public static function sampleDataForRow(): array + { + return [ + [622], [961], [691], [445], [378], [483], [650], [783], [142], [404], + ]; + } + + /** @return array */ + public static 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], + ]; + } + + /** @return array */ + public static function sampleDataForColumn(): array + { + return [ + [622, 961, 691, 445, 378, 483, 650, 783, 142, 404], + ]; + } +} diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByBetterTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByBetterTest.php new file mode 100644 index 0000000000..a8fb0095ed --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByBetterTest.php @@ -0,0 +1,280 @@ +spreadsheet->disconnectWorksheets(); + unset($this->spreadsheet); + } + + /** @param mixed[] $values */ + public function getSheet(array $values): Worksheet + { + $this->spreadsheet = new Spreadsheet(); + $this->spreadsheet->returnArrayAsArray(); + $sheet = $this->spreadsheet->getActiveSheet(); + $sheet->fromArray($values); + $this->maxRow = $sheet->getHighestDataRow(); + $this->maxCol = $sheet->getHighestDataColumn(); + $this->range = "A1:{$this->maxCol}{$this->maxRow}"; + + return $sheet; + } + + public function testSortOnScalar(): void + { + $value = 'NON-ARRAY'; + $byArray = 'ARRAY'; + $sheet = $this->getSheet([$value, $byArray]); + $sheet->getCell('Z1')->setValue('=SORTBY(A1, B1:B1, 1)'); + $sheet->getCell('Z2')->setValue('=SORTBY(A1, B1, 1)'); + $sheet->getCell('Z3')->setValue('=SORTBY(A1, B1, "A")'); + + $result = $sheet->getCell('Z1')->getCalculatedValue(); + self::assertSame([[$value]], $result); + $result = $sheet->getCell('Z2')->getCalculatedValue(); + self::assertSame([[$value]], $result); + $result = $sheet->getCell('Z3')->getCalculatedValue(); + self::assertSame(ExcelError::VALUE(), $result); + } + + #[DataProvider('providerSortWithScalarArgumentErrorReturns')] + public function testSortByWithArgumentErrorReturns(string $byArray, int|string $sortOrder = 1): void + { + $value = [[1, 2], [3, 4], [5, 6]]; + $sheet = $this->getSheet($value); + $formula = "=SORTBY({$this->range}, $byArray, $sortOrder)"; + $sheet->getCell('Z1')->setValue($formula); + $result = $sheet->getCell('Z1')->getCalculatedValue(); + self::assertSame(ExcelError::VALUE(), $result); + } + + public static function providerSortWithScalarArgumentErrorReturns(): array + { + return [ + 'Non-array sortIndex' => ['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], + ]; + } + + /** + * @param mixed[] $matrix + */ + #[DataProvider('providerSortByRow')] + public function testSortByRow(array $expectedResult, array $matrix, string $byArray, ?int $sortOrder = null, ?string $byArray2 = null, ?int $sortOrder2 = null): void + { + $sheet = $this->getSheet($matrix); + $sheet->fromArray([['B'], ['D'], ['A'], ['C'], ['H'], ['G'], ['F'], ['E']], null, 'G1', true); + $sheet->fromArray([[true], [false], [true], [false], [true], [false], [true], [false]], null, 'H1', true); + $formula = "=SORTBY({$this->range}, $byArray"; + if ($sortOrder !== null) { + $formula .= ", $sortOrder"; + if ($byArray2 !== null) { + $formula .= ", $byArray2"; + if ($sortOrder2 !== null) { + $formula .= ", $sortOrder2"; + } + } + } + $formula .= ')'; + $sheet->getCell('Z1')->setValue($formula); + $result = $sheet->getCell('Z1')->getCalculatedValue(); + self::assertSame($expectedResult, $result); + } + + public static function providerSortByRow(): array + { + return [ + 'Simple sort by age' => [ + [ + ['Fritz', 19], + ['Xi', 19], + ['Amy', 22], + ['Srivan', 39], + ['Tom', 52], + ['Fred', 65], + ['Hector', 66], + ['Sal', 73], + ], + self::sampleDataForSimpleSort(), + 'B1:B8', + 1, + ], + 'Simple sort by name' => [ + [ + ['Amy', 22], + ['Fred', 65], + ['Fritz', 19], + ['Hector', 66], + ['Sal', 73], + ['Srivan', 39], + ['Tom', 52], + ['Xi', 19], + ], + self::sampleDataForSimpleSort(), + 'A1:A8', + ], + 'More realistic example of when to use SORTBY vs SORT' => [ + [ + ['Amy', 22], + ['Tom', 52], + ['Sal', 73], + ['Fred', 65], + ['Hector', 66], + ['Xi', 19], + ['Srivan', 39], + ['Fritz', 19], + ], + self::sampleDataForSimpleSort(), + 'G1:G8', + ], + 'Boolean sort indexes' => [ + [ + ['Fred', 65], + ['Sal', 73], + ['Srivan', 39], + ['Hector', 66], + ['Tom', 52], + ['Amy', 22], + ['Fritz', 19], + ['Xi', 19], + ], + self::sampleDataForSimpleSort(), + 'H1:H8', + ], + 'Simple sort by name descending' => [ + [ + ['Xi', 19], + ['Tom', 52], + ['Srivan', 39], + ['Sal', 73], + ['Hector', 66], + ['Fritz', 19], + ['Fred', 65], + ['Amy', 22], + ], + self::sampleDataForSimpleSort(), + 'A1:A8', + -1, + ], + 'Row vector (using Dritz instead of Fritz)' => [ + [ + ['Amy', 22], + ['Fritz', 19], + ['Fred', 65], + ['Hector', 66], + ['Sal', 73], + ['Srivan', 39], + ['Tom', 52], + ['Xi', 19], + ], + self::sampleDataForSimpleSort(), + '{"Tom";"Fred";"Amy";"Sal";"Dritz";"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], + ], + self::sampleDataForMultiSort(), + 'A1:A8', + Sort::ORDER_ASCENDING, + 'B1:B8', + ], + '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], + ], + self::sampleDataForMultiSort(), + 'A1:A8', + Sort::ORDER_ASCENDING, + 'C1:C8', + Sort::ORDER_DESCENDING, + ], + ]; + } + + /** @return array */ + private static function sampleDataForSimpleSort(): array + { + return [ + ['Tom', 52], + ['Fred', 65], + ['Amy', 22], + ['Sal', 73], + ['Fritz', 19], + ['Srivan', 39], + ['Xi', 19], + ['Hector', 66], + ]; + } + + /** @return array */ + private static 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], + ]; + } + + public function testSortByColumn(): void + { + $matrix = [ + ['Tom', 'Fred', 'Amy', 'Sal', 'Fritz', 'Srivan', 'Xi', 'Hector'], + [52, 65, 22, 73, 19, 39, 19, 66], + ]; + $sheet = $this->getSheet($matrix); + $formula = "=SORTBY({$this->range}, A1:H1)"; + $expectedResult = [ + ['Amy', 'Fred', 'Fritz', 'Hector', 'Sal', 'Srivan', 'Tom', 'Xi'], + [22, 65, 19, 66, 73, 39, 52, 19], + ]; + $sheet->getCell('Z1')->setValue($formula); + $result = $sheet->getCell('Z1')->getCalculatedValue(); + self::assertSame($expectedResult, $result); + } +} diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php index 9c7a0033d5..c5dd31ed99 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortByTest.php @@ -15,8 +15,8 @@ public function testSortOnScalar(): void { $value = 'NON-ARRAY'; - $result = Sort::sortBy($value); - self::assertSame($value, $result); + $result = Sort::sortBy($value, [[$value]]); + self::assertSame([[$value]], $result); } #[DataProvider('providerSortWithScalarArgumentErrorReturns')] diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortTest.php index 29e2809da4..51e08d6a96 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/SortTest.php @@ -16,7 +16,7 @@ public function testSortOnScalar(): void $value = 'NON-ARRAY'; $result = Sort::sort($value, 1, -1); - self::assertSame($value, $result); + self::assertSame([[$value]], $result); } #[DataProvider('providerSortWithScalarArgumentErrorReturns')] diff --git a/tests/PhpSpreadsheetTests/Helper/TextGridExtended.php b/tests/PhpSpreadsheetTests/Helper/TextGridExtended.php index 32ab95d8c8..2e70d0566c 100644 --- a/tests/PhpSpreadsheetTests/Helper/TextGridExtended.php +++ b/tests/PhpSpreadsheetTests/Helper/TextGridExtended.php @@ -5,12 +5,13 @@ 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)); } } diff --git a/tests/PhpSpreadsheetTests/Helper/TextGridTest.php b/tests/PhpSpreadsheetTests/Helper/TextGridTest.php index bf12aefea4..0a48f8d7ea 100644 --- a/tests/PhpSpreadsheetTests/Helper/TextGridTest.php +++ b/tests/PhpSpreadsheetTests/Helper/TextGridTest.php @@ -5,6 +5,7 @@ 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 @@ public function testNumbersRight(): void rowDividers: false, rowHeaders: false, columnHeaders: false, - numbersRight: true, + numbersRight: TextGridRightAlign::numeric, ); $expected = [ '+-----+-------+--------------+', @@ -251,7 +252,7 @@ public function testExtendedNumbersRight(): void rowHeaders: false, columnHeaders: false, ); - $textGrid->setNumbersRight(true); + $textGrid->setNumbersRight(TextGridRightAlign::numeric); $expected = [ '+-----+-------+--------------+', '| 0 | 1.00 | $1,234.56 |',