diff --git a/CHANGELOG.md b/CHANGELOG.md index 323c746b2d..da7992e901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - Fixed the `EDATE` function returning the 28th of February instead of the 29th when the shifted date falls in February of a leap year (e.g. `=EDATE(DATE(2028, 1, 31), 1)` now returns `2028-02-29` instead of `2028-02-28`). [#1781](https://github.com/handsontable/hyperformula/pull/1781) +- Fixed approximate `XLOOKUP` ignoring values of a different type in some search modes, and approximate `MATCH`, `VLOOKUP`, and `HLOOKUP` taking such values into account for non-range arrays, single-cell ranges, and binary search over sorted cell ranges; both now behave the same in linear and binary search, consistent with Microsoft Excel. [#1755](https://github.com/handsontable/hyperformula/pull/1755) ## [3.5.0] - 2026-10-09 diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 660fda0f98..02bf1addd4 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -53,6 +53,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | 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** | | EDATE and EOMONTH results in February 1900 | =EDATE(DATE(1900, 1, 31), 1)

=EOMONTH(DATE(1900, 1, 31), 1) | With the [`leapYear1900`](/api/interfaces/configparams.md#leapyear1900) option set to `true`, February 1900 has 29 days, so both examples return 29 February 1900 (serial `60` with `nullDate` set to 1899-12-31). With the default `false`, both return 28 February 1900. | 28 February 1900 for both examples. Google Sheets does not treat 1900 as a leap year. | 28 February 1900 (serial `59`) for both examples. Excel keeps the 1900 leap day only in the serial-to-date mapping (`DAY(60)` is `29`); its month arithmetic treats February 1900 as having 28 days, so no `EDATE` or `EOMONTH` result is ever serial `60`. | | 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. | +| Approximate lookup matching across value types | =XLOOKUP("a", A1:A2, B1:B2, "NF", -1, 1)
where A1:A2 = 10, 20 and B1:B2 = "x", "y" | Approximate `MATCH`, `VLOOKUP`, and `HLOOKUP` skip candidates whose type differs from the lookup value, in both linear and binary search. Approximate `XLOOKUP` follows Microsoft Excel's ordering of numbers, text, logical values, and errors; empty cells are skipped (see *Empty cells in lookup search*). Returns `"y"` for the example. | Approximate lookups ignore candidates whose type differs from the lookup value. Returns `"NF"` for the example. | Same as HyperFormula, except that linear approximate `XLOOKUP` ranks an empty cell above every value: `=XLOOKUP("z", A1:A4, B1:B4, "NF", 1, 1)` where A1:A4 = 10, (empty), 20, "b" returns B2. | ## Built-in functions diff --git a/hyperformula/src/Lookup/AdvancedFind.ts b/hyperformula/src/Lookup/AdvancedFind.ts index 5c5943cde5..35dad717e0 100644 --- a/hyperformula/src/Lookup/AdvancedFind.ts +++ b/hyperformula/src/Lookup/AdvancedFind.ts @@ -12,7 +12,7 @@ import { RawNoErrorScalarValue } from '../interpreter/InterpreterValue' import {SimpleRangeValue} from '../SimpleRangeValue' -import {AdvancedFindOptions, SearchOptions} from './SearchStrategy' +import {AdvancedFindOptions, ApproximateMatchPolicy, SearchOptions} from './SearchStrategy' import {forceNormalizeString} from '../interpreter/ArithmeticHelper' import {compare, findLastOccurrenceInOrderedRange} from '../interpreter/binarySearch' @@ -29,7 +29,7 @@ export abstract class AdvancedFind { const values: InternalScalarValue[] = (range === undefined) ? rangeValue.valuesFromTopLeftCorner() : this.dependencyGraph.computeListOfValuesInRange(range) - + const initialIterationIndex = returnOccurrence === 'first' ? 0 : values.length-1 const iterationCondition = returnOccurrence === 'first' ? (i: number) => i < values.length : (i: number) => i >= 0 const incrementIndex = returnOccurrence === 'first' ? (i: number) => i+1 : (i: number) => i-1 @@ -42,22 +42,22 @@ export abstract class AdvancedFind { return NOT_FOUND } - protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, returnOccurrence }: SearchOptions): number { + protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, approximateMatchPolicy = 'sameType', returnOccurrence }: SearchOptions): number { const normalizedSearchKey = typeof searchKey === 'string' ? forceNormalizeString(searchKey) : searchKey const range = rangeValue.range if (range === undefined) { - return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner(), ifNoMatch, returnOccurrence) + return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner(), ifNoMatch, approximateMatchPolicy, returnOccurrence) } if (ordering === 'none') { - return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range), ifNoMatch, returnOccurrence) + return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range), ifNoMatch, approximateMatchPolicy, returnOccurrence) } return findLastOccurrenceInOrderedRange( normalizedSearchKey, range, - { searchCoordinate, orderingDirection: ordering, ifNoMatch }, + { searchCoordinate, orderingDirection: ordering, ifNoMatch, approximateMatchPolicy }, this.dependencyGraph ) } @@ -67,9 +67,27 @@ export abstract class AdvancedFind { * 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. + * Other cross-type candidates are either ignored or compared using the total ordering, according to + * `approximateMatchPolicy`. + * + * @param {RawNoErrorScalarValue} searchKey - Normalized lookup value. + * @param {InternalScalarValue[]} searchArray - Values to search from the top-left corner of the lookup array. + * @param {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} ifNoMatch - Whether an absent exact value requests a lower bound, upper bound, or no result. + * @param {ApproximateMatchPolicy} approximateMatchPolicy - Cross-type candidate policy for approximate bounds. + * @param {('first'|'last')} returnOccurrence - Which exact duplicate to return and the direction used to scan candidates. + * @returns {number} The zero-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches. + * + * Candidate selection: + * + * Approximate candidate selection remains in the `NOT_FOUND` index state until the first eligible + * value seeds the result. A scalar sentinel such as positive or negative infinity would participate + * in `compare()` and is not type-neutral: numeric infinity is not a universal extremum under the + * number/string/boolean total ordering. Keeping absence in the index domain ensures that only actual + * lookup values are compared. + * + * @internal */ - protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' = 'returnNotFound', returnOccurrence: 'first' | 'last' = 'first'): number { + protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', approximateMatchPolicy: ApproximateMatchPolicy, returnOccurrence: 'first' | 'last' = 'first'): number { const normalizedArray = searchArray .map(getRawValue) .map(val => typeof val === 'string' ? forceNormalizeString(val) : val) @@ -82,7 +100,6 @@ export abstract class AdvancedFind { ? (left: RawNoErrorScalarValue, right: RawInterpreterValue) => compare(left, right) : (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right) - let bestValue: RawNoErrorScalarValue = ifNoMatch === 'returnLowerBound' ? -Infinity : Infinity let bestIndex = NOT_FOUND const initialIterationIndex = returnOccurrence === 'first' ? 0 : normalizedArray.length-1 @@ -103,12 +120,15 @@ export abstract class AdvancedFind { continue } + if (approximateMatchPolicy === 'sameType' && typeof value !== typeof searchKey) { + continue + } + if (compareFn(value, searchKey) > 0) { continue } - - if (compareFn(bestValue, value) < 0) { - bestValue = value + + if (bestIndex === NOT_FOUND || compareFn(normalizedArray[bestIndex] as RawNoErrorScalarValue, value) < 0) { bestIndex = i } } diff --git a/hyperformula/src/Lookup/ColumnIndex.ts b/hyperformula/src/Lookup/ColumnIndex.ts index d7c32e5894..ea3fc27f36 100644 --- a/hyperformula/src/Lookup/ColumnIndex.ts +++ b/hyperformula/src/Lookup/ColumnIndex.ts @@ -107,13 +107,11 @@ export class ColumnIndex implements ColumnSearchStrategy { } } - public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, { ordering, ifNoMatch, returnOccurrence }: SearchOptions): number { - if (returnOccurrence == null) { - returnOccurrence = ordering === 'none' ? 'first' : 'last' - } + public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchOptions: SearchOptions): number { + const returnOccurrence = searchOptions.returnOccurrence ?? (searchOptions.ordering === 'none' ? 'first' : 'last') const resultUsingColumnIndex = this.findUsingColumnIndex(searchKey, rangeValue, returnOccurrence) - return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ordering, ifNoMatch, returnOccurrence }) + return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ...searchOptions, returnOccurrence }) } private findUsingColumnIndex(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, returnOccurrence: 'first' | 'last'): Maybe { diff --git a/hyperformula/src/Lookup/SearchStrategy.ts b/hyperformula/src/Lookup/SearchStrategy.ts index 2316e586c9..b0a672c66f 100644 --- a/hyperformula/src/Lookup/SearchStrategy.ts +++ b/hyperformula/src/Lookup/SearchStrategy.ts @@ -14,9 +14,38 @@ import {Statistics} from '../statistics' import {ColumnBinarySearch} from './ColumnBinarySearch' import {ColumnIndex} from './ColumnIndex' +/** + * Selects how approximate lower and upper bounds handle non-empty candidates whose scalar type + * differs from the lookup value's type. + * + * Mode semantics: + * + * - `'sameType'` accepts only candidates with the same JavaScript scalar type as the lookup value. + * `MATCH`, `VLOOKUP`, and `HLOOKUP` use this policy. + * - `'totalOrder'` accepts cross-type candidates and orders them with the lookup comparator. + * `XLOOKUP` uses this policy to match Microsoft Excel. + * - Exact matches are unaffected. Every search strategy resolves the policy in one place + * (`AdvancedFind.basicFind`), so linear, indexed, and binary paths cannot diverge. + * + * When `SearchOptions.approximateMatchPolicy` is omitted, `'sameType'` is used. + * + * @internal + */ +export type ApproximateMatchPolicy = 'sameType' | 'totalOrder' + +/** + * Defines the lookup semantics passed to a search strategy. + * + * @property {('asc'|'desc'|'none')} ordering - Ordering assumed by the selected search algorithm. + * @property {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} ifNoMatch - Result requested when an exact value is absent. + * @property {ApproximateMatchPolicy} [approximateMatchPolicy='sameType'] - Cross-type candidate policy for approximate bounds; exact matches ignore it. + * @property {('first'|'last')} [returnOccurrence] - Which exact duplicate to return. + * @internal + */ export interface SearchOptions { ordering: 'asc' | 'desc' | 'none', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', + approximateMatchPolicy?: ApproximateMatchPolicy, returnOccurrence?: 'first' | 'last', } diff --git a/hyperformula/src/interpreter/binarySearch.ts b/hyperformula/src/interpreter/binarySearch.ts index 01e7b07350..99c896181a 100644 --- a/hyperformula/src/interpreter/binarySearch.ts +++ b/hyperformula/src/interpreter/binarySearch.ts @@ -6,18 +6,14 @@ import {AbsoluteCellRange} from '../AbsoluteCellRange' import {CellError, simpleCellAddress} from '../Cell' import {DependencyGraph} from '../DependencyGraph' +import {ApproximateMatchPolicy} from '../Lookup/SearchStrategy' import {EmptyValue, getRawValue, RawInterpreterValue, RawNoErrorScalarValue} from './InterpreterValue' const NOT_FOUND = -1 -/* +/** * Searches for the searchKey in a sorted 1-D range. * - * Options: - * - searchCoordinate - must be set to either 'row' or 'col' to indicate the dimension of the search, - * - 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, 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 @@ -30,19 +26,42 @@ const NOT_FOUND = -1 * 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. + * Under the 'sameType' policy, cells whose type differs from the searchKey's are skipped the same + * way, as Excel's MATCH/VLOOKUP/HLOOKUP drop them before searching. + * The returned offset is always relative to the original range, so skipped cells keep their slots + * and the position of the matched 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. + * Complexity: O(log n) as long as the binary-search descent probes no skipped cell — in particular + * for ranges without empty cells (and, under 'sameType', without cross-type cells). Only when the + * descent touches a skipped cell does the search fall back to an O(n) scan that compacts the + * eligible indices and re-runs the binary search over them. * * Note: this function does not normalize input strings. + * + * @param {RawNoErrorScalarValue} searchKey - Lookup value. + * @param {AbsoluteCellRange} range - Sorted one-dimensional cell range. + * @param {object} options - Search direction, requested miss result, and approximate-match policy. + * @param {('row'|'col')} options.searchCoordinate - Dimension containing the one-dimensional lookup values. + * @param {('asc'|'desc')} options.orderingDirection - Ascending or descending ordering assumed by the binary search. + * @param {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} options.ifNoMatch - Whether an absent exact value requests a lower bound, upper bound, or no result. + * @param {ApproximateMatchPolicy} options.approximateMatchPolicy - Cross-type candidate policy for approximate bounds. + * @param {DependencyGraph} dependencyGraph - Source of current cell values for the range. + * @returns {number} The range-relative index of the match, or `NOT_FOUND` when no valid result exists. + * + * Approximate result invariant: + * + * Exact equality is resolved before approximate policy validation. Under 'sameType', cross-type + * cells are excluded from the descent itself — not only from the landing cell — so the binary + * route selects among the same candidates as the linear route, which skips them before comparing. + * Every approximate exit path, including candidates reached by stepping over skipped cells, is + * finalized through the same policy check. + * + * @internal */ export function findLastOccurrenceInOrderedRange( searchKey: RawNoErrorScalarValue, range: AbsoluteCellRange, - { searchCoordinate, orderingDirection, ifNoMatch }: { searchCoordinate: 'row' | 'col', orderingDirection: 'asc' | 'desc', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' }, + { searchCoordinate, orderingDirection, ifNoMatch, approximateMatchPolicy }: { searchCoordinate: 'row' | 'col', orderingDirection: 'asc' | 'desc', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', approximateMatchPolicy: ApproximateMatchPolicy }, dependencyGraph: DependencyGraph, ): number { const start = range.start[searchCoordinate] @@ -57,13 +76,20 @@ export function findLastOccurrenceInOrderedRange( : (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right) /* - * 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. + * A cell is skipped by the approximate search when it is empty or, under the 'sameType' policy, + * holds a value of a different type than the searchKey. Every other cell is eligible. */ - const findNextNonEmptyIndex = (fromIndex: number): number | undefined => { + const isSkipped = (value: RawInterpreterValue): boolean => + value === EmptyValue || (approximateMatchPolicy === 'sameType' && typeof value !== typeof searchKey) + + /* + * Returns the original index of the first eligible cell at or after fromIndex, or undefined if + * no eligible cell remains. Costs O(gap length), which the search pays only when it actually + * needs to step over skipped slots. + */ + const findNextEligibleIndex = (fromIndex: number): number | undefined => { for (let index = fromIndex; index <= end; index++) { - if (getValueFromIndexFn(index) !== EmptyValue) { + if (!isSkipped(getValueFromIndexFn(index))) { return index } } @@ -71,7 +97,8 @@ export function findLastOccurrenceInOrderedRange( } // 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 + // probed a skipped cell (empty, or cross-type under 'sameType'; the reasoning below for empty + // cells applies to both). 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 @@ -80,43 +107,65 @@ export function findLastOccurrenceInOrderedRange( // 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 + let probedSkippedCell = false const directIndex = findLastMatchingIndex(index => { const value = getValueFromIndexFn(index) - if (value === EmptyValue) { - probedEmptyCell = true + if (isSkipped(value)) { + probedSkippedCell = true } return compareFn(searchKey, value) >= 0 }, start, end) let foundIndex: number - if (!probedEmptyCell) { + if (!probedSkippedCell) { 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[] = [] + // The descent probed a skipped cell, so its result cannot be trusted (HF-223): collect the + // original indices of the eligible cells (O(n)) and re-run the binary search over the + // compacted index list. The result maps back to the original index space, so skipped cells + // keep their slots and the matched cell's original position is reported unchanged. When no + // cell is eligible the compacted list has no elements and the search reports NOT_FOUND, which + // the ifNoMatch branches below preserve. + const eligibleIndices: number[] = [] for (let index = start; index <= end; index++) { - if (getValueFromIndexFn(index) !== EmptyValue) { - nonEmptyIndices.push(index) + if (!isSkipped(getValueFromIndexFn(index))) { + eligibleIndices.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 foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(eligibleIndices[compactedIndex])) >= 0, 0, eligibleIndices.length - 1) + foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : eligibleIndices[foundCompactedIndex] } const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex) + /** + * Validates and converts an absolute approximate candidate index into a range-relative result. + * + * @param {(number|undefined)} index - Absolute candidate index, or `undefined` when no eligible candidate exists. + * @returns {number} The range-relative index, or `NOT_FOUND` when the candidate is absent or disallowed. + * + * @internal + */ + const returnApproximateResult = (index: number | undefined): number => { + if (index === undefined || index === NOT_FOUND) { + return NOT_FOUND + } + + // Safety net: every candidate reaching this point is already eligible, because the descent, + // the compaction, and findNextEligibleIndex all skip cross-type cells under 'sameType'. + if (approximateMatchPolicy === 'sameType' && typeof getValueFromIndexFn(index) !== typeof searchKey) { + return NOT_FOUND + } + + return index - start + } + if (foundValue === searchKey) { return foundIndex - start } @@ -128,25 +177,21 @@ export function findLastOccurrenceInOrderedRange( } // 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) { - return NOT_FOUND + // is the first (largest) eligible value — never a skipped leading cell, and NOT_FOUND when no + // cell is eligible. + const firstEligibleIndex = findNextEligibleIndex(start) + return returnApproximateResult(firstEligibleIndex) } // here: foundValue !== searchKey if (orderingDirection === 'asc') { - return foundIndex - start + return returnApproximateResult(foundIndex) } - // 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 + // orderingDirection === 'desc': step to the next eligible cell, so skipped slots never shift + // the reported position. + const nextIndex = findNextEligibleIndex(foundIndex + 1) + return returnApproximateResult(nextIndex) } if (ifNoMatch === 'returnUpperBound') { @@ -156,25 +201,21 @@ export function findLastOccurrenceInOrderedRange( } // 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) { - return NOT_FOUND + // is the first (smallest) eligible value — never a skipped leading cell, and NOT_FOUND when no + // cell is eligible. + const firstEligibleIndex = findNextEligibleIndex(start) + return returnApproximateResult(firstEligibleIndex) } // here: foundValue !== searchKey if (orderingDirection === 'desc') { - return foundIndex - start + return returnApproximateResult(foundIndex) } - // 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 + // orderingDirection === 'asc': step to the next eligible cell, so skipped slots never shift + // the reported position. + const nextIndex = findNextEligibleIndex(foundIndex + 1) + return returnApproximateResult(nextIndex) } // ifNoMatch === 'returnNotFound' diff --git a/hyperformula/src/interpreter/plugin/LookupPlugin.ts b/hyperformula/src/interpreter/plugin/LookupPlugin.ts index 625a68735d..3b66243f53 100644 --- a/hyperformula/src/interpreter/plugin/LookupPlugin.ts +++ b/hyperformula/src/interpreter/plugin/LookupPlugin.ts @@ -90,7 +90,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech const searchOptions: SearchOptions = { ordering: sorted ? 'asc' : 'none', - ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound' + ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound', + approximateMatchPolicy: 'sameType' } return this.doVlookup(zeroIfEmpty(key), rangeValue, index - 1, searchOptions) @@ -120,7 +121,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech const searchOptions: SearchOptions = { ordering: sorted ? 'asc' : 'none', - ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound' + ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound', + approximateMatchPolicy: 'sameType' } return this.doHlookup(zeroIfEmpty(key), rangeValue, index - 1, searchOptions) @@ -149,6 +151,7 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech const searchOptions: SearchOptions = { ordering: searchMode === 2 ? 'asc' : searchMode === -2 ? 'desc' : 'none', returnOccurrence: searchMode === -1 ? 'last' : 'first', + approximateMatchPolicy: 'totalOrder', ifNoMatch: matchMode === -1 ? 'returnLowerBound' : matchMode === 1 @@ -310,8 +313,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech const searchStrategy = rangeValue.width() === 1 ? this.columnSearch : this.rowSearch const searchOptions: SearchOptions = type === 0 - ? { ordering: 'none', ifNoMatch: 'returnNotFound' } - : { ordering: type === -1 ? 'desc' : 'asc', ifNoMatch: type === -1 ? 'returnUpperBound' : 'returnLowerBound' } + ? { ordering: 'none', ifNoMatch: 'returnNotFound', approximateMatchPolicy: 'sameType' } + : { ordering: type === -1 ? 'desc' : 'asc', ifNoMatch: type === -1 ? 'returnUpperBound' : 'returnLowerBound', approximateMatchPolicy: 'sameType' } const index = searchStrategy.find(key, rangeValue, searchOptions) if (index === -1) {