Strange Behavior of CONCATENATE

Fix #4061. CONCATENATE, which has slightly different behavior than CONCAT, and which MS has deprecated for that reason, behaves in an unexpected way when a cell range is presented to it and the spreadsheet does not allow for array results. This would almost certainly occur only for Legacy spreadsheets, but such is what was presented in the issue. The code is changed so that when an array of cells is presented to CONCATENATE, and RETURN_ARRAY_AS_VALUE is in effect, the array will be treated as if it were wrapped in the SINGLE pseudo-function (which is what Excel does by somewhat mysteriously prefixing the cell range with `@`).

This is a niche case. This one stands out because of its deprecation and replacement function. It is possible that other functions exhibit this behavior. I have made no attempt to identify others. A similar approach can probably be applied if issues are raised for others.
This commit is contained in:
oleibman
2026-02-01 08:51:39 -08:00
parent 12095e5f49
commit 9bd828e25d
5 changed files with 111 additions and 4 deletions
@@ -405,6 +405,7 @@ class FunctionArray extends CalculationBase
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [TextData\Concatenate::class, 'actualCONCATENATE'],
'argumentCount' => '1+',
'passCellReference' => true,
],
'CONFIDENCE' => [
'category' => Category::CATEGORY_STATISTICAL,
@@ -3,6 +3,7 @@
namespace PhpOffice\PhpSpreadsheet\Calculation;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
@@ -360,4 +361,47 @@ class Functions
return $coordinate;
}
/** @param mixed[] $array */
public static function convertArrayToCellRange(array $array): string
{
$retVal = '';
$lastRow = $lastColumn = $firstRow = $firstColumn = 0;
foreach ($array as $rowkey => $row) {
if (!is_array($row) || !is_int($rowkey) || $rowkey < 1) {
$firstRow = 0;
break;
}
if ($firstRow > $rowkey || $firstRow === 0) {
$firstRow = $rowkey;
}
if ($lastRow < $rowkey) {
$lastRow = $rowkey;
}
foreach ($row as $colkey => $cellValue) {
if (!preg_match('/^[A-Z]{1,3}$/', $colkey)) {
$firstRow = 0;
break 2;
}
$column = Coordinate::columnIndexFromString($colkey);
if ($firstColumn > $column || $firstColumn === 0) {
$firstColumn = $column;
}
if ($lastColumn < $column) {
$lastColumn = $column;
}
}
}
if ($firstRow > 0 && $firstColumn > 0 && ($firstRow !== $lastRow || $firstColumn !== $lastColumn)) {
$retVal = Coordinate::stringFromColumnIndex($firstColumn)
. $firstRow
. ':'
. Coordinate::stringFromColumnIndex($lastColumn)
. $lastRow;
}
return $retVal;
}
}
@@ -7,6 +7,8 @@ use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use PhpOffice\PhpSpreadsheet\Calculation\Internal\ExcelArrayPseudoFunctions;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
@@ -17,7 +19,7 @@ class Concatenate
/**
* This implements the CONCAT function, *not* CONCATENATE.
*
* @param mixed[] $args
* @param mixed $args data to be concatenated
*/
public static function CONCATENATE(...$args): string
{
@@ -47,17 +49,33 @@ class Concatenate
/**
* This implements the CONCATENATE function.
*
* @param mixed[] $args data to be concatenated
* @param mixed $args data to be concatenated
*
* @return array<string>|string
*/
public static function actualCONCATENATE(...$args): array|string
{
$useSingle = false;
$cell = null;
$count = count($args);
if ($args[$count - 1] instanceof Cell) {
/** @var Cell */
$cell = array_pop($args);
$type = $cell->getWorksheet()->getParent()?->getCalculationEngine()->getInstanceArrayReturnType() ?? Calculation::getArrayReturnType();
$useSingle = $type === Calculation::RETURN_ARRAY_AS_VALUE;
}
if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_GNUMERIC) {
return self::CONCATENATE(...$args);
}
$result = '';
foreach ($args as $operand2) {
if ($useSingle && $cell instanceof Cell && is_array($operand2)) {
$temp = Functions::convertArrayToCellRange($operand2);
if ($temp !== '') {
$operand2 = ExcelArrayPseudoFunctions::single($temp, $cell);
}
}
/** @var null|array<mixed>|bool|float|int|string $operand2 */
$result = self::concatenate2Args($result, $operand2);
if (ErrorValue::isError($result, true) === true) {
break;
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\TextData;
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
class ConcatenateRangeTest extends AllSetupTeardown
{
@@ -38,4 +39,47 @@ class ConcatenateRangeTest extends AllSetupTeardown
$sheet->getCell('F1')->setValue('=CONCAT(A1:A3, "-", C1:C3)');
self::assertSame('abc-123', $sheet->getCell('F1')->getCalculatedValue());
}
public function testIssue4061Value(): void
{
$sheet = $this->getSheet();
$sheet->getCell('A1')->setValue('a');
$sheet->getCell('A2')->setValue('b');
$sheet->getCell('A3')->setValue('c');
$sheet->getCell('C1')->setValue('1');
$sheet->getCell('C2')->setValue('2');
$sheet->getCell('C3')->setValue('3');
$sheet->getCell('B1')->setValue('=CONCATENATE(A:A, "-", C:C)');
$sheet->getCell('B2')->setValue('=CONCATENATE(A:A, "-", C:C)');
$sheet->getCell('B3')->setValue('=CONCATENATE(A:A, "-", C:C)');
Calculation::getInstance($this->getSpreadsheet())
->setInstanceArrayReturnType(
Calculation::RETURN_ARRAY_AS_VALUE
);
self::assertSame('a-1', $sheet->getCell('B1')->getCalculatedValue());
self::assertSame('b-2', $sheet->getCell('B2')->getCalculatedValue());
self::assertSame('c-3', $sheet->getCell('B3')->getCalculatedValue());
}
public function testConvertCellRangeEdgeCases(): void
{
$array1 = [
1 => ['A' => 'a', 'B' => 'd'],
'B' => ['A' => 'b', 'B' => 'e'],
3 => ['A' => 'c', 'B' => 'f'],
];
self::assertSame('', Functions::convertArrayToCellRange($array1));
$array2 = [
1 => ['A' => 'a', 'B' => 'd'],
2 => ['A' => 'b', 6 => 'e'],
3 => ['A' => 'c', 'B' => 'f'],
];
self::assertSame('', Functions::convertArrayToCellRange($array2));
$array3 = [
1 => ['A' => 'a', 'B' => 'd'],
2 => ['A' => 'b', 'B' => 'e'],
3 => ['A' => 'c', 'B' => 'f'],
];
self::assertSame('A1:B3', Functions::convertArrayToCellRange($array3));
}
}
@@ -5,7 +5,7 @@ declare(strict_types=1);
use PhpOffice\PhpSpreadsheet\Cell\DataType;
return [
/*[
[
'ABCDEFGHIJ',
'ABCDE',
'FGHIJ',
@@ -34,6 +34,6 @@ return [
'A3',
'abc',
'def',
],*/
],
'propagate DIV0' => ['#DIV/0!', '1', 'A2', '3'],
];