VLOOKUP Breaks When Array Contains Null Cells (#2939)

Fix #2934. Null is passed to StringHelper::strtolower which expects string. Same problem appears to be applicable to HLOOKUP.

I noted the following problem in the code, but will document it here as well. Excel's results are not consistent when a non-numeric string is passed as the third parameter. For example, if cell Z1 contains `xyz`, Excel will return a REF error for function `VLOOKUP(whatever,whatever,Z1)`, but it returns a VALUE error for function `VLOOKUP(whatever,whatever,"xyz")`. I don't think PhpSpreadsheet can match both behaviors. For now, it will return VALUE for both, with similar results for other errors.

While studying the returned errors, I realized there is something that needs to be deprecated. `ExcelError::$errorCodes` is a public static array. This means that a user can change its value, which should not be allowed. It is replaced by a constant. Since the original is public, I think it needs to stay, but with a deprecation notice; users can reference and change it, but it will be unused in the rest of the code. I suppose this might be considered a break in functionality (that should not have been allowed in the first place).
This commit is contained in:
oleibman 2022-07-17 06:27:56 -07:00 committed by GitHub
parent a062521a18
commit 4bf4278a39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 106 additions and 31 deletions

View File

@ -47,7 +47,7 @@ class ErrorValue
return false; return false;
} }
return in_array($value, ExcelError::$errorCodes, true); return in_array($value, ExcelError::ERROR_CODES, true);
} }
/** /**

View File

@ -13,7 +13,7 @@ class ExcelError
* *
* @var array<string, string> * @var array<string, string>
*/ */
public static $errorCodes = [ public const ERROR_CODES = [
'null' => '#NULL!', // 1 'null' => '#NULL!', // 1
'divisionbyzero' => '#DIV/0!', // 2 'divisionbyzero' => '#DIV/0!', // 2
'value' => '#VALUE!', // 3 'value' => '#VALUE!', // 3
@ -30,12 +30,23 @@ class ExcelError
'calculation' => '#CALC!', //14 'calculation' => '#CALC!', //14
]; ];
/**
* List of error codes. Replaced by constant;
* previously it was public and updateable, allowing
* user to make inappropriate alterations.
*
* @deprecated 1.25.0 Use ERROR_CODES constant instead.
*
* @var array<string, string>
*/
public static $errorCodes = self::ERROR_CODES;
/** /**
* @param mixed $value * @param mixed $value
*/ */
public static function throwError($value): string public static function throwError($value): string
{ {
return in_array($value, self::$errorCodes, true) ? $value : self::$errorCodes['value']; return in_array($value, self::ERROR_CODES, true) ? $value : self::ERROR_CODES['value'];
} }
/** /**
@ -52,7 +63,7 @@ class ExcelError
} }
$i = 1; $i = 1;
foreach (self::$errorCodes as $errorCode) { foreach (self::ERROR_CODES as $errorCode) {
if ($value === $errorCode) { if ($value === $errorCode) {
return $i; return $i;
} }
@ -71,7 +82,7 @@ class ExcelError
*/ */
public static function null(): string public static function null(): string
{ {
return self::$errorCodes['null']; return self::ERROR_CODES['null'];
} }
/** /**
@ -83,7 +94,7 @@ class ExcelError
*/ */
public static function NAN(): string public static function NAN(): string
{ {
return self::$errorCodes['num']; return self::ERROR_CODES['num'];
} }
/** /**
@ -95,7 +106,7 @@ class ExcelError
*/ */
public static function REF(): string public static function REF(): string
{ {
return self::$errorCodes['reference']; return self::ERROR_CODES['reference'];
} }
/** /**
@ -111,7 +122,7 @@ class ExcelError
*/ */
public static function NA(): string public static function NA(): string
{ {
return self::$errorCodes['na']; return self::ERROR_CODES['na'];
} }
/** /**
@ -123,7 +134,7 @@ class ExcelError
*/ */
public static function VALUE(): string public static function VALUE(): string
{ {
return self::$errorCodes['value']; return self::ERROR_CODES['value'];
} }
/** /**
@ -135,7 +146,7 @@ class ExcelError
*/ */
public static function NAME(): string public static function NAME(): string
{ {
return self::$errorCodes['name']; return self::ERROR_CODES['name'];
} }
/** /**
@ -145,7 +156,7 @@ class ExcelError
*/ */
public static function DIV0(): string public static function DIV0(): string
{ {
return self::$errorCodes['divisionbyzero']; return self::ERROR_CODES['divisionbyzero'];
} }
/** /**
@ -155,6 +166,6 @@ class ExcelError
*/ */
public static function CALC(): string public static function CALC(): string
{ {
return self::$errorCodes['calculation']; return self::ERROR_CODES['calculation'];
} }
} }

View File

