CalculationTest.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <?php
  2. namespace PhpOffice\PhpSpreadsheetTests\Calculation;
  3. use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
  4. use PhpOffice\PhpSpreadsheet\Calculation\Functions;
  5. use PhpOffice\PhpSpreadsheet\Cell\DataType;
  6. use PhpOffice\PhpSpreadsheet\Spreadsheet;
  7. use PHPUnit\Framework\TestCase;
  8. class CalculationTest extends TestCase
  9. {
  10. /**
  11. * @var string
  12. */
  13. private $compatibilityMode;
  14. /**
  15. * @var string
  16. */
  17. private $locale;
  18. protected function setUp(): void
  19. {
  20. $this->compatibilityMode = Functions::getCompatibilityMode();
  21. $calculation = Calculation::getInstance();
  22. $this->locale = $calculation->getLocale();
  23. Functions::setCompatibilityMode(Functions::COMPATIBILITY_EXCEL);
  24. }
  25. protected function tearDown(): void
  26. {
  27. Functions::setCompatibilityMode($this->compatibilityMode);
  28. $calculation = Calculation::getInstance();
  29. $calculation->setLocale($this->locale);
  30. }
  31. /**
  32. * @dataProvider providerBinaryComparisonOperation
  33. *
  34. * @param mixed $formula
  35. * @param mixed $expectedResultExcel
  36. * @param mixed $expectedResultOpenOffice
  37. */
  38. public function testBinaryComparisonOperation($formula, $expectedResultExcel, $expectedResultOpenOffice): void
  39. {
  40. Functions::setCompatibilityMode(Functions::COMPATIBILITY_EXCEL);
  41. $resultExcel = Calculation::getInstance()->_calculateFormulaValue($formula);
  42. self::assertEquals($expectedResultExcel, $resultExcel, 'should be Excel compatible');
  43. Functions::setCompatibilityMode(Functions::COMPATIBILITY_OPENOFFICE);
  44. $resultOpenOffice = Calculation::getInstance()->_calculateFormulaValue($formula);
  45. self::assertEquals($expectedResultOpenOffice, $resultOpenOffice, 'should be OpenOffice compatible');
  46. }
  47. public function providerBinaryComparisonOperation(): array
  48. {
  49. return require 'tests/data/CalculationBinaryComparisonOperation.php';
  50. }
  51. public function testDoesHandleXlfnFunctions(): void
  52. {
  53. $calculation = Calculation::getInstance();
  54. $tree = $calculation->parseFormula('=_xlfn.ISFORMULA(A1)');
  55. self::assertIsArray($tree);
  56. self::assertCount(3, $tree);
  57. $function = $tree[2];
  58. self::assertEquals('Function', $function['type']);
  59. $tree = $calculation->parseFormula('=_xlfn.STDEV.S(A1:B2)');
  60. self::assertIsArray($tree);
  61. self::assertCount(5, $tree);
  62. $function = $tree[4];
  63. self::assertEquals('Function', $function['type']);
  64. }
  65. public function testFormulaWithOptionalArgumentsAndRequiredCellReferenceShouldPassNullForMissingArguments(): void
  66. {
  67. $spreadsheet = new Spreadsheet();
  68. $sheet = $spreadsheet->getActiveSheet();
  69. $sheet->fromArray(
  70. [
  71. [1, 2, 3],
  72. [4, 5, 6],
  73. [7, 8, 9],
  74. ]
  75. );
  76. $cell = $sheet->getCell('E5');
  77. $cell->setValue('=OFFSET(D3, -1, -2, 1, 1)');
  78. self::assertEquals(5, $cell->getCalculatedValue(), 'with all arguments');
  79. $cell = $sheet->getCell('F6');
  80. $cell->setValue('=OFFSET(D3, -1, -2)');
  81. self::assertEquals(5, $cell->getCalculatedValue(), 'missing arguments should be filled with null');
  82. }
  83. public function testCellSetAsQuotedText(): void
  84. {
  85. $spreadsheet = new Spreadsheet();
  86. $workSheet = $spreadsheet->getActiveSheet();
  87. $cell = $workSheet->getCell('A1');
  88. $cell->setValue("=cmd|'/C calc'!A0");
  89. $cell->getStyle()->setQuotePrefix(true);
  90. self::assertEquals("=cmd|'/C calc'!A0", $cell->getCalculatedValue());
  91. $cell2 = $workSheet->getCell('A2');
  92. $cell2->setValueExplicit('ABC', DataType::TYPE_FORMULA);
  93. self::assertEquals('ABC', $cell2->getCalculatedValue());
  94. $cell3 = $workSheet->getCell('A3');
  95. $cell3->setValueExplicit('=', DataType::TYPE_FORMULA);
  96. self::assertEquals('', $cell3->getCalculatedValue());
  97. }
  98. public function testCellWithDdeExpresion(): void
  99. {
  100. $spreadsheet = new Spreadsheet();
  101. $workSheet = $spreadsheet->getActiveSheet();
  102. $cell = $workSheet->getCell('A1');
  103. $cell->setValue("=cmd|'/C calc'!A0");
  104. self::assertEquals("=cmd|'/C calc'!A0", $cell->getCalculatedValue());
  105. }
  106. public function testFormulaReferencingWorksheetWithEscapedApostrophe(): void
  107. {
  108. $spreadsheet = new Spreadsheet();
  109. $workSheet = $spreadsheet->getActiveSheet();
  110. $workSheet->setTitle("Catégorie d'absence");
  111. $workSheet->setCellValue('A1', 'HELLO');
  112. $workSheet->setCellValue('B1', ' ');
  113. $workSheet->setCellValue('C1', 'WORLD');
  114. $workSheet->setCellValue(
  115. 'A2',
  116. "=CONCAT('Catégorie d''absence'!A1, 'Catégorie d''absence'!B1, 'Catégorie d''absence'!C1)"
  117. );
  118. $cellValue = $workSheet->getCell('A2')->getCalculatedValue();
  119. self::assertSame('HELLO WORLD', $cellValue);
  120. }
  121. public function testFormulaReferencingWorksheetWithUnescapedApostrophe(): void
  122. {
  123. $spreadsheet = new Spreadsheet();
  124. $workSheet = $spreadsheet->getActiveSheet();
  125. $workSheet->setTitle("Catégorie d'absence");
  126. $workSheet->setCellValue('A1', 'HELLO');
  127. $workSheet->setCellValue('B1', ' ');
  128. $workSheet->setCellValue('C1', 'WORLD');
  129. $workSheet->setCellValue(
  130. 'A2',
  131. "=CONCAT('Catégorie d'absence'!A1, 'Catégorie d'absence'!B1, 'Catégorie d'absence'!C1)"
  132. );
  133. $cellValue = $workSheet->getCell('A2')->getCalculatedValue();
  134. self::assertSame('HELLO WORLD', $cellValue);
  135. }
  136. public function testCellWithFormulaTwoIndirect(): void
  137. {
  138. $spreadsheet = new Spreadsheet();
  139. $workSheet = $spreadsheet->getActiveSheet();
  140. $cell1 = $workSheet->getCell('A1');
  141. $cell1->setValue('2');
  142. $cell2 = $workSheet->getCell('B1');
  143. $cell2->setValue('3');
  144. $cell2 = $workSheet->getCell('C1');
  145. $cell2->setValue('4');
  146. $cell3 = $workSheet->getCell('D1');
  147. $cell3->setValue('=SUM(INDIRECT("A"&ROW()),INDIRECT("B"&ROW()),INDIRECT("C"&ROW()))');
  148. self::assertEquals('9', $cell3->getCalculatedValue());
  149. }
  150. public function testBranchPruningFormulaParsingSimpleCase(): void
  151. {
  152. $calculation = Calculation::getInstance();
  153. $calculation->flushInstance(); // resets the ids
  154. // Very simple formula
  155. $formula = '=IF(A1="please +",B1)';
  156. $tokens = $calculation->parseFormula($formula);
  157. self::assertIsArray($tokens);
  158. $foundEqualAssociatedToStoreKey = false;
  159. $foundConditionalOnB1 = false;
  160. foreach ($tokens as $token) {
  161. $isBinaryOperator = $token['type'] == 'Binary Operator';
  162. $isEqual = $token['value'] == '=';
  163. $correctStoreKey = ($token['storeKey'] ?? '') == 'storeKey-0';
  164. $correctOnlyIf = ($token['onlyIf'] ?? '') == 'storeKey-0';
  165. $isB1Reference = ($token['reference'] ?? '') == 'B1';
  166. $foundEqualAssociatedToStoreKey = $foundEqualAssociatedToStoreKey ||
  167. ($isBinaryOperator && $isEqual && $correctStoreKey);
  168. $foundConditionalOnB1 = $foundConditionalOnB1 ||
  169. ($isB1Reference && $correctOnlyIf);
  170. }
  171. self::assertTrue($foundEqualAssociatedToStoreKey);
  172. self::assertTrue($foundConditionalOnB1);
  173. }
  174. public function testBranchPruningFormulaParsingMultipleIfsCase(): void
  175. {
  176. $calculation = Calculation::getInstance();
  177. $calculation->flushInstance(); // resets the ids
  178. //
  179. // Internal operation
  180. $formula = '=IF(A1="please +",SUM(B1:B3))+IF(A2="please *",PRODUCT(C1:C3), C1)';
  181. $tokens = $calculation->parseFormula($formula);
  182. self::assertIsArray($tokens);
  183. $plusGotTagged = false;
  184. $productFunctionCorrectlyTagged = false;
  185. foreach ($tokens as $token) {
  186. $isBinaryOperator = $token['type'] == 'Binary Operator';
  187. $isPlus = $token['value'] == '+';
  188. $anyStoreKey = isset($token['storeKey']);
  189. $anyOnlyIf = isset($token['onlyIf']);
  190. $anyOnlyIfNot = isset($token['onlyIfNot']);
  191. $plusGotTagged = $plusGotTagged ||
  192. ($isBinaryOperator && $isPlus &&
  193. ($anyStoreKey || $anyOnlyIfNot || $anyOnlyIf));
  194. $isFunction = $token['type'] == 'Function';
  195. $isProductFunction = $token['value'] == 'PRODUCT(';
  196. $correctOnlyIf = ($token['onlyIf'] ?? '') == 'storeKey-1';
  197. $productFunctionCorrectlyTagged = $productFunctionCorrectlyTagged || ($isFunction && $isProductFunction && $correctOnlyIf);
  198. }
  199. self::assertFalse($plusGotTagged, 'chaining IF( should not affect the external operators');
  200. self::assertTrue($productFunctionCorrectlyTagged, 'function nested inside if should be tagged to be processed only if parent branching requires it');
  201. }
  202. public function testBranchPruningFormulaParingNestedIfCase(): void
  203. {
  204. $calculation = Calculation::getInstance();
  205. $calculation->flushInstance(); // resets the ids
  206. $formula = '=IF(A1="please +",SUM(B1:B3),1+IF(NOT(A2="please *"),C2-C1,PRODUCT(C1:C3)))';
  207. $tokens = $calculation->parseFormula($formula);
  208. self::assertIsArray($tokens);
  209. $plusCorrectlyTagged = false;
  210. $productFunctionCorrectlyTagged = false;
  211. $notFunctionCorrectlyTagged = false;
  212. $findOneOperandCountTagged = false;
  213. foreach ($tokens as $token) {
  214. $value = $token['value'];
  215. $isPlus = $value == '+';
  216. $isProductFunction = $value == 'PRODUCT(';
  217. $isNotFunction = $value == 'NOT(';
  218. $isIfOperand = $token['type'] == 'Operand Count for Function IF()';
  219. $isOnlyIfNotDepth1 = (array_key_exists('onlyIfNot', $token) ? $token['onlyIfNot'] : null) == 'storeKey-1';
  220. $isStoreKeyDepth1 = (array_key_exists('storeKey', $token) ? $token['storeKey'] : null) == 'storeKey-1';
  221. $isOnlyIfNotDepth0 = (array_key_exists('onlyIfNot', $token) ? $token['onlyIfNot'] : null) == 'storeKey-0';
  222. $plusCorrectlyTagged = $plusCorrectlyTagged || ($isPlus && $isOnlyIfNotDepth0);
  223. $notFunctionCorrectlyTagged = $notFunctionCorrectlyTagged || ($isNotFunction && $isOnlyIfNotDepth0 && $isStoreKeyDepth1);
  224. $productFunctionCorrectlyTagged = $productFunctionCorrectlyTagged || ($isProductFunction && $isOnlyIfNotDepth1 && !$isStoreKeyDepth1 && !$isOnlyIfNotDepth0);
  225. $findOneOperandCountTagged = $findOneOperandCountTagged || ($isIfOperand && $isOnlyIfNotDepth0);
  226. }
  227. self::assertTrue($plusCorrectlyTagged);
  228. self::assertTrue($productFunctionCorrectlyTagged);
  229. self::assertTrue($notFunctionCorrectlyTagged);
  230. }
  231. public function testBranchPruningFormulaParsingNoArgumentFunctionCase(): void
  232. {
  233. $calculation = Calculation::getInstance();
  234. $calculation->flushInstance(); // resets the ids
  235. $formula = '=IF(AND(TRUE(),A1="please +"),2,3)';
  236. // this used to raise a parser error, we keep it even though we don't
  237. // test the output
  238. $calculation->parseFormula($formula);
  239. self::assertTrue(true);
  240. }
  241. public function testBranchPruningFormulaParsingInequalitiesConditionsCase(): void
  242. {
  243. $calculation = Calculation::getInstance();
  244. $calculation->flushInstance(); // resets the ids
  245. $formula = '=IF(A1="flag",IF(A2<10, 0) + IF(A3<10000, 0))';
  246. $tokens = $calculation->parseFormula($formula);
  247. self::assertIsArray($tokens);
  248. $properlyTaggedPlus = false;
  249. foreach ($tokens as $token) {
  250. $isPlus = $token['value'] === '+';
  251. $hasOnlyIf = !empty($token['onlyIf']);
  252. $properlyTaggedPlus = $properlyTaggedPlus ||
  253. ($isPlus && $hasOnlyIf);
  254. }
  255. self::assertTrue($properlyTaggedPlus);
  256. }
  257. /**
  258. * @param mixed $expectedResult
  259. * @param mixed $dataArray
  260. * @param string $formula
  261. * @param string $cellCoordinates where to put the formula
  262. * @param string[] $shouldBeSetInCacheCells coordinates of cells that must
  263. * be set in cache
  264. * @param string[] $shouldNotBeSetInCacheCells coordinates of cells that must
  265. * not be set in cache because of pruning
  266. *
  267. * @dataProvider dataProviderBranchPruningFullExecution
  268. */
  269. public function testFullExecution(
  270. $expectedResult,
  271. $dataArray,
  272. $formula,
  273. $cellCoordinates,
  274. $shouldBeSetInCacheCells = [],
  275. $shouldNotBeSetInCacheCells = []
  276. ): void {
  277. $spreadsheet = new Spreadsheet();
  278. $sheet = $spreadsheet->getActiveSheet();
  279. $sheet->fromArray($dataArray);
  280. $cell = $sheet->getCell($cellCoordinates);
  281. $calculation = Calculation::getInstance($cell->getWorksheet()->getParent());
  282. $cell->setValue($formula);
  283. $calculated = $cell->getCalculatedValue();
  284. self::assertEquals($expectedResult, $calculated);
  285. // this mostly to ensure that at least some cells are cached
  286. foreach ($shouldBeSetInCacheCells as $setCell) {
  287. unset($inCache);
  288. $calculation->getValueFromCache('Worksheet!' . $setCell, $inCache);
  289. self::assertNotEmpty($inCache);
  290. }
  291. foreach ($shouldNotBeSetInCacheCells as $notSetCell) {
  292. unset($inCache);
  293. $calculation->getValueFromCache('Worksheet!' . $notSetCell, $inCache);
  294. self::assertEmpty($inCache);
  295. }
  296. $calculation->disableBranchPruning();
  297. $calculated = $cell->getCalculatedValue();
  298. self::assertEquals($expectedResult, $calculated);
  299. }
  300. public function dataProviderBranchPruningFullExecution(): array
  301. {
  302. return require 'tests/data/Calculation/Calculation.php';
  303. }
  304. }