diff --git a/CHANGELOG.md b/CHANGELOG.md index 36100532d..94424f8a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ 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) - Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) +### Fixed + +- Fixed the behavior of `MATCH`, `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` functions when the search range contained empty cells. [#1697](https://github.com/handsontable/hyperformula/pull/1697) +- Fixed the `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` functions to return `0` instead of an empty value when the matched cell in the result range is empty. [#1697](https://github.com/handsontable/hyperformula/pull/1697) + ## [3.3.0] - 2026-05-20 ### Added diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index aa0e684fe..9f02316a5 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -41,6 +41,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | TIMEVALUE function | =TIMEVALUE("14:31") | Type of the returned value: `CellValueDetailedType.NUMBER_TIME` (compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard) | Cell auto-formatted as **regular number** | Cell auto-formatted as **regular number** | | EDATE function | =EDATE(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** | | EOMONTH function | =EOMONTH(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** | +| Empty cells in lookup search | =XLOOKUP(3, A1:A4, A1:A4, "NF", 0, 2)
where A1:A4 = 1, 2, (empty), 3 | Empty cells are skipped during the search. A value that is present is found even when it sits past an interspersed empty cell (exact match is gap-independent), and approximate `MATCH`/`VLOOKUP`/`HLOOKUP`/`XLOOKUP` skip empty cells — but not empty strings — when finding the lower/upper bound. Returns `3`. On an all-empty range in a binary search mode, HyperFormula returns the `if_not_found` result (never row 1). | Skips empty cells in approximate search (parity with HyperFormula). | With binary search modes (`search_mode` ±2), a range with interspersed empty cells is not strictly sorted; per Excel's documentation the result may be invalid, so a value past an empty cell is not reliably found. On an all-empty range in a binary mode, Excel returns the first row's value. | ## Built-in functions 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..01e7b0735 100644 --- a/src/interpreter/binarySearch.ts +++ b/src/interpreter/binarySearch.ts @@ -18,7 +18,24 @@ const NOT_FOUND = -1 * - orderingDirection - must be set to either 'asc' or 'desc' to indicate the ordering direction for the search range, * - ifNoMatch - must be set to 'returnLowerBound', 'returnUpperBound' or 'returnNotFound' * - * If the search range contains duplicates, returns the last matching value. If no value found in the range satisfies the above, returns -1. + * If the search range contains duplicates, returns the last matching value, with one caveat: in the + * 'returnNotFound' mode, when the range contains both duplicates of the searchKey and interspersed + * empty cells, the search may report any of the duplicated positions (Excel's binary-search modes + * likewise leave this unspecified). + * + * 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 Google Sheets and Excel's linear approximate lookups, which + * ignore genuinely empty cells (but not empty strings) when looking for the lower/upper bound; + * for Excel's explicit binary search modes (XLOOKUP search_mode ±2) the result on a range with + * interspersed empty cells is unspecified — see docs/guide/list-of-differences.md. + * 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. + * + * Complexity: O(log n) as long as the binary-search descent probes no empty cell — in particular + * for ranges without empty cells. Only when the descent touches an empty cell does the search fall + * back to an O(n) scan that compacts the non-empty indices and re-runs the binary search over them. * * Note: this function does not normalize input strings. */ @@ -39,8 +56,66 @@ 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) + /* + * Returns the original index of the first non-empty cell at or after fromIndex, or undefined if + * all the remaining cells are empty. Costs O(gap length), which the search pays only when it + * actually needs to step over empty slots. + */ + const findNextNonEmptyIndex = (fromIndex: number): number | undefined => { + for (let index = fromIndex; index <= end; index++) { + if (getValueFromIndexFn(index) !== EmptyValue) { + return index + } + } + return undefined + } + + // Fast path: binary search directly over the original range, tracking whether the descent ever + // probed an empty cell. Within the sorted-input contract, empty cells are the only source of + // non-monotonicity in the search predicate: compare() ranks EmptyValue below every non-empty + // value, and genuinely empty cells surface as the EmptyValue sentinel — empty strings and 0 do + // not. (Error values also break the ordering, but a range containing errors is outside the + // contract, and the compaction fallback keeps them too.) In a descent that never probes an empty + // cell every probed pivot is a correctly-ordered non-empty value, so each discarded half is + // justified by the monotonicity of the non-empty values, and the landing index — which is always + // probed — is the same one the compacted search below would return: the result can be trusted + // as-is. This keeps the search O(log n) unless an empty cell actually interferes. + let probedEmptyCell = false + const directIndex = findLastMatchingIndex(index => { + const value = getValueFromIndexFn(index) + if (value === EmptyValue) { + probedEmptyCell = true + } + return compareFn(searchKey, value) >= 0 + }, start, end) + + let foundIndex: number + + if (!probedEmptyCell) { + foundIndex = directIndex + } else if (ifNoMatch === 'returnNotFound' && directIndex !== NOT_FOUND && getValueFromIndexFn(directIndex) === searchKey) { + // Exact-match mode: a misdirected descent cannot produce a false positive, because the landing + // value is re-checked for equality here. Accept the hit and skip the O(n) fallback. + return directIndex - start + } else { + // The descent probed an empty cell, so its result cannot be trusted (HF-223): collect the + // original indices of the non-empty cells (O(n)) and re-run the binary search over the + // compacted, empty-free index list. The result maps back to the original index space, so empty + // cells keep their slots and the matched non-empty cell's original position is reported + // unchanged. On an all-empty range the compacted list has no elements and the search reports + // NOT_FOUND, which the ifNoMatch branches below preserve. + const nonEmptyIndices: number[] = [] + for (let index = start; index <= end; index++) { + if (getValueFromIndexFn(index) !== EmptyValue) { + nonEmptyIndices.push(index) + } + } + + const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(nonEmptyIndices[compactedIndex])) >= 0, 0, nonEmptyIndices.length - 1) + foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : nonEmptyIndices[foundCompactedIndex] + } + + const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex) if (foundValue === searchKey) { return foundIndex - start @@ -48,7 +123,15 @@ export function findLastOccurrenceInOrderedRange( if (ifNoMatch === 'returnLowerBound') { if (foundIndex === NOT_FOUND) { - return orderingDirection === 'asc' ? NOT_FOUND : 0 + if (orderingDirection === 'asc') { + return NOT_FOUND + } + + // orderingDirection === 'desc': the key exceeds every value in the range, so the lower bound + // is the first (largest) non-empty value — never an empty leading cell, and NOT_FOUND on an + // all-empty range. + const firstNonEmptyIndex = findNextNonEmptyIndex(start) + return firstNonEmptyIndex !== undefined ? firstNonEmptyIndex - start : NOT_FOUND } if (typeof foundValue !== typeof searchKey) { @@ -60,14 +143,23 @@ export function findLastOccurrenceInOrderedRange( return foundIndex - start } - // orderingDirection === 'desc' - const nextIndex = foundIndex+1 - return nextIndex <= end ? nextIndex - start : NOT_FOUND + // orderingDirection === 'desc': step to the next non-empty cell, so skipped empty slots never + // shift the reported position. + const nextIndex = findNextNonEmptyIndex(foundIndex + 1) + return nextIndex !== undefined ? nextIndex - start : NOT_FOUND } if (ifNoMatch === 'returnUpperBound') { if (foundIndex === NOT_FOUND) { - return orderingDirection === 'asc' ? 0 : NOT_FOUND + if (orderingDirection === 'desc') { + return NOT_FOUND + } + + // orderingDirection === 'asc': the key precedes every value in the range, so the upper bound + // is the first (smallest) non-empty value — never an empty leading cell, and NOT_FOUND on an + // all-empty range. + const firstNonEmptyIndex = findNextNonEmptyIndex(start) + return firstNonEmptyIndex !== undefined ? firstNonEmptyIndex - start : NOT_FOUND } if (typeof foundValue !== typeof searchKey) { @@ -79,9 +171,10 @@ export function findLastOccurrenceInOrderedRange( return foundIndex - start } - // orderingDirection === 'asc' - const nextIndex = foundIndex+1 - return nextIndex <= end ? nextIndex - start : NOT_FOUND + // orderingDirection === 'asc': step to the next non-empty cell, so skipped empty slots never + // shift the reported position. + const nextIndex = findNextNonEmptyIndex(foundIndex + 1) + return nextIndex !== undefined ? nextIndex - start : NOT_FOUND } // ifNoMatch === 'returnNotFound' diff --git a/src/interpreter/plugin/LookupPlugin.ts b/src/interpreter/plugin/LookupPlugin.ts index 00f942ddf..625a68735 100644 --- a/src/interpreter/plugin/LookupPlugin.ts +++ b/src/interpreter/plugin/LookupPlugin.ts @@ -12,7 +12,7 @@ import { ProcedureAst } from '../../parser' import { StatType } from '../../statistics' import { zeroIfEmpty } from '../ArithmeticHelper' import { InterpreterState } from '../InterpreterState' -import { InternalScalarValue, InterpreterValue, RawNoErrorScalarValue } from '../InterpreterValue' +import { EmptyValue, InternalScalarValue, InterpreterValue, RawNoErrorScalarValue } from '../InterpreterValue' import { SimpleRangeValue } from '../../SimpleRangeValue' import { FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions } from './FunctionPlugin' import { ArraySize } from '../../ArraySize' @@ -237,7 +237,7 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech if (value instanceof SimpleRangeValue) { return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } - return value + return this.zeroIfEmptyResult(value) } private doHlookup(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, index: number, searchOptions: SearchOptions): InternalScalarValue { @@ -265,7 +265,18 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech if (value instanceof SimpleRangeValue) { return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } - return value + return this.zeroIfEmptyResult(value) + } + + /** + * Excel returns 0 (not blank) for a matched cell that is empty — a reference to an empty cell coerces + * to 0 in Excel. Mirror that on the lookup RETURN value so VLOOKUP/HLOOKUP/XLOOKUP match Excel. + * + * Same rule as `ArithmeticHelper.zeroIfEmpty`, kept as a separate method because the lookup RETURN + * value is an `InternalScalarValue` (wider than that helper's `RawNoErrorScalarValue` parameter). + */ + private zeroIfEmptyResult(value: InternalScalarValue): InternalScalarValue { + return value === EmptyValue ? 0 : value } private doXlookup(key: RawNoErrorScalarValue, lookupRange: SimpleRangeValue, returnRange: SimpleRangeValue, notFoundFlag: any, isWildcardMatchMode: boolean, searchOptions: SearchOptions): InterpreterValue { @@ -284,7 +295,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech } const returnValues: InternalScalarValue[][] = isVerticalSearch ? [returnRange.data[indexFound]] : returnRange.data.map((row) => [row[indexFound]]) - return SimpleRangeValue.onlyValues(returnValues) + const coerced = returnValues.map((row) => row.map((value) => this.zeroIfEmptyResult(value))) + return SimpleRangeValue.onlyValues(coerced) } private doMatch(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, type: number): InternalScalarValue {