← All changes
|
libraries/vendor/PhpSpreadsheet/Calculation/Calculation.php
+94
-22
3.3.1
→
3.4
View file →
| @@ -89,8 +89,21 @@ | ||
| 89 | 89 | * Calculation cache enabled. |
| 90 | 90 | */ |
| 91 | 91 | private bool $calculationCacheEnabled = true; |
| 92 | 92 | |
| 93 | + /** | |
| 94 | + * Maximum number of entries in the formula token cache. | |
| 95 | + * Default 0 (disabled). Set via setFormulaTokenCacheMaxSize() to enable. | |
| 96 | + */ | |
| 97 | + private int $formulaTokenCacheMaxSize = 0; | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * Cache of parsed formula tokens, keyed by the raw formula string. | |
| 101 | + * | |
| 102 | + * @var array<string, array<mixed>|bool> | |
| 103 | + */ | |
| 104 | + private array $formulaTokenCache = []; | |
| 105 | + | |
| 93 | 106 | private BranchPruner $branchPruner; |
| 94 | 107 | |
| 95 | 108 | protected bool $branchPruningEnabled = true; |
| 96 | 109 | |
| @@ -243,8 +256,9 @@ | ||
| 243 | 256 | public function flushInstance(): void |
| 244 | 257 | { |
| 245 | 258 | $this->clearCalculationCache(); |
| 246 | 259 | $this->branchPruner->clearBranchStore(); |
| 260 | + $this->formulaTokenCache = []; | |
| 247 | 261 | } |
| 248 | 262 | |
| 249 | 263 | /** |
| 250 | 264 | * Get the Logger for this calculation engine instance. |
| @@ -369,8 +383,46 @@ | ||
| 369 | 383 | $this->calculationCache = []; |
| 370 | 384 | } |
| 371 | 385 | |
| 372 | 386 | /** |
| 387 | + * Clear the formula token cache. | |
| 388 | + */ | |
| 389 | + public function clearFormulaTokenCache(): void | |
| 390 | + { | |
| 391 | + $this->formulaTokenCache = []; | |
| 392 | + } | |
| 393 | + | |
| 394 | + /** | |
| 395 | + * Get the current number of entries in the formula token cache. | |
| 396 | + */ | |
| 397 | + public function getFormulaTokenCacheSize(): int | |
| 398 | + { | |
| 399 | + return count($this->formulaTokenCache); | |
| 400 | + } | |
| 401 | + | |
| 402 | + /** | |
| 403 | + * Set the maximum number of entries in the formula token cache. | |
| 404 | + * Set to 0 to disable caching (default), or a positive integer to enable. | |
| 405 | + */ | |
| 406 | + public function setFormulaTokenCacheMaxSize(int $size): self | |
| 407 | + { | |
| 408 | + $this->formulaTokenCacheMaxSize = max(0, $size); | |
| 409 | + if ($this->formulaTokenCacheMaxSize === 0) { | |
| 410 | + $this->formulaTokenCache = []; | |
| 411 | + } | |
| 412 | + | |
| 413 | + return $this; | |
| 414 | + } | |
| 415 | + | |
| 416 | + /** | |
| 417 | + * Get the maximum number of entries allowed in the formula token cache. | |
| 418 | + */ | |
| 419 | + public function getFormulaTokenCacheMaxSize(): int | |
| 420 | + { | |
| 421 | + return $this->formulaTokenCacheMaxSize; | |
| 422 | + } | |
| 423 | + | |
| 424 | + /** | |
| 373 | 425 | * Clear calculation cache for a specified worksheet. |
| 374 | 426 | */ |
| 375 | 427 | public function clearCalculationCacheForWorksheet(string $worksheetName): void |
| 376 | 428 | { |
| @@ -513,9 +565,9 @@ | ||
| 513 | 565 | fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')', |
| 514 | 566 | $value |
| 515 | 567 | ); |
| 516 | 568 | } |
| 517 | - $result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore-line | |
| 569 | + $result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore argument.type ($value can be mixed not string) | |
| 518 | 570 | if ($this->spreadsheet === null) { |
| 519 | 571 | throw new Exception('null spreadsheet in calculateCellValue'); |
| 520 | 572 | } |
| 521 | 573 | $cellAddressAttempted = true; |
| @@ -533,9 +585,9 @@ | ||
| 533 | 585 | if (!$cellAddressAttempted) { |
| 534 | 586 | $cellAddress = array_pop($this->cellStack); |
| 535 | 587 | } |
| 536 | 588 | if ($this->spreadsheet !== null && is_array($cellAddress) && array_key_exists('sheet', $cellAddress)) { |
| 537 | - $sheetName = $cellAddress['sheet'] ?? null; | |
| 589 | + $sheetName = $cellAddress['sheet']; | |
| 538 | 590 | $testSheet = is_string($sheetName) ? $this->spreadsheet->getSheetByName($sheetName) : null; |
| 539 | 591 | if ($testSheet !== null && array_key_exists('cell', $cellAddress)) { |
| 540 | 592 | /** @var array{cell: string} $cellAddress */ |
| 541 | 593 | $testSheet->getCell($cellAddress['cell']); |
| @@ -570,8 +622,14 @@ | ||
| 570 | 622 | * @return array<mixed>|bool |
| 571 | 623 | */ |
| 572 | 624 | public function parseFormula(string $formula) |
| 573 | 625 | { |
| 626 | + // Check the formula token cache first (only when caching is enabled) | |
| 627 | + if ($this->formulaTokenCacheMaxSize > 0 && isset($this->formulaTokenCache[$formula])) { | |
| 628 | + return $this->formulaTokenCache[$formula]; | |
| 629 | + } | |
| 630 | + | |
| 631 | + $originalFormula = $formula; | |
| 574 | 632 | $formula = Preg::replaceCallback( |
| 575 | 633 | self::CALCULATION_REGEXP_CELLREF_SPILL, |
| 576 | 634 | fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')', |
| 577 | 635 | $formula |
| @@ -587,9 +645,23 @@ | ||
| 587 | 645 | return []; |
| 588 | 646 | } |
| 589 | 647 | |
| 590 | 648 | // Parse the formula and return the token stack |
| 591 | - return $this->internalParseFormula($formula); | |
| 649 | + $result = $this->internalParseFormula($formula); | |
| 650 | + | |
| 651 | + // Cache the result when caching is enabled (clear cache if it exceeds the maximum size) | |
| 652 | + if ($this->formulaTokenCacheMaxSize > 0) { | |
| 653 | + // Phpstan says if condition is always false, | |
| 654 | + // but coverage report says next statement is covered. | |
| 655 | + if (count($this->formulaTokenCache) >= $this->formulaTokenCacheMaxSize) { | |
| 656 | + $this->formulaTokenCache = []; | |
| 657 | + } | |
| 658 | + // Cache key is the original formula string (before ANCHORARRAY transformation) | |
| 659 | + // to ensure consistent lookup regardless of internal transformations. | |
| 660 | + $this->formulaTokenCache[$originalFormula] = $result; | |
| 661 | + } | |
| 662 | + | |
| 663 | + return $result; | |
| 592 | 664 | } |
| 593 | 665 | |
| 594 | 666 | /** |
| 595 | 667 | * Calculate the value of a formula. |
| @@ -937,9 +1009,9 @@ | ||
| 937 | 1009 | $returnMatrix = []; |
| 938 | 1010 | $pad = $rpad = ', '; |
| 939 | 1011 | foreach ($value as $row) { |
| 940 | 1012 | if (is_array($row)) { |
| 941 | - $returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row)); // @phpstan-ignore-line | |
| 1013 | + $returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row)); // @phpstan-ignore argument.type (array_map can theoretically return array<mixed> not array<string>) | |
| 942 | 1014 | $rpad = '; '; |
| 943 | 1015 | } else { |
| 944 | 1016 | $returnMatrix[] = $this->showValue($row); |
| 945 | 1017 | } |
| @@ -1339,9 +1411,9 @@ | ||
| 1339 | 1411 | } elseif ($isOperandOrFunction && !$expectingOperatorCopy) { |
| 1340 | 1412 | // do we now have a function/variable/number? |
| 1341 | 1413 | $expectingOperator = true; |
| 1342 | 1414 | $expectingOperand = false; |
| 1343 | - $val = $match[1] ?? ''; //* @phpstan-ignore-line | |
| 1415 | + $val = $match[1] ?? ''; | |
| 1344 | 1416 | $length = strlen($val); |
| 1345 | 1417 | |
| 1346 | 1418 | if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) { |
| 1347 | 1419 | // $val is known to be valid unicode from statement above, so Preg::replace is okay even with u modifier |
| @@ -1492,9 +1564,9 @@ | ||
| 1492 | 1564 | $val = "{$rangeWS2}{$endRowColRef}{$val}"; |
| 1493 | 1565 | } elseif (ctype_alpha($val) && strlen($val) <= 3) { |
| 1494 | 1566 | // Column range |
| 1495 | 1567 | $stackItemType = 'Column Reference'; |
| 1496 | - $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow($val) : AddressRange::MAX_ROW; // Max 1,048,576 rows for Excel2007 | |
| 1568 | + $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow() : AddressRange::MAX_ROW; // Max 1,048,576 rows for Excel2007 | |
| 1497 | 1569 | $val = "{$rangeWS2}{$val}{$endRowColRef}"; |
| 1498 | 1570 | } |
| 1499 | 1571 | $stackItemReference = $val; |
| 1500 | 1572 | } |
| @@ -1518,9 +1590,9 @@ | ||
| 1518 | 1590 | $stackItemType = 'Row Reference'; |
| 1519 | 1591 | // unescape any apostrophes or double quotes in worksheet name |
| 1520 | 1592 | $val = str_replace(["''", '""'], ["'", '"'], $val); |
| 1521 | 1593 | $column = 'A'; |
| 1522 | - if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { | |
| 1594 | + if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevop must be non-null), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto) | |
| 1523 | 1595 | $column = $pCellParent->getHighestDataColumn($val); |
| 1524 | 1596 | } |
| 1525 | 1597 | $val = "{$rowRangeReference[2]}{$column}{$rowRangeReference[7]}"; |
| 1526 | 1598 | $stackItemReference = $val; |
| @@ -1532,9 +1604,9 @@ | ||
| 1532 | 1604 | $stackItemType = 'Column Reference'; |
| 1533 | 1605 | // unescape any apostrophes or double quotes in worksheet name |
| 1534 | 1606 | $val = str_replace(["''", '""'], ["'", '"'], $val); |
| 1535 | 1607 | $row = '1'; |
| 1536 | - if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { | |
| 1608 | + if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevOp must be non-null?), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto) | |
| 1537 | 1609 | $row = $pCellParent->getHighestDataRow($val); |
| 1538 | 1610 | } |
| 1539 | 1611 | $val = "{$val}{$row}"; |
| 1540 | 1612 | $stackItemReference = $val; |
| @@ -1684,9 +1756,9 @@ | ||
| 1684 | 1756 | // Loop through each token in turn |
| 1685 | 1757 | foreach ($tokens as $tokenIdx => $tokenData) { |
| 1686 | 1758 | /** @var mixed[] $tokenData */ |
| 1687 | 1759 | $this->processingAnchorArray = false; |
| 1688 | - if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore-line | |
| 1760 | + if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore offsetAccess.nonOffsetAccessible ($tokens might be mixed not array) | |
| 1689 | 1761 | $this->processingAnchorArray = true; |
| 1690 | 1762 | } |
| 1691 | 1763 | $token = $tokenData['value']; |
| 1692 | 1764 | // Branch pruning: skip useless resolutions |
| @@ -1786,9 +1858,9 @@ | ||
| 1786 | 1858 | } else { |
| 1787 | 1859 | return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e); |
| 1788 | 1860 | } |
| 1789 | 1861 | } |
| 1790 | - } elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore-line | |
| 1862 | + } elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore offsetAccess.invalidOffset ($token is mixed) | |
| 1791 | 1863 | // 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 |
| 1792 | 1864 | // We must have two operands, error if we don't |
| 1793 | 1865 | $operand2Data = $stack->pop(); |
| 1794 | 1866 | if ($operand2Data === null) { |
| @@ -1904,9 +1976,9 @@ | ||
| 1904 | 1976 | } |
| 1905 | 1977 | if ($breakNeeded) { |
| 1906 | 1978 | break; |
| 1907 | 1979 | } |
| 1908 | - $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line | |
| 1980 | + $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); | |
| 1909 | 1981 | if ($pCellParent !== null && $this->spreadsheet !== null) { |
| 1910 | 1982 | $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false); |
| 1911 | 1983 | } else { |
| 1912 | 1984 | return $this->raiseFormulaError('Unable to access Cell Reference'); |
| @@ -1975,9 +2047,9 @@ | ||
| 1975 | 2047 | $result = $operand1; |
| 1976 | 2048 | } elseif (Information\ErrorValue::isError($operand2)) { |
| 1977 | 2049 | $result = $operand2; |
| 1978 | 2050 | } else { |
| 1979 | - $result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore-line | |
| 2051 | + $result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore binaryOp.invalid (unwrapresult can return mixed rather than string) | |
| 1980 | 2052 | $result = StringHelper::substring( |
| 1981 | 2053 | $result, |
| 1982 | 2054 | 0, |
| 1983 | 2055 | DataType::MAX_STRING_LENGTH |
| @@ -2008,10 +2080,10 @@ | ||
| 2008 | 2080 | if (count(Functions::flattenArray($cellIntersect)) === 0) { |
| 2009 | 2081 | $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect)); |
| 2010 | 2082 | $stack->push('Error', ExcelError::null(), null); |
| 2011 | 2083 | } else { |
| 2012 | - $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore-line | |
| 2013 | - . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line | |
| 2084 | + $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto) | |
| 2085 | + . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto) | |
| 2014 | 2086 | $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect)); |
| 2015 | 2087 | $stack->push('Value', $cellIntersect, $cellRef); |
| 2016 | 2088 | } |
| 2017 | 2089 | |
| @@ -2024,15 +2096,15 @@ | ||
| 2024 | 2096 | $stack->push('Value', $cellUnion, 'A1'); |
| 2025 | 2097 | |
| 2026 | 2098 | break; |
| 2027 | 2099 | } |
| 2028 | - } elseif (($token === '~') || ($token === '%')) { | |
| 2100 | + } elseif (($token === '~') || ($token === '%')) { // @phpstan-ignore booleanOr.alwaysFalse (phpstan says token can't be plain string), identical.alwaysFalse (ditto), identical.alwaysFalse (ditto) | |
| 2029 | 2101 | // if the token is a unary operator, pop one value off the stack, do the operation, and push it back on |
| 2030 | 2102 | if (($arg = $stack->pop()) === null) { |
| 2031 | 2103 | return $this->raiseFormulaError('Internal error - Operand value missing from stack'); |
| 2032 | 2104 | } |
| 2033 | 2105 | $arg = $arg['value']; |
| 2034 | - if ($token === '~') { | |
| 2106 | + if ($token === '~') { // @phpstan-ignore identical.alwaysFalse (phpstan says token can't be plain string) | |
| 2035 | 2107 | $this->debugLog->writeDebugLog('Evaluating Negation of %s', $this->showValue($arg)); |
| 2036 | 2108 | $multiplier = -1; |
| 2037 | 2109 | } else { |
| 2038 | 2110 | $this->debugLog->writeDebugLog('Evaluating Percentile of %s', $this->showValue($arg)); |
| @@ -2204,9 +2276,9 @@ | ||
| 2204 | 2276 | $arg = $stack->pop(); |
| 2205 | 2277 | $a = $argCount - $i - 1; |
| 2206 | 2278 | if ( |
| 2207 | 2279 | ($passByReference) |
| 2208 | - && (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore-line | |
| 2280 | + && (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore offsetAccess.nonOffsetAccessible (possibly need to pass 2 arguments to isset) | |
| 2209 | 2281 | && ($phpSpreadsheetFunctions[$functionName]['passByReference'][$a]) |
| 2210 | 2282 | ) { |
| 2211 | 2283 | /** @var mixed[] $arg */ |
| 2212 | 2284 | if ($arg['reference'] === null) { |
| @@ -2271,9 +2343,9 @@ | ||
| 2271 | 2343 | |
| 2272 | 2344 | if ($functionName !== 'MKMATRIX') { |
| 2273 | 2345 | if ($this->debugLog->getWriteDebugLog()) { |
| 2274 | 2346 | krsort($argArrayVals); |
| 2275 | - $this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals))); // @phpstan-ignore-line | |
| 2347 | + $this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals))); // @phpstan-ignore argument.type (flattenArray returns array<mixed> rather than array<string>) | |
| 2276 | 2348 | } |
| 2277 | 2349 | } |
| 2278 | 2350 | |
| 2279 | 2351 | // Process the argument with the appropriate function call |
| @@ -2308,9 +2380,9 @@ | ||
| 2308 | 2380 | } |
| 2309 | 2381 | } |
| 2310 | 2382 | } else { |
| 2311 | 2383 | // if the token is a number, boolean, string or an Excel error, push it onto the stack |
| 2312 | - /** @var ?string $token */ | |
| 2384 | + /** @var null|numeric-string $token */ | |
| 2313 | 2385 | if (isset(self::EXCEL_CONSTANTS[strtoupper($token ?? '')])) { |
| 2314 | 2386 | $excelConstant = strtoupper("$token"); |
| 2315 | 2387 | $stack->push('Constant Value', self::EXCEL_CONSTANTS[$excelConstant]); |
| 2316 | 2388 | if (isset($storeKey)) { |
| @@ -2316,9 +2388,9 @@ | ||
| 2316 | 2388 | if (isset($storeKey)) { |
| 2317 | 2389 | $branchStore[$storeKey] = self::EXCEL_CONSTANTS[$excelConstant]; |
| 2318 | 2390 | } |
| 2319 | 2391 | $this->debugLog->writeDebugLog('Evaluating Constant %s as %s', $excelConstant, $this->showTypeDetails(self::EXCEL_CONSTANTS[$excelConstant])); |
| 2320 | - } elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore-line | |
| 2392 | + } elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore function.alreadyNarrowedType (phpstan says is_bool has argument of *NEVER*?), equal.alwaysFalse (ditto) | |
| 2321 | 2393 | /** @var array{type: string, reference: ?string} $tokenData */ |
| 2322 | 2394 | $stack->push($tokenData['type'], $token, $tokenData['reference']); |
| 2323 | 2395 | if (isset($storeKey)) { |
| 2324 | 2396 | $branchStore[$storeKey] = $token; |
| @@ -2447,9 +2519,9 @@ | ||
| 2447 | 2519 | /** @var array<string, mixed> $r */ |
| 2448 | 2520 | $r = $stack->pop(); |
| 2449 | 2521 | $result[$x] = $r['value']; |
| 2450 | 2522 | } |
| 2451 | - } elseif (is_array($operand2) && is_array($operand1)) { | |
| 2523 | + } elseif (is_array($operand2) /*&& is_array($operand1)*/) { | |
| 2452 | 2524 | // Operand 1 and Operand 2 are both arrays |
| 2453 | 2525 | if (!$recursingArrays) { |
| 2454 | 2526 | self::checkMatrixOperands($operand1, $operand2, 2); |
| 2455 | 2527 | } |
| @@ -2460,9 +2532,9 @@ | ||
| 2460 | 2532 | $r = $stack->pop(); |
| 2461 | 2533 | $result[$x] = $r['value']; |
| 2462 | 2534 | } |
| 2463 | 2535 | } else { |
| 2464 | - throw new Exception('Neither operand is an arra'); | |
| 2536 | + throw new Exception('Neither operand is an array'); | |
| 2465 | 2537 | } |
| 2466 | 2538 | // Log the result details |
| 2467 | 2539 | $this->debugLog->writeDebugLog('Comparison Evaluation Result is %s', $this->showTypeDetails($result)); |
| 2468 | 2540 | // And push the result onto the stack |