mirror of
https://github.com/PHPOffice/PhpSpreadsheet.git
synced 2026-08-17 14:44:43 +00:00
XLOOKUP
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user