Skip to content

Commit c79a9a8

Browse files
authored
Improved Support for INDIRECT, ROW, and COLUMN Functions (PHPOffice#2004)
* Improved Support for INDIRECT, ROW, and COLUMN Functions This should address issues PHPOffice#1913 and PHPOffice#1993. INDIRECT had heretofore not supported an optional parameter intended to support addresses in R1C1 format which was introduced with Excel 2010. It also had not supported the use of defined names as an argument. This PR is a replacement for PHPOffice#1995, which is currently in draft status and which I will close in a day or two. The ROW and COLUMN functions also should support defined names. I have added that, and test cases, with the latest push. ROWS and COLUMNS already supported it correctly, but there had been no test cases. Because ROW and COLUMN can return arrays, and PhpSpreadsheet does not support dynamic arrays, I left the existing direct-call tests unchanged to demonstrate those capabilities. The unit tests for INDIRECT had used mocking, and were sorely lacking (tested only error conditions). They have been replaced with normal, and hopefully adequate, tests. This includes testing globally defined names, as well as locally defined names, both in and out of scope. The test case in 1913 was too complicated for me to add as a unit test. The main impediments to it are now removed, and its complex situation will, I hope, be corrected with this fix. INDIRECT can also support a reference of the form Sheetname!localName when localName on its own would be out of scope. That functionality is added. It is also added, in theory, for ROW and COLUMN, however such a construction is rejected by the Calculation engine before passing control to ROW or COLUMN. It might be possible to change the engine to allow this, and I may want to look into that later, but it seems much too risky, and not nearly useful enough, to attempt to address that as part of this change. Several unusual test cases (leading equals sign, not-quite-as-expected name definition in file, complex indirection involving concatenation and a dropdown list) were suggested by @MarkBaker and are included in this request.
1 parent aeccdb3 commit c79a9a8

22 files changed

+724
-113
lines changed

phpstan-baseline.neon

-15
Original file line numberDiff line numberDiff line change
@@ -1090,16 +1090,6 @@ parameters:
10901090
count: 1
10911091
path: src/PhpSpreadsheet/Calculation/LookupRef/HLookup.php
10921092

1093-
-
1094-
message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Calculation\\\\LookupRef\\\\Indirect\\:\\:extractRequiredCells\\(\\) has no return typehint specified\\.$#"
1095-
count: 1
1096-
path: src/PhpSpreadsheet/Calculation/LookupRef/Indirect.php
1097-
1098-
-
1099-
message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Calculation\\\\LookupRef\\\\Indirect\\:\\:extractWorksheet\\(\\) has parameter \\$cellAddress with no typehint specified\\.$#"
1100-
count: 1
1101-
path: src/PhpSpreadsheet/Calculation/LookupRef/Indirect.php
1102-
11031093
-
11041094
message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Calculation\\\\LookupRef\\\\Lookup\\:\\:verifyResultVector\\(\\) has no return typehint specified\\.$#"
11051095
count: 1
@@ -1185,11 +1175,6 @@ parameters:
11851175
count: 3
11861176
path: src/PhpSpreadsheet/Calculation/LookupRef/RowColumnInformation.php
11871177

1188-
-
1189-
message: "#^Cannot cast array\\|string to string\\.$#"
1190-
count: 2
1191-
path: src/PhpSpreadsheet/Calculation/LookupRef/RowColumnInformation.php
1192-
11931178
-
11941179
message: "#^Parameter \\#1 \\$low of function range expects float\\|int\\|string, string\\|null given\\.$#"
11951180
count: 1

src/PhpSpreadsheet/Calculation/LookupRef.php

+5-5
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ public static function ROWS($cellAddress = null)
148148
* Excel Function:
149149
* =HYPERLINK(linkURL,displayName)
150150
*
151-
* @param mixed $linkURL URL Value to check, is also the value returned when no error
152-
* @param mixed $displayName String Value to return when testValue is an error condition
151+
* @param mixed $linkURL Expect string. Value to check, is also the value returned when no error
152+
* @param mixed $displayName Expect string. Value to return when testValue is an error condition
153153
* @param Cell $pCell The cell to set the hyperlink in
154154
*
155155
* @return mixed The value of $displayName (or $linkURL if $displayName was blank)
@@ -188,16 +188,16 @@ public static function HYPERLINK($linkURL = '', $displayName = null, ?Cell $pCel
188188
*
189189
* NOTE - INDIRECT() does not yet support the optional a1 parameter introduced in Excel 2010
190190
*
191-
* @param null|array|string $cellAddress $cellAddress The cell address of the current cell (containing this formula)
191+
* @param array|string $cellAddress $cellAddress The cell address of the current cell (containing this formula)
192192
* @param Cell $pCell The current cell (containing this formula)
193193
*
194194
* @return array|string An array containing a cell or range of cells, or a string on error
195195
*
196196
* @TODO Support for the optional a1 parameter introduced in Excel 2010
197197
*/
198-
public static function INDIRECT($cellAddress = null, ?Cell $pCell = null)
198+
public static function INDIRECT($cellAddress, Cell $pCell)
199199
{
200-
return Indirect::INDIRECT($cellAddress, $pCell);
200+
return Indirect::INDIRECT($cellAddress, true, $pCell);
201201
}
202202

203203
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
4+
5+
use PhpOffice\PhpSpreadsheet\Cell\AddressHelper;
6+
use PhpOffice\PhpSpreadsheet\Cell\Cell;
7+
use PhpOffice\PhpSpreadsheet\DefinedName;
8+
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
9+
10+
class Helpers
11+
{
12+
public const CELLADDRESS_USE_A1 = true;
13+
14+
public const CELLADDRESS_USE_R1C1 = false;
15+
16+
private static function convertR1C1(string &$cellAddress1, ?string &$cellAddress2, bool $a1): string
17+
{
18+
if ($a1 === self::CELLADDRESS_USE_R1C1) {
19+
$cellAddress1 = AddressHelper::convertToA1($cellAddress1);
20+
if ($cellAddress2) {
21+
$cellAddress2 = AddressHelper::convertToA1($cellAddress2);
22+
}
23+
}
24+
25+
return $cellAddress1 . ($cellAddress2 ? ":$cellAddress2" : '');
26+
}
27+
28+
private static function adjustSheetTitle(string &$sheetTitle, ?string $value): void
29+
{
30+
if ($sheetTitle) {
31+
$sheetTitle .= '!';
32+
if (stripos($value ?? '', $sheetTitle) === 0) {
33+
$sheetTitle = '';
34+
}
35+
}
36+
}
37+
38+
public static function extractCellAddresses(string $cellAddress, bool $a1, Worksheet $sheet, string $sheetName = ''): array
39+
{
40+
$cellAddress1 = $cellAddress;
41+
$cellAddress2 = null;
42+
$namedRange = DefinedName::resolveName($cellAddress1, $sheet, $sheetName);
43+
if ($namedRange !== null) {
44+
$workSheet = $namedRange->getWorkSheet();
45+
$sheetTitle = ($workSheet === null) ? '' : $workSheet->getTitle();
46+
$value = preg_replace('/^=/', '', $namedRange->getValue());
47+
self::adjustSheetTitle($sheetTitle, $value);
48+
$cellAddress1 = $sheetTitle . $value;
49+
$cellAddress = $cellAddress1;
50+
$a1 = self::CELLADDRESS_USE_A1;
51+
}
52+
if (strpos($cellAddress, ':') !== false) {
53+
[$cellAddress1, $cellAddress2] = explode(':', $cellAddress);
54+
}
55+
$cellAddress = self::convertR1C1($cellAddress1, $cellAddress2, $a1);
56+
57+
return [$cellAddress1, $cellAddress2, $cellAddress];
58+
}
59+
60+
public static function extractWorksheet(string $cellAddress, Cell $pCell): array
61+
{
62+
$sheetName = '';
63+
if (strpos($cellAddress, '!') !== false) {
64+
[$sheetName, $cellAddress] = Worksheet::extractSheetTitle($cellAddress, true);
65+
$sheetName = trim($sheetName, "'");
66+
}
67+
68+
$pSheet = ($sheetName !== '')
69+
? $pCell->getWorksheet()->getParent()->getSheetByName($sheetName)
70+
: $pCell->getWorksheet();
71+
72+
return [$cellAddress, $pSheet, $sheetName];
73+
}
74+
}

src/PhpSpreadsheet/Calculation/LookupRef/Indirect.php

+51-32
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,74 @@
22

33
namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
44

5+
use Exception;
56
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
67
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
78
use PhpOffice\PhpSpreadsheet\Cell\Cell;
89
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
910

1011
class Indirect
1112
{
13+
/**
14+
* Determine whether cell address is in A1 (true) or R1C1 (false) format.
15+
*
16+
* @param mixed $a1fmt Expect bool Helpers::CELLADDRESS_USE_A1 or CELLADDRESS_USE_R1C1, but can be provided as numeric which is cast to bool
17+
*/
18+
private static function a1Format($a1fmt): bool
19+
{
20+
$a1fmt = Functions::flattenSingleValue($a1fmt);
21+
if ($a1fmt === null) {
22+
return Helpers::CELLADDRESS_USE_A1;
23+
}
24+
if (is_string($a1fmt)) {
25+
throw new Exception(Functions::VALUE());
26+
}
27+
28+
return (bool) $a1fmt;
29+
}
30+
31+
/**
32+
* Convert cellAddress to string, verify not null string.
33+
*
34+
* @param array|string $cellAddress
35+
*/
36+
private static function validateAddress($cellAddress): string
37+
{
38+
$cellAddress = Functions::flattenSingleValue($cellAddress);
39+
if (!is_string($cellAddress) || !$cellAddress) {
40+
throw new Exception(Functions::REF());
41+
}
42+
43+
return $cellAddress;
44+
}
45+
1246
/**
1347
* INDIRECT.
1448
*
1549
* Returns the reference specified by a text string.
1650
* References are immediately evaluated to display their contents.
1751
*
1852
* Excel Function:
19-
* =INDIRECT(cellAddress)
53+
* =INDIRECT(cellAddress, bool) where the bool argument is optional
2054
*
21-
* NOTE - INDIRECT() does not yet support the optional a1 parameter introduced in Excel 2010
22-
*
23-
* @param null|array|string $cellAddress $cellAddress The cell address of the current cell (containing this formula)
24-
* @param null|Cell $pCell The current cell (containing this formula)
55+
* @param array|string $cellAddress $cellAddress The cell address of the current cell (containing this formula)
56+
* @param mixed $a1fmt Expect bool Helpers::CELLADDRESS_USE_A1 or CELLADDRESS_USE_R1C1, but can be provided as numeric which is cast to bool
57+
* @param Cell $pCell The current cell (containing this formula)
2558
*
2659
* @return array|string An array containing a cell or range of cells, or a string on error
27-
*
28-
* @TODO Support for the optional a1 parameter introduced in Excel 2010
2960
*/
30-
public static function INDIRECT($cellAddress = null, ?Cell $pCell = null)
61+
public static function INDIRECT($cellAddress, $a1fmt, Cell $pCell)
3162
{
32-
$cellAddress = Functions::flattenSingleValue($cellAddress);
33-
if ($cellAddress === null || $cellAddress === '' || !is_object($pCell)) {
34-
return Functions::REF();
63+
try {
64+
$a1 = self::a1Format($a1fmt);
65+
$cellAddress = self::validateAddress($cellAddress);
66+
} catch (Exception $e) {
67+
return $e->getMessage();
3568
}
3669

37-
[$cellAddress, $pSheet] = self::extractWorksheet($cellAddress, $pCell);
70+
[$cellAddress, $pSheet, $sheetName] = Helpers::extractWorksheet($cellAddress, $pCell);
3871

39-
$cellAddress1 = $cellAddress;
40-
$cellAddress2 = null;
41-
if (strpos($cellAddress, ':') !== false) {
42-
[$cellAddress1, $cellAddress2] = explode(':', $cellAddress);
43-
}
72+
[$cellAddress1, $cellAddress2, $cellAddress] = Helpers::extractCellAddresses($cellAddress, $a1, $pCell->getWorkSheet(), $sheetName);
4473

4574
if (
4675
(!preg_match('/^' . Calculation::CALCULATION_REGEXP_CELLREF . '$/i', $cellAddress1, $matches)) ||
@@ -52,24 +81,14 @@ public static function INDIRECT($cellAddress = null, ?Cell $pCell = null)
5281
return self::extractRequiredCells($pSheet, $cellAddress);
5382
}
5483

84+
/**
85+
* Extract range values.
86+
*
87+
* @return mixed Array of values in range if range contains more than one element. Otherwise, a single value is returned.
88+
*/
5589
private static function extractRequiredCells(?Worksheet $pSheet, string $cellAddress)
5690
{
5791
return Calculation::getInstance($pSheet !== null ? $pSheet->getParent() : null)
5892
->extractCellRange($cellAddress, $pSheet, false);
5993
}
60-
61-
private static function extractWorksheet($cellAddress, Cell $pCell): array
62-
{
63-
$sheetName = '';
64-
if (strpos($cellAddress, '!') !== false) {
65-
[$sheetName, $cellAddress] = Worksheet::extractSheetTitle($cellAddress, true);
66-
$sheetName = trim($sheetName, "'");
67-
}
68-
69-
$pSheet = ($sheetName !== '')
70-
? $pCell->getWorksheet()->getParent()->getSheetByName($sheetName)
71-
: $pCell->getWorksheet();
72-
73-
return [$cellAddress, $pSheet];
74-
}
7594
}

src/PhpSpreadsheet/Calculation/LookupRef/RowColumnInformation.php

+47-11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@
1010

1111
class RowColumnInformation
1212
{
13+
/**
14+
* Test if cellAddress is null or whitespace string.
15+
*
16+
* @param null|array|string $cellAddress A reference to a range of cells
17+
*/
18+
private static function cellAddressNullOrWhitespace($cellAddress): bool
19+
{
20+
return $cellAddress === null || (!is_array($cellAddress) && trim($cellAddress) === '');
21+
}
22+
23+
private static function cellColumn(?Cell $pCell): int
24+
{
25+
return ($pCell !== null) ? (int) Coordinate::columnIndexFromString($pCell->getColumn()) : 1;
26+
}
27+
1328
/**
1429
* COLUMN.
1530
*
@@ -27,10 +42,10 @@ class RowColumnInformation
2742
*
2843
* @return int|int[]
2944
*/
30-
public static function COLUMN($cellAddress = null, ?Cell $cell = null)
45+
public static function COLUMN($cellAddress = null, ?Cell $pCell = null)
3146
{
32-
if ($cellAddress === null || (!is_array($cellAddress) && trim($cellAddress) === '')) {
33-
return ($cell !== null) ? (int) Coordinate::columnIndexFromString($cell->getColumn()) : 1;
47+
if (self::cellAddressNullOrWhitespace($cellAddress)) {
48+
return self::cellColumn($pCell);
3449
}
3550

3651
if (is_array($cellAddress)) {
@@ -39,9 +54,16 @@ public static function COLUMN($cellAddress = null, ?Cell $cell = null)
3954

4055
return (int) Coordinate::columnIndexFromString($columnKey);
4156
}
57+
58+
return self::cellColumn($pCell);
4259
}
4360

44-
[, $cellAddress] = Worksheet::extractSheetTitle((string) $cellAddress, true);
61+
$cellAddress = $cellAddress ?? '';
62+
if ($pCell != null) {
63+
[,, $sheetName] = Helpers::extractWorksheet($cellAddress, $pCell);
64+
[,, $cellAddress] = Helpers::extractCellAddresses($cellAddress, true, $pCell->getWorksheet(), $sheetName);
65+
}
66+
[, $cellAddress] = Worksheet::extractSheetTitle($cellAddress, true);
4567
if (strpos($cellAddress, ':') !== false) {
4668
[$startAddress, $endAddress] = explode(':', $cellAddress);
4769
$startAddress = preg_replace('/[^a-z]/i', '', $startAddress);
@@ -73,9 +95,10 @@ public static function COLUMN($cellAddress = null, ?Cell $cell = null)
7395
*/
7496
public static function COLUMNS($cellAddress = null)
7597
{
76-
if ($cellAddress === null || (is_string($cellAddress) && trim($cellAddress) === '')) {
98+
if (self::cellAddressNullOrWhitespace($cellAddress)) {
7799
return 1;
78-
} elseif (!is_array($cellAddress)) {
100+
}
101+
if (!is_array($cellAddress)) {
79102
return Functions::VALUE();
80103
}
81104

@@ -90,6 +113,11 @@ public static function COLUMNS($cellAddress = null)
90113
return $columns;
91114
}
92115

116+
private static function cellRow(?Cell $pCell): int
117+
{
118+
return ($pCell !== null) ? $pCell->getRow() : 1;
119+
}
120+
93121
/**
94122
* ROW.
95123
*
@@ -109,8 +137,8 @@ public static function COLUMNS($cellAddress = null)
109137
*/
110138
public static function ROW($cellAddress = null, ?Cell $pCell = null)
111139
{
112-
if ($cellAddress === null || (!is_array($cellAddress) && trim($cellAddress) === '')) {
113-
return ($pCell !== null) ? $pCell->getRow() : 1;
140+
if (self::cellAddressNullOrWhitespace($cellAddress)) {
141+
return self::cellRow($pCell);
114142
}
115143

116144
if (is_array($cellAddress)) {
@@ -119,9 +147,16 @@ public static function ROW($cellAddress = null, ?Cell $pCell = null)
119147
return (int) preg_replace('/\D/', '', $rowKey);
120148
}
121149
}
150+
151+
return self::cellRow($pCell);
122152
}
123153

124-
[, $cellAddress] = Worksheet::extractSheetTitle((string) $cellAddress, true);
154+
$cellAddress = $cellAddress ?? '';
155+
if ($pCell !== null) {
156+
[,, $sheetName] = Helpers::extractWorksheet($cellAddress, $pCell);
157+
[,, $cellAddress] = Helpers::extractCellAddresses($cellAddress, true, $pCell->getWorksheet(), $sheetName);
158+
}
159+
[, $cellAddress] = Worksheet::extractSheetTitle($cellAddress, true);
125160
if (strpos($cellAddress, ':') !== false) {
126161
[$startAddress, $endAddress] = explode(':', $cellAddress);
127162
$startAddress = preg_replace('/\D/', '', $startAddress);
@@ -154,9 +189,10 @@ function ($value) {
154189
*/
155190
public static function ROWS($cellAddress = null)
156191
{
157-
if ($cellAddress === null || (is_string($cellAddress) && trim($cellAddress) === '')) {
192+
if (self::cellAddressNullOrWhitespace($cellAddress)) {
158193
return 1;
159-
} elseif (!is_array($cellAddress)) {
194+
}
195+
if (!is_array($cellAddress)) {
160196
return Functions::VALUE();
161197
}
162198

src/PhpSpreadsheet/Cell/Cell.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ public function getCalculatedValue($resetLog = true)
268268
} catch (Exception $ex) {
269269
if (($ex->getMessage() === 'Unable to access External Workbook') && ($this->calculatedValue !== null)) {
270270
return $this->calculatedValue; // Fallback for calculations referencing external files.
271-
} elseif (strpos($ex->getMessage(), 'undefined name') !== false) {
271+
} elseif (preg_match('/[Uu]ndefined (name|offset: 2|array key 2)/', $ex->getMessage()) === 1) {
272272
return \PhpOffice\PhpSpreadsheet\Calculation\Functions::NAME();
273273
}
274274

0 commit comments

Comments
 (0)