diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 240e5a027..3f7b63e1c 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -4732,9 +4732,20 @@ class Calculation } if ($token instanceof Operands\StructuredReference) { - throw new Exception('Structured References are not currently supported'); // The next step is converting any structured reference to a cell value of range - // to a new $token value, which can then be processed in the following code. + // to a new $token value (a cell range), which can then be processed in the following code. + var_dump($token); + + if ($cell === null) { + return $this->raiseFormulaError('Structured References must exist in a Cell context'); + } + + try { + $token->parse($cell); + } catch (Exception $e) { + return $this->raiseFormulaError($e->getMessage()); + } + die(); } // 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 diff --git a/src/PhpSpreadsheet/Calculation/Engine/Operands/StructuredReference.php b/src/PhpSpreadsheet/Calculation/Engine/Operands/StructuredReference.php index 4a4d1b83d..3db976583 100644 --- a/src/PhpSpreadsheet/Calculation/Engine/Operands/StructuredReference.php +++ b/src/PhpSpreadsheet/Calculation/Engine/Operands/StructuredReference.php @@ -2,7 +2,11 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\Engine\Operands; +use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Exception; +use PhpOffice\PhpSpreadsheet\Cell\Cell; +use PhpOffice\PhpSpreadsheet\Cell\Coordinate; +use PhpOffice\PhpSpreadsheet\Worksheet\Table; final class StructuredReference implements Operand { @@ -11,8 +15,37 @@ final class StructuredReference implements Operand private const OPEN_BRACE = '['; private const CLOSE_BRACE = ']'; + private const ITEM_SPECIFIER_ALL = '#All'; + private const ITEM_SPECIFIER_HEADERS = '#Headers'; + private const ITEM_SPECIFIER_DATA = '#Data'; + private const ITEM_SPECIFIER_TOTALS = '#Totals'; + private const ITEM_SPECIFIER_THIS_ROW = '#This Row'; + + private const ITEM_SPECIFIER_ROWS_SET = [ + self::ITEM_SPECIFIER_ALL, + self::ITEM_SPECIFIER_HEADERS, + self::ITEM_SPECIFIER_DATA, + self::ITEM_SPECIFIER_TOTALS, + ]; + + private const TABLE_REFERENCE = '/([\p{L}_\\\\][\p{L}\p{N}\._]+)?(\[(?:[^\]\[]+|(?R))*+\])/miu'; + private string $value; + private string $tableName; + + private string $reference; + + private ?int $headersRow; + + private int $firstDataRow; + + private int $lastDataRow; + + private ?int $totalsRow; + + private array $columns; + public function __construct(string $structuredReference) { $this->value = $structuredReference; @@ -42,6 +75,228 @@ final class StructuredReference implements Operand return new self($val); } + /** + * @throws Exception + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + public function parse(Cell $cell): string + { + $this->getTableStructure($cell); + $cellRange = ($this->isRowReference()) ? $this->getRowReference($cell) : $this->getColumnReference(); + + return $cellRange; + } + + private function isRowReference(): bool + { + return strpos($this->value, '[@') !== false + || strpos($this->value, '[' . self::ITEM_SPECIFIER_THIS_ROW . ']') !== false; + } + + /** + * @throws Exception + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + private function getTableStructure(Cell $cell): void + { + preg_match(self::TABLE_REFERENCE, $this->value, $matches); + + $this->tableName = $matches[1]; + $table = ($this->tableName === '') + ? $this->getTableForCell($cell) + : $this->getTableByName($cell); + $this->reference = $matches[2]; + $tableRange = Coordinate::getRangeBoundaries($table->getRange()); + + $this->headersRow = ($table->getShowHeaderRow()) ? (int) $tableRange[0][1] : null; + $this->firstDataRow = ($table->getShowHeaderRow()) ? (int) $tableRange[0][1] + 1 : $tableRange[0][1]; + $this->totalsRow = ($table->getShowTotalsRow()) ? (int) $tableRange[1][1] : null; + $this->lastDataRow = ($table->getShowTotalsRow()) ? (int) $tableRange[1][1] - 1 : $tableRange[1][1]; + + $this->columns = $this->getColumns($cell, $tableRange); + } + + /** + * @throws Exception + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + private function getTableForCell(Cell $cell): Table + { + $tables = $cell->getWorksheet()->getTableCollection(); + foreach ($tables as $table) { + /** @var Table $table */ + $range = $table->getRange(); + if ($cell->isInRange($range) === true) { + $this->tableName = $table->getName(); + + return $table; + } + } + + throw new Exception('Table for Structured Reference cannot be identified'); + } + + /** + * @throws Exception + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + private function getTableByName(Cell $cell): Table + { + $table = $cell->getWorksheet()->getTableByName($this->tableName); + + if ($table === null) { + throw new Exception("Table {$this->tableName} for Structured Reference cannot be located"); + } + + return $table; + } + + private function getColumns(Cell $cell, array $tableRange): array + { + $worksheet = $cell->getWorksheet(); + $cellReference = $cell->getCoordinate(); + + $columns = []; + $lastColumn = ++$tableRange[1][0]; + for ($column = $tableRange[0][0]; $column !== $lastColumn; ++$column) { + $columns[$column] = $worksheet + ->getCell($column . $this->headersRow) + ->getCalculatedValue(); + } + + $cell = $worksheet->getCell($cellReference); + + return $columns; + } + + private function getRowReference(Cell $cell): string + { + $reference = str_replace("\u{a0}", ' ', $this->reference); + /** @var string $reference */ + $reference = str_replace('[' . self::ITEM_SPECIFIER_THIS_ROW . '],', '', $reference); + + foreach ($this->columns as $columnId => $columnName) { + $columnName = str_replace("\u{a0}", ' ', $columnName); + $cellReference = $columnId . $cell->getRow(); + /** @var string $reference */ + if (stripos($reference, '[' . $columnName . ']') !== false) { + $reference = preg_replace('/\[' . preg_quote($columnName) . '\]/miu', $cellReference, $reference); + } elseif (stripos($reference, $columnName) !== false) { + $reference = preg_replace('/@' . preg_quote($columnName) . '/miu', $cellReference, $reference); + } + } + + /** @var string $reference */ + return $this->validateParsedReference(trim($reference, '[]@ ')); + } + + /** + * @throws Exception + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + private function getColumnReference(): string + { + $reference = str_replace("\u{a0}", ' ', $this->reference); + $startRow = ($this->totalsRow === null) ? $this->lastDataRow : $this->totalsRow; + $endRow = ($this->headersRow === null) ? $this->firstDataRow : $this->headersRow; + + $rowsSelected = false; + foreach (self::ITEM_SPECIFIER_ROWS_SET as $rowReference) { + /** @var string $reference */ + if (stripos($reference, '[' . $rowReference . ']') !== false) { + $rowsSelected = true; + $startRow = min($startRow, $this->getMinimumRow($rowReference)); + $endRow = max($endRow, $this->getMaximumRow($rowReference)); + $reference = preg_replace('/\[' . $rowReference . '\],/mui', '', $reference); + } + } + if ($rowsSelected === false) { + // If there isn't any Special Item Identifier specified, then the selection defaults to data rows only. + $startRow = $this->firstDataRow; + $endRow = $this->lastDataRow; + } + + $columnsSelected = false; + foreach ($this->columns as $columnId => $columnName) { + $columnName = str_replace("\u{a0}", ' ', $columnName); + $cellFrom = "{$columnId}{$startRow}"; + $cellTo = "{$columnId}{$endRow}"; + $cellReference = ($cellFrom === $cellTo) ? $cellFrom : "{$cellFrom}:{$cellTo}"; + /** @var string $reference */ + if (stripos($reference, '[' . $columnName . ']') !== false) { + $columnsSelected = true; + $reference = preg_replace('/\[' . preg_quote($columnName) . '\]/miu', $cellReference, $reference); + } elseif (stripos($reference, $columnName) !== false) { + $reference = preg_replace('/@' . preg_quote($columnName) . '/miu', $cellReference, $reference); + $columnsSelected = true; + } + } + if ($columnsSelected === false) { + return $this->fullData($startRow, $endRow); + } + + /** @var string $reference */ + $reference = trim($reference, '[]@ '); + if (substr_count($reference, ':') > 1) { + $cells = explode(':', $reference); + $firstCell = array_shift($cells); + $lastCell = array_pop($cells); + $reference = "{$firstCell}:{$lastCell}"; + } + + return $this->validateParsedReference($reference); + } + + /** + * @throws Exception + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + private function validateParsedReference(string $reference): string + { + if (preg_match('/^' . Calculation::CALCULATION_REGEXP_CELLREF . ':' . Calculation::CALCULATION_REGEXP_CELLREF . '$/miu', $reference) !== 1) { + if (preg_match('/^' . Calculation::CALCULATION_REGEXP_CELLREF . '$/miu', $reference) !== 1) { + throw new Exception("Invalid Structured Reference {$this->reference} {$reference}"); + } + } + + return $reference; + } + + private function fullData(int $startRow, int $endRow): string + { + $columns = array_keys($this->columns); + $firstColumn = array_shift($columns); + $lastColumn = (empty($columns)) ? $firstColumn : array_pop($columns); + + return "{$firstColumn}{$startRow}:{$lastColumn}{$endRow}"; + } + + private function getMinimumRow(string $reference): int + { + switch ($reference) { + case self::ITEM_SPECIFIER_ALL: + case self::ITEM_SPECIFIER_HEADERS: + return $this->headersRow ?? $this->firstDataRow; + case self::ITEM_SPECIFIER_DATA: + return $this->firstDataRow; + case self::ITEM_SPECIFIER_TOTALS: + return $this->totalsRow ?? $this->lastDataRow; + } + } + + private function getMaximumRow(string $reference): int + { + switch ($reference) { + case self::ITEM_SPECIFIER_HEADERS: + return $this->headersRow ?? $this->firstDataRow; + case self::ITEM_SPECIFIER_DATA: + return $this->lastDataRow; + case self::ITEM_SPECIFIER_ALL: + case self::ITEM_SPECIFIER_TOTALS: + return $this->totalsRow ?? $this->lastDataRow; + } + } + public function value(): string { return $this->value; diff --git a/tests/PhpSpreadsheetTests/Calculation/Engine/StructuredReferenceTest.php b/tests/PhpSpreadsheetTests/Calculation/Engine/StructuredReferenceTest.php new file mode 100644 index 000000000..23bfe4c09 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Engine/StructuredReferenceTest.php @@ -0,0 +1,133 @@ +spreadSheet = new Spreadsheet(); + $workSheet = $this->spreadSheet->getActiveSheet(); + $workSheet->fromArray($this->tableData, null, 'A1'); + + $table = new Table('A1:E8', 'DeptSales'); + $table->setShowTotalsRow(true); + $table->getColumn('A')->setTotalsRowLabel('Total'); + $workSheet->addTable($table); + } + + protected function tearDown(): void + { + $this->spreadSheet->disconnectWorksheets(); + + parent::tearDown(); + } + + public function testStructuredReferenceInvalidTable(): void + { + $cell = $this->spreadSheet->getActiveSheet()->getCell('H5'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Table SalesResults for Structured Reference cannot be located'); + $structuredReferenceObject = new StructuredReference('SalesResults[@[% Commission]]'); + $structuredReferenceObject->parse($cell); + } + + public function testStructuredReferenceInvalidCellForTable(): void + { + $cell = $this->spreadSheet->getActiveSheet()->getCell('H99'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Table for Structured Reference cannot be identified'); + $structuredReferenceObject = new StructuredReference('[@[% Commission]]'); + $structuredReferenceObject->parse($cell); + } + + /** + * @dataProvider structuredReferenceProviderColumnData + */ + public function testStructuredReferenceColumns(string $expectedCellRange, string $structuredReference): void + { + $cell = $this->spreadSheet->getActiveSheet()->getCell('E5'); + + $structuredReferenceObject = new StructuredReference($structuredReference); + $cellRange = $structuredReferenceObject->parse($cell); + self::assertSame($expectedCellRange, $cellRange); + } + + /** + * @dataProvider structuredReferenceProviderRowData + */ + public function testStructuredReferenceRows(string $expectedCellRange, string $structuredReference): void + { + $cell = $this->spreadSheet->getActiveSheet()->getCell('E5'); + + $structuredReferenceObject = new StructuredReference($structuredReference); + $cellRange = $structuredReferenceObject->parse($cell); + self::assertSame($expectedCellRange, $cellRange); + } + + public function structuredReferenceProviderColumnData(): array + { + return [ + // Full table, with no column specified, means data only, not headers or totals + 'Full table Unqualified' => ['A2:E7', '[]'], + 'Full table Qualified' => ['A2:E7', 'DeptSales[]'], + // No item identifier, but with a column identifier, means data and header for the column, but no totals + 'Column with no Item Identifier #1' => ['A2:A7', 'DeptSales[[Sales Person]]'], + 'Column with no Item Identifier #2' => ['B2:B7', 'DeptSales[Region]'], + // Item identifier with no column specified + 'Item Identifier only #1' => ['A1:E1', 'DeptSales[#Headers]'], + 'Item Identifier only #2' => ['A1:E1', 'DeptSales[[#Headers]]'], + 'Item Identifier only #3' => ['A8:E8', 'DeptSales[#Totals]'], + 'Item Identifier only #4' => ['A2:E7', 'DeptSales[#Data]'], + // Item identifiers and column identifiers + 'Full column' => ['C1:C8', 'DeptSales[[#All],[Sales Amount]]'], + 'Column Header' => ['D1', 'DeptSales[[#Headers],[% Commission]]'], + 'Column Total' => ['B8', 'DeptSales[[#Totals],[Region]]'], + 'Column Range All' => ['C1:D8', 'DeptSales[[#All],[Sales Amount]:[% Commission]]'], + 'Column Range Data' => ['D2:E7', 'DeptSales[[#Data],[% Commission]:[Commission Amount]]'], + 'Column Range Headers' => ['B1:E1', 'DeptSales[[#Headers],[Region]:[Commission Amount]]'], + 'Column Range Totals' => ['C8:E8', 'DeptSales[[#Totals],[Sales Amount]:[Commission Amount]]'], + 'Column Range Headers and Data' => ['D1:D7', 'DeptSales[[#Headers],[#Data],[% Commission]]'], + 'Column Range No Item Identifier' => ['A2:B7', 'DeptSales[[Sales Person]:[Region]]'], + // ['C2:C7,E2:E7', 'DeptSales[Sales Amount],DeptSales[Commission Amount]'], + // ['B2:C7', 'DeptSales[[Sales Person]:[Sales Amount]] DeptSales[[Region]:[% Commission]]'], + ]; + } + + public function structuredReferenceProviderRowData(): array + { + return [ + ['E5', 'DeptSales[[#This Row], [Commission Amount]]'], + ['E5', 'DeptSales[@Commission Amount]'], + ['E5', 'DeptSales[@[Commission Amount]]'], + ['C5:D5', 'DeptSales[@[Sales Amount]:[% Commission]]'], + ]; + } +}