Skip to content

Commit cff73fb

Browse files
authored
Merge pull request #3261 from PHPOffice/CalcEngine_Structured-References
Handling Structured References in the Calculation Engine
2 parents e7a0e80 + 5649541 commit cff73fb

File tree

6 files changed

+468
-7
lines changed

6 files changed

+468
-7
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
99

1010
### Added
1111

12-
- Nothing
12+
- Support for Structured References in the Calculation Engine [PR #3261](https://github.com/PHPOffice/PhpSpreadsheet/pull/3261)
1313

1414
### Changed
1515

src/PhpSpreadsheet/Calculation/Calculation.php

+21-6
Original file line numberDiff line numberDiff line change
@@ -4732,13 +4732,28 @@ private function processTokenStack($tokens, $cellID = null, ?Cell $cell = null)
47324732
}
47334733

47344734
if ($token instanceof Operands\StructuredReference) {
4735-
throw new Exception('Structured References are not currently supported');
4736-
// The next step is converting any structured reference to a cell value of range
4737-
// to a new $token value, which can then be processed in the following code.
4738-
}
4735+
if ($cell === null) {
4736+
return $this->raiseFormulaError('Structured References must exist in a Cell context');
4737+
}
47394738

4740-
// if the token is a binary operator, pop the top two values off the stack, do the operation, and push the result back on the stack
4741-
if (!is_numeric($token) && !is_object($token) && isset(self::BINARY_OPERATORS[$token])) {
4739+
try {
4740+
$cellRange = $token->parse($cell);
4741+
if (strpos($cellRange, ':') !== false) {
4742+
$this->debugLog->writeDebugLog('Evaluating Structured Reference %s as Cell Range %s', $token->value(), $cellRange);
4743+
$rangeValue = self::getInstance($cell->getWorksheet()->getParent())->_calculateFormulaValue("={$cellRange}", $token->value(), $cell);
4744+
$stack->push('Value', $rangeValue);
4745+
$this->debugLog->writeDebugLog('Evaluated Structured Reference %s as a value %s', $token->value(), $this->showValue($rangeValue));
4746+
} else {
4747+
$this->debugLog->writeDebugLog('Evaluating Structured Reference %s as Cell %s', $token->value(), $cellRange);
4748+
$cellValue = $cell->getWorksheet()->getCell($cellRange)->getCalculatedValue(false);
4749+
$stack->push('Cell Reference', $cellValue, $cellRange);
4750+
$this->debugLog->writeDebugLog('Evaluated Structured Reference %s as a value %s', $token->value(), $this->showValue($cellValue));
4751+
}
4752+
} catch (Exception $e) {
4753+
return $this->raiseFormulaError($e->getMessage());
4754+
}
4755+
} elseif (!is_numeric($token) && !is_object($token) && isset(self::BINARY_OPERATORS[$token])) {
4756+
// if the token is a binary operator, pop the top two values off the stack, do the operation, and push the result back on the stack
47424757
// We must have two operands, error if we don't
47434758
if (($operand2Data = $stack->pop()) === null) {
47444759
return $this->raiseFormulaError('Internal error - Operand value missing from stack');

src/PhpSpreadsheet/Calculation/Engine/Operands/StructuredReference.php

+275
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
namespace PhpOffice\PhpSpreadsheet\Calculation\Engine\Operands;
44

5+
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
56
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
7+
use PhpOffice\PhpSpreadsheet\Cell\Cell;
8+
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
9+
use PhpOffice\PhpSpreadsheet\Worksheet\Table;
610

711
final class StructuredReference implements Operand
812
{
@@ -11,8 +15,37 @@ final class StructuredReference implements Operand
1115
private const OPEN_BRACE = '[';
1216
private const CLOSE_BRACE = ']';
1317

18+
private const ITEM_SPECIFIER_ALL = '#All';
19+
private const ITEM_SPECIFIER_HEADERS = '#Headers';
20+
private const ITEM_SPECIFIER_DATA = '#Data';
21+
private const ITEM_SPECIFIER_TOTALS = '#Totals';
22+
private const ITEM_SPECIFIER_THIS_ROW = '#This Row';
23+
24+
private const ITEM_SPECIFIER_ROWS_SET = [
25+
self::ITEM_SPECIFIER_ALL,
26+
self::ITEM_SPECIFIER_HEADERS,
27+
self::ITEM_SPECIFIER_DATA,
28+
self::ITEM_SPECIFIER_TOTALS,
29+
];
30+
31+
private const TABLE_REFERENCE = '/([\p{L}_\\\\][\p{L}\p{N}\._]+)?(\[(?:[^\]\[]+|(?R))*+\])/miu';
32+
1433
private string $value;
1534

35+
private string $tableName;
36+
37+
private string $reference;
38+
39+
private ?int $headersRow;
40+
41+
private int $firstDataRow;
42+
43+
private int $lastDataRow;
44+
45+
private ?int $totalsRow;
46+
47+
private array $columns;
48+
1649
public function __construct(string $structuredReference)
1750
{
1851
$this->value = $structuredReference;
@@ -42,8 +75,250 @@ public static function fromParser(string $formula, int $index, array $matches):
4275
return new self($val);
4376
}
4477

78+
/**
79+
* @throws Exception
80+
* @throws \PhpOffice\PhpSpreadsheet\Exception
81+
*/
82+
public function parse(Cell $cell): string
83+
{
84+
$this->getTableStructure($cell);
85+
$cellRange = ($this->isRowReference()) ? $this->getRowReference($cell) : $this->getColumnReference();
86+
87+
return $cellRange;
88+
}
89+
90+
private function isRowReference(): bool
91+
{
92+
return strpos($this->value, '[@') !== false
93+
|| strpos($this->value, '[' . self::ITEM_SPECIFIER_THIS_ROW . ']') !== false;
94+
}
95+
96+
/**
97+
* @throws Exception
98+
* @throws \PhpOffice\PhpSpreadsheet\Exception
99+
*/
100+
private function getTableStructure(Cell $cell): void
101+
{
102+
preg_match(self::TABLE_REFERENCE, $this->value, $matches);
103+
104+
$this->tableName = $matches[1];
105+
$table = ($this->tableName === '')
106+
? $this->getTableForCell($cell)
107+
: $this->getTableByName($cell);
108+
$this->reference = $matches[2];
109+
$tableRange = Coordinate::getRangeBoundaries($table->getRange());
110+
111+
$this->headersRow = ($table->getShowHeaderRow()) ? (int) $tableRange[0][1] : null;
112+
$this->firstDataRow = ($table->getShowHeaderRow()) ? (int) $tableRange[0][1] + 1 : $tableRange[0][1];
113+
$this->totalsRow = ($table->getShowTotalsRow()) ? (int) $tableRange[1][1] : null;
114+
$this->lastDataRow = ($table->getShowTotalsRow()) ? (int) $tableRange[1][1] - 1 : $tableRange[1][1];
115+
116+
$this->columns = $this->getColumns($cell, $tableRange);
117+
}
118+
119+
/**
120+
* @throws Exception
121+
* @throws \PhpOffice\PhpSpreadsheet\Exception
122+
*/
123+
private function getTableForCell(Cell $cell): Table
124+
{
125+
$tables = $cell->getWorksheet()->getTableCollection();
126+
foreach ($tables as $table) {
127+
/** @var Table $table */
128+
$range = $table->getRange();
129+
if ($cell->isInRange($range) === true) {
130+
$this->tableName = $table->getName();
131+
132+
return $table;
133+
}
134+
}
135+
136+
throw new Exception('Table for Structured Reference cannot be identified');
137+
}
138+
139+
/**
140+
* @throws Exception
141+
* @throws \PhpOffice\PhpSpreadsheet\Exception
142+
*/
143+
private function getTableByName(Cell $cell): Table
144+
{
145+
$table = $cell->getWorksheet()->getTableByName($this->tableName);
146+
147+
if ($table === null) {
148+
throw new Exception("Table {$this->tableName} for Structured Reference cannot be located");
149+
}
150+
151+
return $table;
152+
}
153+
154+
private function getColumns(Cell $cell, array $tableRange): array
155+
{
156+
$worksheet = $cell->getWorksheet();
157+
$cellReference = $cell->getCoordinate();
158+
159+
$columns = [];
160+
$lastColumn = ++$tableRange[1][0];
161+
for ($column = $tableRange[0][0]; $column !== $lastColumn; ++$column) {
162+
$columns[$column] = $worksheet
163+
->getCell($column . $this->headersRow)
164+
->getCalculatedValue();
165+
}
166+
167+
$cell = $worksheet->getCell($cellReference);
168+
169+
return $columns;
170+
}
171+
172+
private function getRowReference(Cell $cell): string
173+
{
174+
$reference = str_replace("\u{a0}", ' ', $this->reference);
175+
/** @var string $reference */
176+
$reference = str_replace('[' . self::ITEM_SPECIFIER_THIS_ROW . '],', '', $reference);
177+
178+
foreach ($this->columns as $columnId => $columnName) {
179+
$columnName = str_replace("\u{a0}", ' ', $columnName);
180+
$cellReference = $columnId . $cell->getRow();
181+
$pattern1 = '/\[' . preg_quote($columnName) . '\]/miu';
182+
$pattern2 = '/@' . preg_quote($columnName) . '/miu';
183+
/** @var string $reference */
184+
if (preg_match($pattern1, $reference) === 1) {
185+
$reference = preg_replace($pattern1, $cellReference, $reference);
186+
} elseif (preg_match($pattern2, $reference) === 1) {
187+
$reference = preg_replace($pattern2, $cellReference, $reference);
188+
}
189+
}
190+
191+
/** @var string $reference */
192+
return $this->validateParsedReference(trim($reference, '[]@, '));
193+
}
194+
195+
/**
196+
* @throws Exception
197+
* @throws \PhpOffice\PhpSpreadsheet\Exception
198+
*/
199+
private function getColumnReference(): string
200+
{
201+
$reference = str_replace("\u{a0}", ' ', $this->reference);
202+
$startRow = ($this->totalsRow === null) ? $this->lastDataRow : $this->totalsRow;
203+
$endRow = ($this->headersRow === null) ? $this->firstDataRow : $this->headersRow;
204+
205+
[$startRow, $endRow] = $this->getRowsForColumnReference($reference, $startRow, $endRow);
206+
$reference = $this->getColumnsForColumnReference($reference, $startRow, $endRow);
207+
208+
$reference = trim($reference, '[]@, ');
209+
if (substr_count($reference, ':') > 1) {
210+
$cells = explode(':', $reference);
211+
$firstCell = array_shift($cells);
212+
$lastCell = array_pop($cells);
213+
$reference = "{$firstCell}:{$lastCell}";
214+
}
215+
216+
return $this->validateParsedReference($reference);
217+
}
218+
219+
/**
220+
* @throws Exception
221+
* @throws \PhpOffice\PhpSpreadsheet\Exception
222+
*/
223+
private function validateParsedReference(string $reference): string
224+
{
225+
if (preg_match('/^' . Calculation::CALCULATION_REGEXP_CELLREF . ':' . Calculation::CALCULATION_REGEXP_CELLREF . '$/miu', $reference) !== 1) {
226+
if (preg_match('/^' . Calculation::CALCULATION_REGEXP_CELLREF . '$/miu', $reference) !== 1) {
227+
throw new Exception("Invalid Structured Reference {$this->reference} {$reference}");
228+
}
229+
}
230+
231+
return $reference;
232+
}
233+
234+
private function fullData(int $startRow, int $endRow): string
235+
{
236+
$columns = array_keys($this->columns);
237+
$firstColumn = array_shift($columns);
238+
$lastColumn = (empty($columns)) ? $firstColumn : array_pop($columns);
239+
240+
return "{$firstColumn}{$startRow}:{$lastColumn}{$endRow}";
241+
}
242+
243+
private function getMinimumRow(string $reference): int
244+
{
245+
switch ($reference) {
246+
case self::ITEM_SPECIFIER_ALL:
247+
case self::ITEM_SPECIFIER_HEADERS:
248+
return $this->headersRow ?? $this->firstDataRow;
249+
case self::ITEM_SPECIFIER_DATA:
250+
return $this->firstDataRow;
251+
case self::ITEM_SPECIFIER_TOTALS:
252+
return $this->totalsRow ?? $this->lastDataRow;
253+
}
254+
255+
return $this->headersRow ?? $this->firstDataRow;
256+
}
257+
258+
private function getMaximumRow(string $reference): int
259+
{
260+
switch ($reference) {
261+
case self::ITEM_SPECIFIER_HEADERS:
262+
return $this->headersRow ?? $this->firstDataRow;
263+
case self::ITEM_SPECIFIER_DATA:
264+
return $this->lastDataRow;
265+
case self::ITEM_SPECIFIER_ALL:
266+
case self::ITEM_SPECIFIER_TOTALS:
267+
return $this->totalsRow ?? $this->lastDataRow;
268+
}
269+
270+
return $this->totalsRow ?? $this->lastDataRow;
271+
}
272+
45273
public function value(): string
46274
{
47275
return $this->value;
48276
}
277+
278+
/**
279+
* @return array<int, int>
280+
*/
281+
private function getRowsForColumnReference(string &$reference, int $startRow, int $endRow): array
282+
{
283+
$rowsSelected = false;
284+
foreach (self::ITEM_SPECIFIER_ROWS_SET as $rowReference) {
285+
$pattern = '/\[' . $rowReference . '\]/mui';
286+
/** @var string $reference */
287+
if (preg_match($pattern, $reference) === 1) {
288+
$rowsSelected = true;
289+
$startRow = min($startRow, $this->getMinimumRow($rowReference));
290+
$endRow = max($endRow, $this->getMaximumRow($rowReference));
291+
$reference = preg_replace($pattern, '', $reference);
292+
}
293+
}
294+
if ($rowsSelected === false) {
295+
// If there isn't any Special Item Identifier specified, then the selection defaults to data rows only.
296+
$startRow = $this->firstDataRow;
297+
$endRow = $this->lastDataRow;
298+
}
299+
300+
return [$startRow, $endRow];
301+
}
302+
303+
private function getColumnsForColumnReference(string $reference, int $startRow, int $endRow): string
304+
{
305+
$columnsSelected = false;
306+
foreach ($this->columns as $columnId => $columnName) {
307+
$columnName = str_replace("\u{a0}", ' ', $columnName);
308+
$cellFrom = "{$columnId}{$startRow}";
309+
$cellTo = "{$columnId}{$endRow}";
310+
$cellReference = ($cellFrom === $cellTo) ? $cellFrom : "{$cellFrom}:{$cellTo}";
311+
$pattern = '/\[' . preg_quote($columnName) . '\]/mui';
312+
if (preg_match($pattern, $reference) === 1) {
313+
$columnsSelected = true;
314+
$reference = preg_replace($pattern, $cellReference, $reference);
315+
}
316+
/** @var string $reference */
317+
}
318+
if ($columnsSelected === false) {
319+
return $this->fullData($startRow, $endRow);
320+
}
321+
322+
return $reference;
323+
}
49324
}

0 commit comments

Comments
 (0)