diff --git a/CHANGELOG.md b/CHANGELOG.md index 683e1da14..8d3204cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) +### Fixed + +- Fixed approximate `MATCH`, `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` incorrectly returning `#N/A` (or a wrong position) when the search range contained empty cells. [#1697](https://github.com/handsontable/hyperformula/pull/1697) + ## [3.3.0] - 2026-05-20 ### Added diff --git a/src/Lookup/AdvancedFind.ts b/src/Lookup/AdvancedFind.ts index 0c5f0d73b..5c5943cde 100644 --- a/src/Lookup/AdvancedFind.ts +++ b/src/Lookup/AdvancedFind.ts @@ -5,6 +5,7 @@ import {DependencyGraph} from '../DependencyGraph' import { + EmptyValue, getRawValue, InternalScalarValue, RawInterpreterValue, @@ -61,6 +62,13 @@ export abstract class AdvancedFind { ) } + /** + * Linear search over an in-memory array for the value equal to `searchKey`, or — when `ifNoMatch` + * is `returnLowerBound`/`returnUpperBound` — the closest non-exceeding/non-preceding value. + * Genuinely empty cells (`EmptyValue`) are skipped, consistent with `findLastOccurrenceInOrderedRange` + * and with Excel/Google Sheets, which ignore empty cells (but not empty strings) in approximate search. + * Returns the 0-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches. + */ protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' = 'returnNotFound', returnOccurrence: 'first' | 'last' = 'first'): number { const normalizedArray = searchArray .map(getRawValue) @@ -88,6 +96,13 @@ export abstract class AdvancedFind { return i } + // Skip empty cells in the approximate search, consistent with findLastOccurrenceInOrderedRange: + // Excel/Google Sheets ignore genuinely empty cells (but not empty strings) when looking for the + // lower/upper bound. EmptyValue would otherwise be ranked below every value by compare(). + if (value === EmptyValue) { + continue + } + if (compareFn(value, searchKey) > 0) { continue } diff --git a/src/interpreter/binarySearch.ts b/src/interpreter/binarySearch.ts index 2823011a4..bca91b8d6 100644 --- a/src/interpreter/binarySearch.ts +++ b/src/interpreter/binarySearch.ts @@ -20,6 +20,12 @@ const NOT_FOUND = -1 * * If the search range contains duplicates, returns the last matching value. If no value found in the range satisfies the above, returns -1. * + * Empty cells (EmptyValue) are skipped: they are not treated as ordered values during the + * approximate search. This mirrors Excel/Google Sheets, where MATCH/VLOOKUP/HLOOKUP/XLOOKUP + * ignore genuinely empty cells (but not empty strings) when looking for the lower/upper bound. + * The returned offset is always relative to the original range, so empty cells keep their slots + * and the position of the matched non-empty cell is reported unchanged. + * * Note: this function does not normalize input strings. */ export function findLastOccurrenceInOrderedRange( @@ -39,8 +45,42 @@ export function findLastOccurrenceInOrderedRange( ? (left: RawNoErrorScalarValue, right: RawInterpreterValue) => compare(left, right) : (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right) - const foundIndex = findLastMatchingIndex(index => compareFn(searchKey, getValueFromIndexFn(index)) >= 0, start, end) - const foundValue = getValueFromIndexFn(foundIndex) + // Exact-match mode (returnNotFound) does not skip empty cells: HF-223 changes only the approximate + // (bound) lookups, and an exact search must neither match a blank nor be redirected by one. Run the + // binary search directly over the original range — this keeps the mode O(log n) and preserves its + // pre-HF-223 behaviour: an exact hit is returned, everything else is NOT_FOUND. (The empty-skipping + // compaction below would otherwise let an exact search land on, and report, a non-empty cell that + // sits past an interspersed blank — silently changing exact-match results.) + if (ifNoMatch === 'returnNotFound') { + const exactIndex = findLastMatchingIndex(index => compareFn(searchKey, getValueFromIndexFn(index)) >= 0, start, end) + return exactIndex !== NOT_FOUND && getValueFromIndexFn(exactIndex) === searchKey ? exactIndex - start : NOT_FOUND + } + + // Collect the original indices of the non-empty cells, preserving their order. Empty cells break + // the sort invariant binary search relies on (compare() ranks EmptyValue below every other value), + // so the bound search runs over the compacted, empty-free index list and the result is mapped back + // to the original index space afterwards. + // + // This pre-scan is O(n) over the range, which trades away the binary search's O(log n) guarantee. + // It is required for correctness in the bound modes: with empty cells interspersed the search + // predicate is no longer monotonic, so the binary search cannot run directly on the original range. + const nonEmptyIndices: number[] = [] + for (let index = start; index <= end; index++) { + if (getValueFromIndexFn(index) !== EmptyValue) { + nonEmptyIndices.push(index) + } + } + + // With no non-empty cells there is nothing to match against. Return early so the + // ifNoMatch branches below (which treat NOT_FOUND as "key past the edge of a non-empty + // list" and may return offset 0) are not reached for an all-empty range. + if (nonEmptyIndices.length === 0) { + return NOT_FOUND + } + + const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(nonEmptyIndices[compactedIndex])) >= 0, 0, nonEmptyIndices.length - 1) + const foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : nonEmptyIndices[foundCompactedIndex] + const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex) if (foundValue === searchKey) { return foundIndex - start @@ -61,8 +101,8 @@ export function findLastOccurrenceInOrderedRange( } // orderingDirection === 'desc' - const nextIndex = foundIndex+1 - return nextIndex <= end ? nextIndex - start : NOT_FOUND + const nextIndex = nonEmptyIndices[foundCompactedIndex + 1] + return nextIndex !== undefined ? nextIndex - start : NOT_FOUND } if (ifNoMatch === 'returnUpperBound') { @@ -80,8 +120,8 @@ export function findLastOccurrenceInOrderedRange( } // orderingDirection === 'asc' - const nextIndex = foundIndex+1 - return nextIndex <= end ? nextIndex - start : NOT_FOUND + const nextIndex = nonEmptyIndices[foundCompactedIndex + 1] + return nextIndex !== undefined ? nextIndex - start : NOT_FOUND } // ifNoMatch === 'returnNotFound'