From 93c5c51dd5394e9f69f1dab52f6cd4b5adcea20f Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:44:54 -0700 Subject: [PATCH] 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. --- docs/references/function-list-by-category.md | 2 +- .../function-list-by-name-compact.md | 2 +- docs/references/function-list-by-name.md | 2 +- .../Calculation/FunctionArray.php | 2 +- .../Calculation/LookupRef/XLookup.php | 275 ++++++++++++++++++ .../Functions/LookupRef/XLookupTest.php | 144 +++++++++ 6 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 src/PhpSpreadsheet/Calculation/LookupRef/XLookup.php create mode 100644 tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/XLookupTest.php diff --git a/docs/references/function-list-by-category.md b/docs/references/function-list-by-category.md index 6e1eed10e..1066a37f8 100644 --- a/docs/references/function-list-by-category.md +++ b/docs/references/function-list-by-category.md @@ -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 diff --git a/docs/references/function-list-by-name-compact.md b/docs/references/function-list-by-name-compact.md index 38d2bc59f..ebd5f867e 100644 --- a/docs/references/function-list-by-name-compact.md +++ b/docs/references/function-list-by-name-compact.md @@ -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 diff --git a/docs/references/function-list-by-name.md b/docs/references/function-list-by-name.md index f7a393ed9..81aca800a 100644 --- a/docs/references/function-list-by-name.md +++ b/docs/references/function-list-by-name.md @@ -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 diff --git a/src/PhpSpreadsheet/Calculation/FunctionArray.php b/src/PhpSpreadsheet/Calculation/FunctionArray.php index 22273cdc7..300efc000 100644 --- a/src/PhpSpreadsheet/Calculation/FunctionArray.php +++ b/src/PhpSpreadsheet/Calculation/FunctionArray.php @@ -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' => [ diff --git a/src/PhpSpreadsheet/Calculation/LookupRef/XLookup.php b/src/PhpSpreadsheet/Calculation/LookupRef/XLookup.php new file mode 100644 index 000000000..8e074a323 --- /dev/null +++ b/src/PhpSpreadsheet/Calculation/LookupRef/XLookup.php @@ -0,0 +1,275 @@ +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); + } +} diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/XLookupTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/XLookupTest.php new file mode 100644 index 000000000..d96281509 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/XLookupTest.php @@ -0,0 +1,144 @@ +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() + ); + } +}