Skip to content

Commit c1968e5

Browse files
authored
Correct Re-computation of Relative Addresses in Defined Names (#3673)
* Correct Re-computation of Relative Addresses in Defined Names Fix #3661. Insertion or deletion of rows or columns can cause changes to the ranges for Defined Names. In fact, only the absolute parts of such ranges should be adjusted, while the relative parts should be left alone. Otherwise, as the original issue documents, the adjustment to the relative portion winds up being double-counted when the Defined Name is referenced in a formula. The major part of this change is to ReferenceHelper and CellReferenceHelper to not adjust relative addresses for Defined Names. An additional small change is needed in the Calculation engine to `recursiveCalculationCell` when a Defined Formula is being calculated. In a sense, this is a breaking change, but for an obscure use case which (a) was wrong, and (b) is unlikely to be of importance. Some of the tests in ReferenceHelperTest were wrong and are now corrected, with the results being cross-checked against Excel. When a Defined Name using relative addressing is defined in Excel, the result is treated as relative to the active cell on the sheet in which the name is defined. PhpSpreadsheet treats it as relative to cell A1. I think that is a reasonable treatment, and will not change its behavior to match Excel's - that would definitely be a breaking change of some consequence. An interesting use of relative address in a defined name is demonstrated at https://excelguru.ca/always-refer-to-the-cell-above/. Note that the steps there involve setting the selected cell to A2 before defining the name. When that spreadsheet is stored, the actual definition of the range is `A1048576`. Likewise, adding a defined name for the cell to the left would be stored as `XFD1`. This seems a little fragile, but Ods, which I believe does not have the same row and column limits as Excel, certainly treats these values the same as Excel. This particular construction is formally unit-tested. Note, however, that although using these Defined Names as a formula on their own works just fine, a construction like `=SUM(A1:CellAbove)`, as suggested in the article, seems to put PhpSpreadsheet calculation engine in a loop. In the likely event that I can't solve that before I merge this change, I will open a new issue to that effect when I do merge it. Note that this can be handled without defined names as `=SUM(A$1:INDIRECT(ADDRESS(ROW()-1,COLUMN())))`. PhpSpreadsheet will handle this as a cell formula, but not yet as a Named Formula. The tests show a breakdown evaluating `=ProductTotal` (product of 2 formulas using defined names with relative addresses) on the sheet on which it is defined, but it works from a different sheet. The usual debugging techniques show me why this is happening, but I can't see how to overcome it. As above, if I can't solve it before I merge, I will open a new issue. For those situations where I intend to open a new issue, tests are added but are marked Incomplete. Because of those, I will leave this PR in draft status for 2 weeks before moving forward with it. * Fix productTotal Problem Need to restore current cell after evaluating defined name.
1 parent 4c891c0 commit c1968e5

File tree

6 files changed

+322
-61
lines changed

6 files changed

+322
-61
lines changed

src/PhpSpreadsheet/Calculation/Calculation.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -5745,7 +5745,8 @@ private function evaluateDefinedName(Cell $cell, DefinedName $namedRange, Worksh
57455745

57465746
$this->debugLog->writeDebugLog('Defined Name is a %s with a value of %s', $definedNameType, $definedNameValue);
57475747

5748-
$recursiveCalculationCell = ($definedNameWorksheet !== null && $definedNameWorksheet !== $cellWorksheet)
5748+
$originalCoordinate = $cell->getCoordinate();
5749+
$recursiveCalculationCell = ($definedNameType !== 'Formula' && $definedNameWorksheet !== null && $definedNameWorksheet !== $cellWorksheet)
57495750
? $definedNameWorksheet->getCell('A1')
57505751
: $cell;
57515752
$recursiveCalculationCellAddress = $recursiveCalculationCell->getCoordinate();
@@ -5763,6 +5764,7 @@ private function evaluateDefinedName(Cell $cell, DefinedName $namedRange, Worksh
57635764
$recursiveCalculator->getDebugLog()->setWriteDebugLog($this->getDebugLog()->getWriteDebugLog());
57645765
$recursiveCalculator->getDebugLog()->setEchoDebugLog($this->getDebugLog()->getEchoDebugLog());
57655766
$result = $recursiveCalculator->_calculateFormulaValue($definedNameValue, $recursiveCalculationCellAddress, $recursiveCalculationCell, true);
5767+
$cellWorksheet->getCell($originalCoordinate);
57665768

57675769
if ($this->getDebugLog()->getWriteDebugLog()) {
57685770
$this->debugLog->mergeDebugLog(array_slice($recursiveCalculator->getDebugLog()->getLog(), 3));

src/PhpSpreadsheet/CellReferenceHelper.php

+5-2
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public function refreshRequired(string $beforeCellAddress, int $numberOfColumns,
5656
$this->numberOfRows !== $numberOfRows;
5757
}
5858

59-
public function updateCellReference(string $cellReference = 'A1', bool $includeAbsoluteReferences = false): string
59+
public function updateCellReference(string $cellReference = 'A1', bool $includeAbsoluteReferences = false, bool $onlyAbsoluteReferences = false): string
6060
{
6161
if (Coordinate::coordinateIsRange($cellReference)) {
6262
throw new Exception('Only single cell references may be passed to this method.');
@@ -70,7 +70,10 @@ public function updateCellReference(string $cellReference = 'A1', bool $includeA
7070
$absoluteColumn = $newColumn[0] === '$' ? '$' : '';
7171
$absoluteRow = $newRow[0] === '$' ? '$' : '';
7272
// Verify which parts should be updated
73-
if ($includeAbsoluteReferences === false) {
73+
if ($onlyAbsoluteReferences === true) {
74+
$updateColumn = (($absoluteColumn === '$') && $newColumnIndex >= $this->beforeColumn);
75+
$updateRow = (($absoluteRow === '$') && $newRowIndex >= $this->beforeRow);
76+
} elseif ($includeAbsoluteReferences === false) {
7477
$updateColumn = (($absoluteColumn !== '$') && $newColumnIndex >= $this->beforeColumn);
7578
$updateRow = (($absoluteRow !== '$') && $newRowIndex >= $this->beforeRow);
7679
} else {

src/PhpSpreadsheet/ReferenceHelper.php

+22-18
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace PhpOffice\PhpSpreadsheet;
44

55
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
6+
use PhpOffice\PhpSpreadsheet\Cell\AddressRange;
67
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
78
use PhpOffice\PhpSpreadsheet\Cell\DataType;
89
use PhpOffice\PhpSpreadsheet\Style\Conditional;
@@ -572,7 +573,8 @@ public function updateFormulaReferences(
572573
$numberOfColumns = 0,
573574
$numberOfRows = 0,
574575
$worksheetName = '',
575-
bool $includeAbsoluteReferences = false
576+
bool $includeAbsoluteReferences = false,
577+
bool $onlyAbsoluteReferences = false
576578
) {
577579
if (
578580
$this->cellReferenceHelper === null ||
@@ -596,8 +598,8 @@ public function updateFormulaReferences(
596598
foreach ($matches as $match) {
597599
$fromString = ($match[2] > '') ? $match[2] . '!' : '';
598600
$fromString .= $match[3] . ':' . $match[4];
599-
$modified3 = substr($this->updateCellReference('$A' . $match[3], $includeAbsoluteReferences), 2);
600-
$modified4 = substr($this->updateCellReference('$A' . $match[4], $includeAbsoluteReferences), 2);
601+
$modified3 = substr($this->updateCellReference('$A' . $match[3], $includeAbsoluteReferences, $onlyAbsoluteReferences), 2);
602+
$modified4 = substr($this->updateCellReference('$A' . $match[4], $includeAbsoluteReferences, $onlyAbsoluteReferences), 2);
601603

602604
if ($match[3] . ':' . $match[4] !== $modified3 . ':' . $modified4) {
603605
if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) {
@@ -621,8 +623,8 @@ public function updateFormulaReferences(
621623
foreach ($matches as $match) {
622624
$fromString = ($match[2] > '') ? $match[2] . '!' : '';
623625
$fromString .= $match[3] . ':' . $match[4];
624-
$modified3 = substr($this->updateCellReference($match[3] . '$1', $includeAbsoluteReferences), 0, -2);
625-
$modified4 = substr($this->updateCellReference($match[4] . '$1', $includeAbsoluteReferences), 0, -2);
626+
$modified3 = substr($this->updateCellReference($match[3] . '$1', $includeAbsoluteReferences, $onlyAbsoluteReferences), 0, -2);
627+
$modified4 = substr($this->updateCellReference($match[4] . '$1', $includeAbsoluteReferences, $onlyAbsoluteReferences), 0, -2);
626628

627629
if ($match[3] . ':' . $match[4] !== $modified3 . ':' . $modified4) {
628630
if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) {
@@ -646,8 +648,8 @@ public function updateFormulaReferences(
646648
foreach ($matches as $match) {
647649
$fromString = ($match[2] > '') ? $match[2] . '!' : '';
648650
$fromString .= $match[3] . ':' . $match[4];
649-
$modified3 = $this->updateCellReference($match[3], $includeAbsoluteReferences);
650-
$modified4 = $this->updateCellReference($match[4], $includeAbsoluteReferences);
651+
$modified3 = $this->updateCellReference($match[3], $includeAbsoluteReferences, $onlyAbsoluteReferences);
652+
$modified4 = $this->updateCellReference($match[4], $includeAbsoluteReferences, $onlyAbsoluteReferences);
651653

652654
if ($match[3] . $match[4] !== $modified3 . $modified4) {
653655
if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) {
@@ -674,7 +676,7 @@ public function updateFormulaReferences(
674676
$fromString = ($match[2] > '') ? $match[2] . '!' : '';
675677
$fromString .= $match[3];
676678

677-
$modified3 = $this->updateCellReference($match[3], $includeAbsoluteReferences);
679+
$modified3 = $this->updateCellReference($match[3], $includeAbsoluteReferences, $onlyAbsoluteReferences);
678680
if ($match[3] !== $modified3) {
679681
if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) {
680682
$toString = ($match[2] > '') ? $match[2] . '!' : '';
@@ -757,11 +759,13 @@ private function updateCellReferencesAllWorksheets(string $formula, int $numberO
757759
$row = $rows[$splitCount][0];
758760

759761
if (!empty($column) && $column[0] !== '$') {
760-
$column = Coordinate::stringFromColumnIndex(Coordinate::columnIndexFromString($column) + $numberOfColumns);
762+
$column = ((Coordinate::columnIndexFromString($column) + $numberOfColumns) % AddressRange::MAX_COLUMN_INT) ?: AddressRange::MAX_COLUMN_INT;
763+
$column = Coordinate::stringFromColumnIndex($column);
764+
$rowOffset -= ($columnLength - strlen($column));
761765
$formula = substr($formula, 0, $columnOffset) . $column . substr($formula, $columnOffset + $columnLength);
762766
}
763767
if (!empty($row) && $row[0] !== '$') {
764-
$row = (int) $row + $numberOfRows;
768+
$row = (((int) $row + $numberOfRows) % AddressRange::MAX_ROW) ?: AddressRange::MAX_ROW;
765769
$formula = substr($formula, 0, $rowOffset) . $row . substr($formula, $rowOffset + $rowLength);
766770
}
767771
}
@@ -854,7 +858,7 @@ private function updateRowRangesAllWorksheets(string $formula, int $numberOfRows
854858
*
855859
* @return string Updated cell range
856860
*/
857-
private function updateCellReference($cellReference = 'A1', bool $includeAbsoluteReferences = false)
861+
private function updateCellReference($cellReference = 'A1', bool $includeAbsoluteReferences = false, bool $onlyAbsoluteReferences = false)
858862
{
859863
// Is it in another worksheet? Will not have to update anything.
860864
if (strpos($cellReference, '!') !== false) {
@@ -863,11 +867,11 @@ private function updateCellReference($cellReference = 'A1', bool $includeAbsolut
863867
// Is it a range or a single cell?
864868
if (!Coordinate::coordinateIsRange($cellReference)) {
865869
// Single cell
866-
return $this->cellReferenceHelper->updateCellReference($cellReference, $includeAbsoluteReferences);
870+
return $this->cellReferenceHelper->updateCellReference($cellReference, $includeAbsoluteReferences, $onlyAbsoluteReferences);
867871
}
868872

869873
// Range
870-
return $this->updateCellRange($cellReference, $includeAbsoluteReferences);
874+
return $this->updateCellRange($cellReference, $includeAbsoluteReferences, $onlyAbsoluteReferences);
871875
}
872876

873877
/**
@@ -922,7 +926,7 @@ private function updateNamedRange(DefinedName $definedName, Worksheet $worksheet
922926
* them with a #REF!
923927
*/
924928
if ($asFormula === true) {
925-
$formula = $this->updateFormulaReferences($cellAddress, $beforeCellAddress, $numberOfColumns, $numberOfRows, $worksheet->getTitle(), true);
929+
$formula = $this->updateFormulaReferences($cellAddress, $beforeCellAddress, $numberOfColumns, $numberOfRows, $worksheet->getTitle(), true, true);
926930
$definedName->setValue($formula);
927931
} else {
928932
$definedName->setValue($this->updateCellReference(ltrim($cellAddress, '='), true));
@@ -953,7 +957,7 @@ private function updateNamedFormula(DefinedName $definedName, Worksheet $workshe
953957
*
954958
* @return string Updated cell range
955959
*/
956-
private function updateCellRange(string $cellRange = 'A1:A1', bool $includeAbsoluteReferences = false): string
960+
private function updateCellRange(string $cellRange = 'A1:A1', bool $includeAbsoluteReferences = false, bool $onlyAbsoluteReferences = false): string
957961
{
958962
if (!Coordinate::coordinateIsRange($cellRange)) {
959963
throw new Exception('Only cell ranges may be passed to this method.');
@@ -967,14 +971,14 @@ private function updateCellRange(string $cellRange = 'A1:A1', bool $includeAbsol
967971
for ($j = 0; $j < $jc; ++$j) {
968972
if (ctype_alpha($range[$i][$j])) {
969973
$range[$i][$j] = Coordinate::coordinateFromString(
970-
$this->cellReferenceHelper->updateCellReference($range[$i][$j] . '1', $includeAbsoluteReferences)
974+
$this->cellReferenceHelper->updateCellReference($range[$i][$j] . '1', $includeAbsoluteReferences, $onlyAbsoluteReferences)
971975
)[0];
972976
} elseif (ctype_digit($range[$i][$j])) {
973977
$range[$i][$j] = Coordinate::coordinateFromString(
974-
$this->cellReferenceHelper->updateCellReference('A' . $range[$i][$j], $includeAbsoluteReferences)
978+
$this->cellReferenceHelper->updateCellReference('A' . $range[$i][$j], $includeAbsoluteReferences, $onlyAbsoluteReferences)
975979
)[1];
976980
} else {
977-
$range[$i][$j] = $this->cellReferenceHelper->updateCellReference($range[$i][$j], $includeAbsoluteReferences);
981+
$range[$i][$j] = $this->cellReferenceHelper->updateCellReference($range[$i][$j], $includeAbsoluteReferences, $onlyAbsoluteReferences);
978982
}
979983
}
980984
}

tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/IndirectTest.php

+30
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\LookupRef;
44

5+
use PhpOffice\PhpSpreadsheet\NamedFormula;
56
use PhpOffice\PhpSpreadsheet\NamedRange;
67
use PhpOffice\PhpSpreadsheet\Reader\Xlsx as ReaderXlsx;
78

@@ -176,4 +177,33 @@ public static function providerRelative(): array
176177
'uninitialized cell' => [null, 'RC[+2]'], // Excel result is 0
177178
];
178179
}
180+
181+
/** @var bool */
182+
private static $definedFormulaWorking = false;
183+
184+
public function testAboveCell(): void
185+
{
186+
$spreadsheet = $this->getSpreadsheet();
187+
$spreadsheet->addNamedFormula(
188+
new NamedFormula('SumAbove', $spreadsheet->getActiveSheet(), '=SUM(INDIRECT(ADDRESS(1,COLUMN())):INDIRECT(ADDRESS(ROW()-1,COLUMN())))')
189+
);
190+
$sheet = $this->getSheet();
191+
$sheet->getCell('A1')->setValue(100);
192+
$sheet->getCell('A2')->setValue(200);
193+
$sheet->getCell('A3')->setValue(300);
194+
$sheet->getCell('A4')->setValue(400);
195+
$sheet->getCell('A5')->setValue(500);
196+
$sheet->getCell('A6')->setValue('=SUM(A$1:INDIRECT(ADDRESS(ROW()-1,COLUMN())))');
197+
self::AssertSame(1500, $sheet->getCell('A6')->getCalculatedValue());
198+
if (self::$definedFormulaWorking) {
199+
$sheet->getCell('B1')->setValue(10);
200+
$sheet->getCell('B2')->setValue(20);
201+
$sheet->getCell('B3')->setValue(30);
202+
$sheet->getCell('B4')->setValue(40);
203+
$sheet->getCell('B5')->setValue('=SumAbove');
204+
self::AssertSame(100, $sheet->getCell('B5')->getCalculatedValue());
205+
} else {
206+
self::markTestIncomplete('PhpSpreadsheet does not handle this correctly');
207+
}
208+
}
179209
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheetTests;
4+
5+
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
6+
use PhpOffice\PhpSpreadsheet\NamedRange;
7+
use PhpOffice\PhpSpreadsheet\Spreadsheet;
8+
use PHPUnit\Framework\TestCase;
9+
10+
class ReferenceHelper3Test extends TestCase
11+
{
12+
public function testIssue3661(): void
13+
{
14+
$spreadsheet = new Spreadsheet();
15+
$sheet = $spreadsheet->getActiveSheet();
16+
$sheet->setTitle('Data');
17+
18+
$spreadsheet->addNamedRange(new NamedRange('FIRST', $sheet, '=$A1'));
19+
$spreadsheet->addNamedRange(new NamedRange('SECOND', $sheet, '=$B1'));
20+
$spreadsheet->addNamedRange(new NamedRange('THIRD', $sheet, '=$C1'));
21+
22+
$sheet->fromArray([
23+
[1, 2, 3, '=FIRST', '=SECOND', '=THIRD', '=10*$A1'],
24+
[4, 5, 6, '=FIRST', '=SECOND', '=THIRD'],
25+
[7, 8, 9, '=FIRST', '=SECOND', '=THIRD'],
26+
]);
27+
28+
$sheet->insertNewRowBefore(1, 4);
29+
$sheet->insertNewColumnBefore('A', 1);
30+
self::assertSame(1, $sheet->getCell('E5')->getCalculatedValue());
31+
self::assertSame(5, $sheet->getCell('F6')->getCalculatedValue());
32+
self::assertSame(9, $sheet->getCell('G7')->getCalculatedValue());
33+
self::assertSame('=10*$B5', $sheet->getCell('H5')->getValue());
34+
self::assertSame(10, $sheet->getCell('H5')->getCalculatedValue());
35+
$firstColumn = $spreadsheet->getNamedRange('FIRST');
36+
/** @var NamedRange $firstColumn */
37+
self::assertSame('=$B1', $firstColumn->getRange());
38+
$spreadsheet->disconnectWorksheets();
39+
}
40+
41+
public function testCompletelyRelative(): void
42+
{
43+
$spreadsheet = new Spreadsheet();
44+
$sheet = $spreadsheet->getActiveSheet();
45+
$sheet->setTitle('Data');
46+
47+
$spreadsheet->addNamedRange(new NamedRange('CellAbove', $sheet, '=A1048576'));
48+
$spreadsheet->addNamedRange(new NamedRange('CellBelow', $sheet, '=A2'));
49+
$spreadsheet->addNamedRange(new NamedRange('CellToLeft', $sheet, '=XFD1'));
50+
$spreadsheet->addNamedRange(new NamedRange('CellToRight', $sheet, '=B1'));
51+
52+
$sheet->fromArray([
53+
[null, 'Above', null, null, 'Above', null, null, 'Above', null, null, 'Above', null],
54+
['Left', '=CellAbove', 'Right', 'Left', '=CellBelow', 'Right', 'Left', '=CellToLeft', 'Right', 'Left', '=CellToRight', 'Right'],
55+
[null, 'Below', null, null, 'Below', null, null, 'Below', null, null, 'Below', null],
56+
], null, 'A1', true);
57+
self::assertSame('Above', $sheet->getCell('B2')->getCalculatedValue());
58+
self::assertSame('Below', $sheet->getCell('E2')->getCalculatedValue());
59+
self::assertSame('Left', $sheet->getCell('H2')->getCalculatedValue());
60+
self::assertSame('Right', $sheet->getCell('K2')->getCalculatedValue());
61+
62+
Calculation::getInstance($spreadsheet)->flushInstance();
63+
self::assertNull($sheet->getCell('L7')->getCalculatedValue(), 'value in L7 after flush is null');
64+
// Reset it once more
65+
Calculation::getInstance($spreadsheet)->flushInstance();
66+
// shift 5 rows down and 1 column to the right
67+
$sheet->insertNewRowBefore(1, 5);
68+
$sheet->insertNewColumnBefore('A', 1);
69+
70+
self::assertSame('Above', $sheet->getCell('C7')->getCalculatedValue()); // Above
71+
self::assertSame('Below', $sheet->getCell('F7')->getCalculatedValue());
72+
self::assertSame('Left', $sheet->getCell('I7')->getCalculatedValue());
73+
self::assertSame('Right', $sheet->getCell('L7')->getCalculatedValue());
74+
75+
$sheet2 = $spreadsheet->createSheet();
76+
$sheet2->setCellValue('L6', 'NotThisCell');
77+
$sheet2->setCellValue('L7', '=CellAbove');
78+
self::assertSame('Above', $sheet2->getCell('L7')->getCalculatedValue(), 'relative value uses cell on worksheet where name is defined');
79+
$spreadsheet->disconnectWorksheets();
80+
}
81+
82+
/** @var bool */
83+
private static $sumFormulaWorking = false;
84+
85+
public function testSumAboveCell(): void
86+
{
87+
$spreadsheet = new Spreadsheet();
88+
$sheet = $spreadsheet->getActiveSheet();
89+
$spreadsheet->addNamedRange(new NamedRange('AboveCell', $sheet, 'A1048576'));
90+
$sheet->setCellValue('C2', 123);
91+
$sheet->setCellValue('C3', '=AboveCell');
92+
$sheet->fromArray([
93+
['Column 1', 'Column 2'],
94+
[2, 1],
95+
[4, 3],
96+
[6, 5],
97+
[8, 7],
98+
[10, 9],
99+
[12, 11],
100+
[14, 13],
101+
[16, 15],
102+
['=SUM(A2:AboveCell)', '=SUM(B2:AboveCell)'],
103+
], null, 'A1', true);
104+
self::assertSame(123, $sheet->getCell('C3')->getCalculatedValue());
105+
if (self::$sumFormulaWorking) {
106+
self::assertSame(72, $sheet->getCell('A10')->getCalculatedValue());
107+
} else {
108+
$spreadsheet->disconnectWorksheets();
109+
self::markTestIncomplete('PhpSpreadsheet does not handle this correctly');
110+
}
111+
$spreadsheet->disconnectWorksheets();
112+
}
113+
}

0 commit comments

Comments
 (0)