Support for chained range operators in the Calculation Engine (e.g. `A3:B1:C2` which gives an effective combined range of `A1:C3` or `A5:C10:C20:F1` which gives an effective combined range of `A1:F20`).

Next step will be allowing Named Cells/Ranges to be chained in the same way.
This commit is contained in:
MarkBaker 2022-04-12 17:04:07 +02:00
parent a6cb80fd4c
commit 8c84ce4399
3 changed files with 60 additions and 54 deletions

View File

@ -127,7 +127,7 @@ parameters:
- -
message: "#^Offset 'value' does not exist on array\\|null\\.$#" message: "#^Offset 'value' does not exist on array\\|null\\.$#"
count: 3 count: 5
path: src/PhpSpreadsheet/Calculation/Calculation.php path: src/PhpSpreadsheet/Calculation/Calculation.php
- -

View File

@ -33,11 +33,11 @@ class Calculation
// Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it) // Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it)
const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?([\p{L}][\p{L}\p{N}\.]*)[\s]*\('; const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?([\p{L}][\p{L}\p{N}\.]*)[\s]*\(';
// Cell reference (cell or range of cells, with or without a sheet reference) // Cell reference (cell or range of cells, with or without a sheet reference)
const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=-]*)|(\'.*?\')|(\".*?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])'; const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|(\'.*?\')|(\".*?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])';
// Cell reference (with or without a sheet reference) ensuring absolute/relative // Cell reference (with or without a sheet reference) ensuring absolute/relative
const CALCULATION_REGEXP_CELLREF_RELATIVE = '((([^\s\(,!&%^\/\*\+<>=-]*)|(\'.*?\')|(\".*?\"))!)?(\$?\b[a-z]{1,3})(\$?\d{1,7})(?![\w.])'; const CALCULATION_REGEXP_CELLREF_RELATIVE = '((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'.*?\')|(\".*?\"))!)?(\$?\b[a-z]{1,3})(\$?\d{1,7})(?![\w.])';
const CALCULATION_REGEXP_COLUMN_RANGE = '(((([^\s\(,!&%^\/\*\+<>=-]*)|(\'.*?\')|(\".*?\"))!)?(\$?[a-z]{1,3})):(?![.*])'; const CALCULATION_REGEXP_COLUMN_RANGE = '(((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'.*?\')|(\".*?\"))!)?(\$?[a-z]{1,3})):(?![.*])';
const CALCULATION_REGEXP_ROW_RANGE = '(((([^\s\(,!&%^\/\*\+<>=-]*)|(\'.*?\')|(\".*?\"))!)?(\$?[1-9][0-9]{0,6})):(?![.*])'; const CALCULATION_REGEXP_ROW_RANGE = '(((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'.*?\')|(\".*?\"))!)?(\$?[1-9][0-9]{0,6})):(?![.*])';
// Cell reference (with or without a sheet reference) ensuring absolute/relative // Cell reference (with or without a sheet reference) ensuring absolute/relative
// Cell ranges ensuring absolute/relative // Cell ranges ensuring absolute/relative
const CALCULATION_REGEXP_COLUMNRANGE_RELATIVE = '(\$?[a-z]{1,3}):(\$?[a-z]{1,3})'; const CALCULATION_REGEXP_COLUMNRANGE_RELATIVE = '(\$?[a-z]{1,3}):(\$?[a-z]{1,3})';
@ -4135,17 +4135,25 @@ class Calculation
$testPrevOp = $stack->last(1); $testPrevOp = $stack->last(1);
if ($testPrevOp !== null && $testPrevOp['value'] === ':') { if ($testPrevOp !== null && $testPrevOp['value'] === ':') {
// If we have a worksheet reference, then we're playing with a 3D reference // If we have a worksheet reference, then we're playing with a 3D reference
if ($matches[2] == '') { if ($matches[2] === '') {
// Otherwise, we 'inherit' the worksheet reference from the start cell reference // Otherwise, we 'inherit' the worksheet reference from the start cell reference
// The start of the cell range reference should be the last entry in $output // The start of the cell range reference should be the last entry in $output
$rangeStartCellRef = $output[count($output) - 1]['value']; $rangeStartCellRef = $output[count($output) - 1]['value'];
preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', $rangeStartCellRef, $rangeStartMatches); if ($rangeStartCellRef === ':') {
// Do we have chained range operators?
$rangeStartCellRef = $output[count($output) - 2]['value'];
}
preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches);
if ($rangeStartMatches[2] > '') { if ($rangeStartMatches[2] > '') {
$val = $rangeStartMatches[2] . '!' . $val; $val = $rangeStartMatches[2] . '!' . $val;
} }
} else { } else {
$rangeStartCellRef = $output[count($output) - 1]['value']; $rangeStartCellRef = $output[count($output) - 1]['value'];
preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', $rangeStartCellRef, $rangeStartMatches); if ($rangeStartCellRef === ':') {
// Do we have chained range operators?
$rangeStartCellRef = $output[count($output) - 2]['value'];
}
preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches);
if ($rangeStartMatches[2] !== $matches[2]) { if ($rangeStartMatches[2] !== $matches[2]) {
return $this->raiseFormulaError('3D Range references are not yet supported'); return $this->raiseFormulaError('3D Range references are not yet supported');
} }
@ -4461,21 +4469,21 @@ class Calculation
// Process the operation in the appropriate manner // Process the operation in the appropriate manner
switch ($token) { switch ($token) {
// Comparison (Boolean) Operators // Comparison (Boolean) Operators
case '>': // Greater than case '>': // Greater than
case '<': // Less than case '<': // Less than
case '>=': // Greater than or Equal to case '>=': // Greater than or Equal to
case '<=': // Less than or Equal to case '<=': // Less than or Equal to
case '=': // Equality case '=': // Equality
case '<>': // Inequality case '<>': // Inequality
$result = $this->executeBinaryComparisonOperation($operand1, $operand2, (string) $token, $stack); $result = $this->executeBinaryComparisonOperation($operand1, $operand2, (string) $token, $stack);
if (isset($storeKey)) { if (isset($storeKey)) {
$branchStore[$storeKey] = $result; $branchStore[$storeKey] = $result;
} }
break; break;
// Binary Operators // Binary Operators
case ':': // Range case ':': // Range
if (strpos($operand1Data['reference'], '!') !== false) { if (strpos($operand1Data['reference'], '!') !== false) {
[$sheet1, $operand1Data['reference']] = Worksheet::extractSheetTitle($operand1Data['reference'], true); [$sheet1, $operand1Data['reference']] = Worksheet::extractSheetTitle($operand1Data['reference'], true);
} else { } else {

View File

@ -21,15 +21,7 @@ class RangeTest extends TestCase
{ {
$this->spreadSheet = new Spreadsheet(); $this->spreadSheet = new Spreadsheet();
$this->spreadSheet->getActiveSheet() $this->spreadSheet->getActiveSheet()
->setCellValue('A1', 1) ->fromArray(array_chunk(range(1, 240), 6), null, 'A1', true);
->setCellValue('B1', 2)
->setCellValue('C1', 3)
->setCellValue('A2', 4)
->setCellValue('B2', 5)
->setCellValue('C2', 6)
->setCellValue('A3', 7)
->setCellValue('B3', 8)
->setCellValue('C3', 9);
} }
/** /**
@ -40,33 +32,39 @@ class RangeTest extends TestCase
public function testRangeEvaluation(string $formula, $expectedResult): void public function testRangeEvaluation(string $formula, $expectedResult): void
{ {
$workSheet = $this->spreadSheet->getActiveSheet(); $workSheet = $this->spreadSheet->getActiveSheet();
$workSheet->setCellValue('E1', $formula); $workSheet->setCellValue('H1', $formula);
$actualRresult = $workSheet->getCell('E1')->getCalculatedValue(); $actualRresult = $workSheet->getCell('H1')->getCalculatedValue();
self::assertSame($expectedResult, $actualRresult); self::assertSame($expectedResult, $actualRresult);
} }
public function providerRangeEvaluation(): array public function providerRangeEvaluation(): array
{ {
return[ return[
['=SUM(A1:B3,A1:C2)', 48], 'Sum with Simple Range' => ['=SUM(A1:C3)', 72],
['=COUNT(A1:B3,A1:C2)', 12], 'Count with Simple Range' => ['=COUNT(A1:C3)', 9],
['=SUM(A1:B3 A1:C2)', 12], 'Sum with UNION #1' => ['=SUM(A1:B3,A1:C2)', 75],
['=COUNT(A1:B3 A1:C2)', 4], 'Count with UNION #1' => ['=COUNT(A1:B3,A1:C2)', 12],
['=SUM(A1:A3,C1:C3)', 30], 'Sum with INTERSECTION #1' => ['=SUM(A1:B3 A1:C2)', 18],
['=COUNT(A1:A3,C1:C3)', 6], 'Count with INTERSECTION #1' => ['=COUNT(A1:B3 A1:C2)', 4],
['=SUM(A1:A3 C1:C3)', Functions::null()], 'Sum with UNION #2' => ['=SUM(A1:A3,C1:C3)', 48],
['=COUNT(A1:A3 C1:C3)', 0], 'Count with UNION #2' => ['=COUNT(A1:A3,C1:C3)', 6],
['=SUM(A1:B2,B2:C3)', 40], 'Sum with INTERSECTION #2 - No Intersect' => ['=SUM(A1:A3 C1:C3)', Functions::null()],
['=COUNT(A1:B2,B2:C3)', 8], 'Count with INTERSECTION #2 - No Intersect' => ['=COUNT(A1:A3 C1:C3)', 0],
['=SUM(A1:B2 B2:C3)', 5], 'Sum with UNION #3' => ['=SUM(A1:B2,B2:C3)', 64],
['=COUNT(A1:B2 B2:C3)', 1], 'Count with UNION #3' => ['=COUNT(A1:B2,B2:C3)', 8],
['=SUM(A1:C1,A3:C3,B1:C3)', 63], 'Sum with INTERSECTION #3 - Single Cell' => ['=SUM(A1:B2 B2:C3)', 8],
['=COUNT(A1:C1,A3:C3,B1:C3)', 12], 'Count with INTERSECTION #3 - Single Cell' => ['=COUNT(A1:B2 B2:C3)', 1],
['=SUM(A1:C1,A3:C3 B1:C3)', 23], 'Sum with Triple UNION' => ['=SUM(A1:C1,A3:C3,B1:C3)', 99],
['=COUNT(A1:C1,A3:C3 B1:C3)', 5], 'Count with Triple UNION' => ['=COUNT(A1:C1,A3:C3,B1:C3)', 12],
['=SUM(Worksheet!A1:B3,Worksheet!A1:C2)', 48], 'Sum with UNION and INTERSECTION' => ['=SUM(A1:C1,A3:C3 B1:C3)', 35],
['=SUM(Worksheet!A1:Worksheet!B3,Worksheet!A1:Worksheet!C2)', 48], 'Count with UNION and INTERSECTION' => ['=COUNT(A1:C1,A3:C3 B1:C3)', 5],
'Sum with UNION with Worksheet Reference' => ['=SUM(Worksheet!A1:B3,Worksheet!A1:C2)', 75],
'Sum with UNION with full Worksheet Reference' => ['=SUM(Worksheet!A1:Worksheet!B3,Worksheet!A1:Worksheet!C2)', 75],
'Sum with Chained UNION #1' => ['=SUM(A3:B1:C2)', 72],
'Count with Chained UNION #1' => ['=COUNT(A3:B1:C2)', 9],
'Sum with Chained UNION #2' => ['=SUM(A5:C10:C20:F1)', 7260],
'Count with Chained UNION#2' => ['=COUNT(A5:C10:C20:F1)', 120],
]; ];
} }
@ -97,16 +95,16 @@ class RangeTest extends TestCase
public function providerNamedRangeEvaluation(): array public function providerNamedRangeEvaluation(): array
{ {
return[ return[
['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1,GROUP2)', 48], ['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1,GROUP2)', 75],
['$A$1:$B$3', '$A$1:$C$2', '=COUNT(GROUP1,GROUP2)', 12], ['$A$1:$B$3', '$A$1:$C$2', '=COUNT(GROUP1,GROUP2)', 12],
['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1 GROUP2)', 12], ['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1 GROUP2)', 18],
['$A$1:$B$3', '$A$1:$C$2', '=COUNT(GROUP1 GROUP2)', 4], ['$A$1:$B$3', '$A$1:$C$2', '=COUNT(GROUP1 GROUP2)', 4],
['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 40], ['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 64],
['$A$1:$B$2', '$B$2:$C$3', '=COUNT(GROUP1,GROUP2)', 8], ['$A$1:$B$2', '$B$2:$C$3', '=COUNT(GROUP1,GROUP2)', 8],
['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1 GROUP2)', 5], ['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1 GROUP2)', 8],
['$A$1:$B$2', '$B$2:$C$3', '=COUNT(GROUP1 GROUP2)', 1], ['$A$1:$B$2', '$B$2:$C$3', '=COUNT(GROUP1 GROUP2)', 1],
['Worksheet!$A$1:$B$2', 'Worksheet!$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 40], ['Worksheet!$A$1:$B$2', 'Worksheet!$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 64],
['Worksheet!$A$1:Worksheet!$B$2', 'Worksheet!$B$2:Worksheet!$C$3', '=SUM(GROUP1,GROUP2)', 40], ['Worksheet!$A$1:Worksheet!$B$2', 'Worksheet!$B$2:Worksheet!$C$3', '=SUM(GROUP1,GROUP2)', 64],
]; ];
} }
@ -132,9 +130,9 @@ class RangeTest extends TestCase
public function providerUTF8NamedRangeEvaluation(): array public function providerUTF8NamedRangeEvaluation(): array
{ {
return[ return[
[['Γειά', 'σου', 'Κόσμε'], ['$A$1', '$B$1:$B$2', '$C$1:$C$3'], '=SUM(Γειά,σου,Κόσμε)', 26], [['Γειά', 'σου', 'Κόσμε'], ['$A$1', '$B$1:$B$2', '$C$1:$C$3'], '=SUM(Γειά,σου,Κόσμε)', 38],
[['Γειά', 'σου', 'Κόσμε'], ['$A$1', '$B$1:$B$2', '$C$1:$C$3'], '=COUNT(Γειά,σου,Κόσμε)', 6], [['Γειά', 'σου', 'Κόσμε'], ['$A$1', '$B$1:$B$2', '$C$1:$C$3'], '=COUNT(Γειά,σου,Κόσμε)', 6],
[['Здравствуй', 'мир'], ['$A$1:$A$3', '$C$1:$C$3'], '=SUM(Здравствуй,мир)', 30], [['Здравствуй', 'мир'], ['$A$1:$A$3', '$C$1:$C$3'], '=SUM(Здравствуй,мир)', 48],
]; ];
} }