@ -66,7 +66,7 @@ class HLookup extends LookupBase
*/ */
private static function hLookupSearch($lookupValue, array $lookupArray, $column, bool $notExactMatch): ?int private static function hLookupSearch($lookupValue, array $lookupArray, $column, bool $notExactMatch): ?int
{ {
$lookupLower = StringHelper::strToLower($lookupValue); $lookupLower = StringHelper::strToLower((string) $lookupValue);
$rowNumber = null; $rowNumber = null;
foreach ($lookupArray[$column] as $rowKey => $rowData) { foreach ($lookupArray[$column] as $rowKey => $rowData) {

View File

@ -19,8 +19,16 @@ abstract class LookupBase
protected static function validateIndexLookup(array $lookup_array, $index_number): int protected static function validateIndexLookup(array $lookup_array, $index_number): int
{ {
// index_number must be a number greater than or equal to 1 // index_number must be a number greater than or equal to 1.
if (!is_numeric($index_number) || $index_number < 1) { // Excel results are inconsistent when index is non-numeric.
// VLOOKUP(whatever, whatever, SQRT(-1)) yields NUM error, but
// VLOOKUP(whatever, whatever, cellref) yields REF error
// when cellref is '=SQRT(-1)'. So just try our best here.
// Similar results if string (literal yields VALUE, cellRef REF).
if (!is_numeric($index_number)) {
throw new Exception(ExcelError::throwError($index_number));
}
if ($index_number < 1) {
throw new Exception(ExcelError::VALUE()); throw new Exception(ExcelError::VALUE());
} }

View File

@ -68,8 +68,8 @@ class VLookup extends LookupBase
{ {
reset($a); reset($a);
$firstColumn = key($a); $firstColumn = key($a);
$aLower = StringHelper::strToLower($a[$firstColumn]); $aLower = StringHelper::strToLower((string) $a[$firstColumn]);
$bLower = StringHelper::strToLower($b[$firstColumn]); $bLower = StringHelper::strToLower((string) $b[$firstColumn]);
if ($aLower == $bLower) { if ($aLower == $bLower) {
return 0; return 0;
@ -84,7 +84,7 @@ class VLookup extends LookupBase
*/ */
private static function vLookupSearch($lookupValue, array $lookupArray, $column, bool $notExactMatch): ?int private static function vLookupSearch($lookupValue, array $lookupArray, $column, bool $notExactMatch): ?int
{ {
$lookupLower = StringHelper::strToLower($lookupValue); $lookupLower = StringHelper::strToLower((string) $lookupValue);
$rowNumber = null; $rowNumber = null;
foreach ($lookupArray as $rowKey => $rowData) { foreach ($lookupArray as $rowKey => $rowData) {

View File

@ -3,26 +3,42 @@
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\LookupRef; namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\LookupRef;
use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
use PhpOffice\PhpSpreadsheet\Calculation\Functions; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class VLookupTest extends TestCase class VLookupTest extends TestCase
{ {
protected function setUp(): void
{
Functions::setCompatibilityMode(Functions::COMPATIBILITY_EXCEL);
}
/** /**
* @dataProvider providerVLOOKUP * @dataProvider providerVLOOKUP
* *
* @param mixed $expectedResult * @param mixed $expectedResult
* @param mixed $value
* @param mixed $table
* @param mixed $index
*/ */
public function testVLOOKUP($expectedResult, ...$args): void public function testVLOOKUP($expectedResult, $value, $table, $index, ?bool $lookup = null): void
{ {
$result = LookupRef::VLOOKUP(...$args); $spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
if (is_array($table)) {
$sheet->fromArray($table);
$dimension = $sheet->calculateWorksheetDimension();
} else {
$sheet->getCell('A1')->setValue($table);
$dimension = 'A1';
}
if ($lookup === null) {
$lastarg = '';
} else {
$lastarg = $lookup ? ',TRUE' : ',FALSE';
}
$sheet->getCell('Z98')->setValue($value);
$sheet->getCell('Z97')->setValue($index);
$sheet->getCell('Z99')->setValue("=VLOOKUP(Z98,$dimension,Z97$lastarg)");
$result = $sheet->getCell('Z99')->getCalculatedValue();
self::assertEquals($expectedResult, $result); self::assertEquals($expectedResult, $result);
$spreadsheet->disconnectWorksheets();
} }
public function providerVLOOKUP(): array public function providerVLOOKUP(): array

View File

@ -186,4 +186,14 @@ return [
3, 3,
true, true,
], ],
'issue2934' => [
'Red',
102,
[
[null, 102],
[null, 'Red'],
],
2,
false,
],
]; ];

View File

@ -98,7 +98,7 @@ return [
['10y1', 7.0], ['10y1', 7.0],
['10y2', 10.0], ['10y2', 10.0],
], ],
'NaN', -5,
], ],
[ [
'#REF!', '#REF!',
@ -111,9 +111,9 @@ return [
'#REF!', '#REF!',
'10y2', '10y2',
[ [
2.0, [2.0],
7.0, [7.0],
10.0, [10.0],
], ],
2.0, 2.0,
], ],
@ -163,4 +163,34 @@ return [
3, 3,
null, null,
], ],
'issue2934' => [
'Red',
102,
[
[null, null],
[102, 'Red'],
],
2,
false,
],
'string supplied as index' => [
'#VALUE!',
102,
[
[null, null],
[102, 'Red'],
],
'xyz',
false,
],
'num error propagated' => [
'#NUM!',
102,
[
[null, null],
[102, 'Red'],
],
'=SQRT(-1)',
false,
],
]; ];