Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/guide/list-of-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)<br><br>=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)<br>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)<br>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

Expand Down
44 changes: 32 additions & 12 deletions hyperformula/src/Lookup/AdvancedFind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand All @@ -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
)
}
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
}
}
Expand Down
8 changes: 3 additions & 5 deletions hyperformula/src/Lookup/ColumnIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
Expand Down
29 changes: 29 additions & 0 deletions hyperformula/src/Lookup/SearchStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}

Expand Down
Loading
Loading