Fix #1453, which went stale in 2000, which I unstaled in 2024, and which I finally got to. The code, and several unit tests, was substantially generated by AI, my first foray into that frontier.

Trait `ArrayEnabled` is not able to handle this function because it has too many array parameters. I would have liked to update it to handle this, but that seemed too difficult. I have let the code do its own array handling; it looks a little kludgey but seems to do the job. I may return to this at some point.
This commit is contained in:
oleibman
2026-03-25 21:44:54 -07:00
parent a1dacfdf79
commit 93c5c51dd5
6 changed files with 423 additions and 4 deletions
+1 -1
View File
@@ -270,7 +270,7 @@ TRANSPOSE | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\Matri
UNIQUE | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\Unique::unique
VLOOKUP | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\VLookup::lookup
VSTACK | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\Vstack::vstack
XLOOKUP | **Not yet Implemented**
XLOOKUP | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\XLookup::lookup
XMATCH | **Not yet Implemented**
## CATEGORY_MATH_AND_TRIG
@@ -640,7 +640,7 @@ WRAPROWS | MATH_AND_TRIG | **Not yet Implemented**
Excel Function | Category | PhpSpreadsheet Function
-------------------------|-----------------------|--------------------------------------
XIRR | FINANCIAL | Financial\CashFlow\Variable\NonPeriodic::rate
XLOOKUP | LOOKUP_AND_REFERENCE | **Not yet Implemented**
XLOOKUP | LOOKUP_AND_REFERENCE | LookupRef\XLookup::lookup
XMATCH | LOOKUP_AND_REFERENCE | **Not yet Implemented**
XNPV | FINANCIAL | Financial\CashFlow\Variable\NonPeriodic::presentValue
XOR | LOGICAL | Logical\Operations::logicalXor
+1 -1
View File
@@ -636,7 +636,7 @@ WRAPROWS | CATEGORY_MATH_AND_TRIG | **Not yet Implemente
Excel Function | Category | PhpSpreadsheet Function
-------------------------|--------------------------------|--------------------------------------
XIRR | CATEGORY_FINANCIAL | \PhpOffice\PhpSpreadsheet\Calculation\Financial\CashFlow\Variable\NonPeriodic::rate
XLOOKUP | CATEGORY_LOOKUP_AND_REFERENCE | **Not yet Implemented**
XLOOKUP | CATEGORY_LOOKUP_AND_REFERENCE | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\XLookup::lookup
XMATCH | CATEGORY_LOOKUP_AND_REFERENCE | **Not yet Implemented**
XNPV | CATEGORY_FINANCIAL | \PhpOffice\PhpSpreadsheet\Calculation\Financial\CashFlow\Variable\NonPeriodic::presentValue
XOR | CATEGORY_LOGICAL | \PhpOffice\PhpSpreadsheet\Calculation\Logical\Operations::logicalXor
@@ -2628,7 +2628,7 @@ class FunctionArray extends CalculationBase
],
'XLOOKUP' => [
'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [LookupRef\XLookup::class, 'lookup'],
'argumentCount' => '3-6',
],
'XNPV' => [
@@ -0,0 +1,275 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use UnhandledMatchError;
class XLookup extends LookupBase
{
//use ArrayEnabled; // not yet supported
/**
* XLOOKUP PHP emulation of Excel's XLOOKUP function.
*
* @param mixed $lookupValue Value to search for
* @param mixed $lookupArray Expect array, Array to search in
* @param mixed $returnArray Expect array, Array to return from (must match lookupArray size)
* @param mixed $ifNotFound Value to return if no match found (default: #N/A!)
* @param mixed $matchMode expect int 0 = exact match (default)
* -1 = exact or next smaller
* 1 = exact or next larger
* 2 = wildcard match (* ? ~)
* @param mixed $searchMode expect int 1 = first to last (default)
* -1 = last to first
* 2 = binary search ascending
* -2 = binary search descending
*/
public static function lookup(
mixed $lookupValue,
mixed $lookupArray,
mixed $returnArray,
mixed $ifNotFound = '#N/A!',
mixed $matchMode = 0,
mixed $searchMode = 1
): mixed {
if (is_array($lookupValue)) {
$lookupValue = Functions::flattenArray($lookupValue);
if (count($lookupValue) === 1) {
$lookupValue = reset($lookupValue);
}
}
if (is_array($lookupValue)) {
$result = [];
foreach ($lookupValue as $value) {
$result[] = self::lookup($value, $lookupArray, $returnArray, $ifNotFound, $matchMode, $searchMode);
}
return $result;
}
if (!is_array($lookupArray)) {
$lookupArray = [$lookupArray];
}
if (!is_array($returnArray)) {
$returnArray = [$returnArray];
}
$lookupArray = Functions::flattenArray($lookupArray);
if (count($returnArray) === 1) {
$returnArray = Functions::flattenArray($returnArray);
} else {
$oldArray = $returnArray;
$returnArray = [];
foreach ($oldArray as $row) {
$newRow = Functions::flattenArray($row);
if (count($newRow) === 1) {
$newRow = reset($newRow);
}
$returnArray[] = $newRow;
}
}
/*if (is_array($lookupValue)) { // not yet supported by ArrayEnabled
return self::evaluateArrayArgumentsIgnore([self::class, __FUNCTION__], 1, $lookupValue, $lookupArray, $returnArray, $ifNotFound, $matchMode, $searchMode);
}*/
try {
self::validateLookupArray($lookupArray);
self::validateLookupArray($returnArray);
$matchMode = LookupRefValidations::validateInt($matchMode);
$searchMode = LookupRefValidations::validateInt($searchMode);
} catch (Exception $e) {
return $e->getMessage();
}
if (!in_array($matchMode, [0, -1, 1, 2], true)) {
return ExcelError::VALUE();
}
if (count($lookupArray) !== count($returnArray)) {
return ExcelError::VALUE();
}
try {
$index = match ($searchMode) {
1 => self::searchLinear($lookupValue, $lookupArray, $matchMode, false),
-1 => self::searchLinear($lookupValue, $lookupArray, $matchMode, true),
2 => self::searchBinary($lookupValue, $lookupArray, $matchMode, true),
-2 => self::searchBinary($lookupValue, $lookupArray, $matchMode, false),
};
} catch (UnhandledMatchError) {
return ExcelError::VALUE();
}
return ($index === null) ? $ifNotFound : $returnArray[$index];
}
// ---------------------------------------------------------------------------
// Search strategies
// ---------------------------------------------------------------------------
/**
* Linear search (searchMode 1 and -1).
*
* @param mixed[] $lookupArray
*/
private static function searchLinear(
mixed $lookupValue,
array $lookupArray,
int $matchMode,
bool $reverse
): ?int {
$keys = array_keys($lookupArray);
if ($reverse) {
$keys = array_reverse($keys);
}
$bestIdx = null;
$bestVal = null;
foreach ($keys as $i) {
/** @var scalar */
$candidate = $lookupArray[$i];
if ($matchMode === 2) {
// Wildcard: convert Excel wildcards to PHP regex
/** @var scalar $lookupValue */
if (self::wildcardMatch((string) $lookupValue, (string) $candidate)) {
return $i;
}
continue;
}
$cmp = self::compareValues($candidate, $lookupValue);
if ($cmp === 0) {
return $i; // Exact match — return immediately
}
if ($matchMode === -1 && $cmp < 0) {
// Next smaller: track largest value still below lookupValue
if ($bestVal === null || self::compareValues($candidate, $bestVal) > 0) {
$bestVal = $candidate;
$bestIdx = $i;
}
}
if ($matchMode === 1 && $cmp > 0) {
// Next larger: track smallest value still above lookupValue
if ($bestVal === null || self::compareValues($candidate, $bestVal) < 0) {
$bestVal = $candidate;
$bestIdx = $i;
}
}
}
/** @var ?int $bestIdx */
return $bestIdx;
}
/**
* Binary search (searchMode 2 and -2)
* Assumes array is sorted ascending (searchMode 2) or descending (searchMode -2).
*
* @param mixed[] $lookupArray
*/
private static function searchBinary(
mixed $lookupValue,
array $lookupArray,
int $matchMode,
bool $ascending
): ?int {
$values = array_values($lookupArray);
$keys = array_keys($lookupArray);
$lo = 0;
$hi = count($values) - 1;
$bestIdx = null;
while ($lo <= $hi) {
$mid = intdiv($lo + $hi, 2);
$cmp = self::compareValues($values[$mid], $lookupValue);
if (!$ascending) {
$cmp = -$cmp; // Flip for descending
}
if ($cmp === 0) {
return $keys[$mid]; // Exact match
}
if ($cmp < 0) {
if ($matchMode === -1) {
$bestIdx = $keys[$mid]; // Candidate for next smaller
}
$lo = $mid + 1;
} else {
if ($matchMode === 1) {
$bestIdx = $keys[$mid]; // Candidate for next larger
}
$hi = $mid - 1;
}
}
/** @var int $bestIdx */
return ($matchMode !== 0) ? $bestIdx : null;
}
/**
* Compare two values with type coercion matching Excel's behaviour:
* numbers < strings < booleans
*/
private static function compareValues(mixed $a, mixed $b): int
{
// Numeric comparison
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
// String comparison (case-insensitive, like Excel)
if (is_string($a) && is_string($b)) {
return strcasecmp($a, $b);
}
// Bool comparison
if (is_bool($a) && is_bool($b)) {
return $a <=> $b;
}
// Cross-type: number < string < bool
$typeOrder = fn ($v) => match (true) {
is_numeric($v) => 0,
is_string($v) => 1,
is_bool($v) => 2,
default => 3,
};
return $typeOrder($a) <=> $typeOrder($b);
}
/**
* Wildcard match (matchMode 2)
* Supports Excel wildcards: * (any sequence), ? (any single char), ~ (escape).
*/
private static function wildcardMatch(string $pattern, string $subject): bool
{
// Handle ~* and ~? escapes first
$regex = '';
$len = strlen($pattern);
for ($i = 0; $i < $len; ++$i) {
$ch = $pattern[$i];
if ($ch === '~' && $i + 1 < $len) {
$next = $pattern[++$i];
$regex .= preg_quote($next, '/');
} elseif ($ch === '*') {
$regex .= '.*';
} elseif ($ch === '?') {
$regex .= '.';
} else {
$regex .= preg_quote($ch, '/');
}
}
return (bool) preg_match('/^' . $regex . '$/i', $subject);
}
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\LookupRef;
use PhpOffice\PhpSpreadsheet\NamedRange;
use PHPUnit\Framework\Attributes\DataProvider;
class XLookupTest extends AllSetupTeardown
{
#[DataProvider('providerXLOOKUP')]
public function testXLOOKUP(mixed $expectedResult, string $formula): void
{
$sheet = $this->getSheet();
$sheet->fromArray([
['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'],
[1.20, 0.50, 3.00, 2.50, 5.00],
['A01', 'B02', 'C03', 'D04', 'E05'],
[100, 250, 30, 80, 15],
]);
$sheet->fromArray([
['Alice', 'Bob', 'Alice', 'Carol'],
[88, 72, 95, 81],
], null, 'H1');
$this->getSpreadsheet()->addNamedRange(new NamedRange('names1', $sheet, '$H$1:$K$1'));
$this->getSpreadsheet()->addNamedRange(new NamedRange('values1', $sheet, '$H$2:$K$2'));
$sheet->fromArray([
[0, 10000, 50000, 100000, 500000],
[0.00, 0.10, 0.20, 0.30, 0.40],
], null, 'H10');
$sheet->fromArray([
[6, 7, 8, 9, 10],
['XS', 'S', 'M', 'L', 'XL'],
], null, 'A14');
$sheet->fromArray([
[101, 204, 317, 489, 562, 741, 890],
['Grace', 'Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank'],
], null, 'H15');
$sheet->fromArray([
[9101, 8204, 7317, 6489, 5562, 4741, 3890],
['Grace', 'Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank'],
], null, 'H25');
$sheet->getCell('Z98')->setValue('Cherry');
$sheet->getCell('Z99')->setValue($formula);
$result = $sheet->getCell('Z99')->getCalculatedValue();
self::assertSame($expectedResult, $result);
}
public static function providerXLOOKUP(): array
{
return [
'simple lookup' => [3.0, '=XLOOKUP("Cherry", A1:E1, A2:E2)'],
'simple lookup cell' => [3.0, '=XLOOKUP(Z98, A1:E1, A2:E2)'],
'not found with string' => ['Not found', '=XLOOKUP("Fig", A1:E1, A2:E2, "Not found")'],
'not found with default string' => ['#N/A!', '=XLOOKUP("Fig", A1:E1, A2:E2)'],
'Wildcard match (case-insensitive)' => ['E05', '=XLOOKUP("EL*", A1:E1, A3:E3, "Not found", 2)'],
'Wildcard with ?' => [80, '=XLOOKUP("?ate", A1:E1, A4:E4, "Not found", 2)'],
'Array sizes do not match' => ['#VALUE!', '=XLOOKUP("Apple", A1:E1, A2:D2)'],
'Invalid match mode' => ['#VALUE!', '=XLOOKUP("Apple", A1:E1, A2:E2, "Not found", 4)'],
'Invalid search mode' => ['#VALUE!', '=XLOOKUP("Apple", A1:E1, A2:E2, "Not found", 0, 5)'],
'Arrays supplied as scalar' => [1.2, '=XLOOKUP("Apple", "Apple", A2)'],
'Arrays supplied as scalar non-match' => ['#N/A!', '=XLOOKUP("Apple", "Banana", A2)'],
'Reverse search with defined names' => [95, '=XLOOKUP("Alice", names1, values1, "Not found", 0, -1)'],
'Next smaller approximate match' => [0.2, '=XLOOKUP(75000, H10:L10, H11:L11, "Not found", -1)'],
'Next larger approximate match' => ['L', '=XLOOKUP(8.5, A14:E14, A15:E15, "Not found", 1)'],
'Binary search' => ['Carol', '=XLOOKUP(489, H15:N15, H16:N16, "Not found", 0, 2)'],
'Binary search fail' => ['Not found', '=XLOOKUP(490, H15:N15, H16:N16, "Not found", 0, 2)'],
'Descending Binary search' => ['Bob', '=XLOOKUP(7317, H25:N25, H26:N26, "Not found", 0, -2)'],
];
}
public function testHorizontal(): void
{
$sheet = $this->getSheet();
$sheet->fromArray([
['Product', 'Laptop', 'Headphone', 'Watch', 'TV'],
['ID', 1, 2, 3, 4],
['Price', 15.00, 25.00, 30.00, 20.00],
], null, 'A2');
$sheet->setCellValue('E8', 'Headphone');
$sheet->setCellValue('E9', '=XLOOKUP(E8, B2:E2, B4:E4)');
self::assertSame(25.0, $sheet->getCell('E9')->getCalculatedValue());
}
public function testBackwardsDirection(): void
{
$sheet = $this->getSheet();
$sheet->fromArray([
['Grades', 'Student Name'],
[85, 'Drake'],
[56, 'Robin'],
[95, 'Raven'],
[84, 'Sam'],
[91, 'Dale'],
[81, 'John'],
]);
$sheet->setCellValue('D5', 'Robin');
$sheet->setCellValue('D6', '=XLOOKUP(D5, B2:B7, A2:A7)');
self::assertSame(56, $sheet->getCell('D6')->getCalculatedValue());
}
public function testArray(): void
{
$this->getSpreadsheet()->returnArrayAsArray();
$sheet = $this->getSheet();
$sheet->fromArray([
['Red', 4.14],
['Orange', 4.19],
['Yellow', 5.17],
['Green', 5.77],
['Blue', 6.39],
], null, 'K21');
$sheet->getCell('A23')
->setValue('=XLOOKUP({"Red","Orange","Green"}, K21:K25, L21:L25)');
self::assertSame([4.14, 4.19, 5.77], $sheet->getCell('A23')->getCalculatedValue());
}
public function testMultiple(): void
{
$this->getSpreadsheet()->returnArrayAsArray();
$sheet = $this->getSheet();
$sheet->fromArray([
['ID', 'Name', 'Department', 'Salary'],
[1001, 'Darwin', 'HR', 89000],
[1002, 'Sam', 'IT', 74000],
[1003, 'Robin', 'Finance', 59000],
[1004, 'Raven', 'Marketing', 44000],
[1005, 'Johnny', 'IT', 29000],
[1006, 'Tom', 'Marketing', 14000],
[1007, 'Taylor', 'Finance', 84000],
[1008, 'Frank', 'HR', 96000],
[1009, 'George', 'Sales', 21000],
]);
$sheet->getCell('F5')->setValue(1004);
$sheet->getCell('F6')
->setValue('=XLOOKUP(F5, A2:A10, B2:D10)');
self::assertSame(
['Raven', 'Marketing', 44000],
$sheet->getCell('F6')->getCalculatedValue()
);
}
}