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 @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Fixed

- Fixed array results exceeding the configured row or column limits, including intermediate arrays created by `VSTACK`, `HSTACK`, `TRANSPOSE`, and array literals. Arrays with valid dimensions now return `#SPILL!` when their spill area extends beyond the sheet edge. [#1774](https://github.com/handsontable/hyperformula/pull/1774)
- Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733)
- Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748)
- Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718)
Expand Down
10 changes: 10 additions & 0 deletions docs/guide/arrays.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ If your specified output array size is larger or equal to the input array size,

## Array rules

### Array size limits

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEV_DOCS.md asks that deviations from Excel be recorded in docs/guide/list-of-differences.md, and this paragraph introduces one. Measured in Excel Online: only rows are capped for in-memory arrays (=SUM(VSTACK(SEQUENCE(1048576),SEQUENCE(1))) → #NUM!); columns are not (=SUM(HSTACK(SEQUENCE(1,16384),SEQUENCE(1,1))) → 134225921, and an over-wide array only fails when it spills, with #SPILL!). HyperFormula caps both dimensions and returns #VALUE!. Capping both is a reasonable safety choice. Could you add a row for it? For example: array dimensions vs. maxRows/maxColumns — HyperFormula: #VALUE! for either dimension, including intermediates; Excel: #NUM! for rows only.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an "Array size limits" row to list-of-differences.md in e450ecb20, using your example:

=SUM(VSTACK(SEQUENCE(1048576), SEQUENCE(1)))
HyperFormula (default limits) #VALUE!
Excel Online #NUM!
Google Sheets 549756338177

The Excel column says rows are capped for in-memory arrays but columns aren't, so an over-wide array fails only when it spills, with #SPILL!. I checked Google Sheets directly: it caps neither dimension (=SUM(HSTACK(SEQUENCE(1,18278),SEQUENCE(1,1))) → 167051782, and even a 20-million-cell =SUM(SEQUENCE(20000000)) computes).


HyperFormula limits array dimensions using the [`maxRows`](../api/interfaces/configparams.md#maxrows) and [`maxColumns`](../api/interfaces/configparams.md#maxcolumns) configuration options. An array result that exceeds either limit returns `#VALUE!` (`Value too large.`). This also applies to intermediate arrays: wrapping an oversized `VSTACK` result in `SUM` or `ARRAY_CONSTRAIN` does not bypass the limits. Reduce the dimensions of the intermediate array to resolve the error.

An array whose dimensions fit within these limits can still extend beyond the sheet edge because of its formula's position. In that case, HyperFormula returns `#SPILL!` (`No space for array result.`). Move the formula to a position with enough space or reduce its output dimensions. For example, with `maxRows` set to `4`, `=SEQUENCE(4)` fits at `A1` but returns `#SPILL!` at `A2`. A scalar formula such as `=SUM(SEQUENCE(4))` can be placed at `A2` because its result occupies only one cell.

An array formula that returns `#SPILL!` does not spill again on its own when space becomes available, for example after the rows above it are removed or a blocking value is cleared. Re-enter the formula to recalculate it. See [#1785](https://github.com/handsontable/hyperformula/issues/1785).

These options limit each dimension separately. They do not impose a separate limit on the total number of cells in an array, so an array within both limits can still be very large: at the default limits, `=SEQUENCE(40000, 18278)` has over 731 million cells and can exhaust the available memory.

### With the array arithmetic mode enabled

When the [array arithmetic mode](#array-arithmetic-mode) is enabled, and you pass an array to a [scalar](#about-arrays) function, the following rules apply:
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 @@ -52,6 +52,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google
| 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)<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. |
| Array size limits | =SUM(VSTACK(SEQUENCE(1048576), SEQUENCE(1)))<br><br>=SUM(HSTACK(SEQUENCE(1, 18278), SEQUENCE(1, 1))) | Every array, including an intermediate array that is never spilled, is limited in both dimensions by the [`maxRows`](../api/interfaces/configparams.md#maxrows) and [`maxColumns`](../api/interfaces/configparams.md#maxcolumns) configuration options. An array that exceeds either limit returns `#VALUE!`. With the default limits (40,000 rows, 18,278 columns), both examples return `#VALUE!`. | In-memory arrays are not limited in either dimension; only a spilled result is limited by the sheet size. Returns `549756338177` and `167051782`. | In-memory arrays are limited to 1,048,576 rows, but not in columns; an over-wide array fails only when it spills, with `#SPILL!`. Returns `#NUM!` for the first example. |

## Built-in functions

Expand Down
20 changes: 19 additions & 1 deletion src/ArraySize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export class ArraySize {
isScalar(): boolean {
return (this.width === 1 && this.height === 1) || this.isRef
}

/**
* Checks result dimensions against the configured sheet limits, independently
* of the formula's position. Call before allocating a result array.
*/
exceedsSheetSizeLimits(maxColumns: number, maxRows: number): boolean {
return this.width > maxColumns || this.height > maxRows
}
}

function arraySizeForBinaryOp(leftArraySize: ArraySize, rightArraySize: ArraySize): ArraySize {
Expand All @@ -46,8 +54,18 @@ export class ArraySizePredictor {
) {
}

/**
* Limits the predicted spill area before the dependency graph maps its cells.
* A scalar fallback still evaluates the formula, so runtime allocation guards
* determine the error and also protect intermediate arrays.
* Infinite dimensions represent whole-row or whole-column references, whose
* effective size is resolved by the dependency graph.
*/
public checkArraySize(ast: Ast, formulaAddress: SimpleCellAddress): ArraySize {
return this.checkArraySizeForAst(ast, {formulaAddress, arraysFlag: this.config.useArrayArithmetic})
const size = this.checkArraySizeForAst(ast, {formulaAddress, arraysFlag: this.config.useArrayArithmetic})
const maxColumns = size.width === Infinity ? Infinity : this.config.maxColumns
const maxRows = size.height === Infinity ? Infinity : this.config.maxRows
return size.exceedsSheetSizeLimits(maxColumns, maxRows) ? ArraySize.error() : size
}

public checkArraySizeForAst(ast: Ast, state: InterpreterState): ArraySize {
Expand Down
6 changes: 4 additions & 2 deletions src/DependencyGraph/DependencyGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class DependencyGraph {
public readonly lazilyTransformingAstService: LazilyTransformingAstService,
public readonly functionRegistry: FunctionRegistry,
public readonly namedExpressions: NamedExpressions,
private readonly config: Config,
) {
this.graph = new Graph<Vertex>(this.dependencyQueryVertices)
this.sheetReferenceRegistrar = new SheetReferenceRegistrar(sheetMapping, addressMapping)
Expand All @@ -82,7 +83,8 @@ export class DependencyGraph {
stats,
lazilyTransformingAstService,
functionRegistry,
namedExpressions
namedExpressions,
config
)
}

Expand Down Expand Up @@ -468,7 +470,7 @@ export class DependencyGraph {

public isThereSpaceForArray(arrayVertex: ArrayFormulaVertex): boolean {
const range = arrayVertex.getRangeOrUndef()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker, but worth a follow-up ticket before this ships. Once isThereSpaceForArray returns false here, setNoSpace() replaces the vertex's size with ArraySize.error() (1×1, isRef), and Evaluator.recomputeFormulaVertexValue then short-circuits to setNoSpace() on every later recompute. So a #SPILL! produced by this new branch is permanent until the formula is re-entered or the engine is rebuilt.

Example: with maxRows: 4, =SEQUENCE(4) in A2 is #SPILL!. removeRows(0, [0, 1]) moves it to A1, where it fits, and it stays #SPILL!. The same happens after undo/redo, removeColumns, moveRows, or a dependency change. Excel Online re-spills in that case: deleting row 1 above a #SPILL! SEQUENCE(3) at row 1048575 gives 1,2,3.

The mechanism is pre-existing: a value-blocked array on develop does not re-spill when the blocker is cleared either. But before this change the only way into that state was a collision with a value; now any array near the bottom or right edge gets there. The paired tests only cover recovery by re-entering the formula, updateConfig, or undo of the formula edit, so nothing in the suite observes this. I'd suggest tracking "re-evaluate space for arrays in #SPILL! state after structural changes" as its own task and linking it from the docs paragraph added here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as #1785, and the Arrays guide links to it in e450ecb20. Before filing I reproduced every case you listed on this branch, with one refinement: redo of the removeRows stays stuck, but undoing the write that blocked the spill recovers. I also confirmed the value-blocked case on develop, and that Excel Online re-spills in all three setups (rows deleted above, a column deleted to the left, a blocking value cleared).

One correction to the scope: the tests#57 undo and dependency-change tests check that the error stays when the array still doesn't fit. Recovery is only covered via editing the formula and updateConfig. The issue says so.

if (range === undefined) {
if (range === undefined || range.exceedsSheetSizeLimits(this.config.maxColumns, this.config.maxRows)) {
return false
}
for (const address of range.addresses(this)) {
Expand Down
13 changes: 9 additions & 4 deletions src/interpreter/Interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import {AbsoluteCellRange, AbsoluteColumnRange, AbsoluteRowRange} from '../AbsoluteCellRange'
import {ArraySizePredictor} from '../ArraySize'
import {ArraySize, ArraySizePredictor} from '../ArraySize'
import {ArrayValue, NotComputedArray} from '../ArrayValue'
import {CellError, ErrorType, isColOrRowInvalid} from '../Cell'
import {Config} from '../Config'
Expand Down Expand Up @@ -256,15 +256,20 @@ export class Interpreter {
for (const astIt of astRow) {
const arr = coerceToRange(this.evaluateAst(astIt, state))
const height = arr.height()
if (rowHeight !== undefined && rowHeight !== height) {
return new CellError(ErrorType.REF, ErrorMessage.SizeMismatch)
}
const size = new ArraySize((rowRet[0]?.length ?? 0) + arr.width(), ret.length + height)
if (size.exceedsSheetSizeLimits(this.config.maxColumns, this.config.maxRows)) {
return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge)
}
if (rowHeight === undefined) {
rowHeight = height
rowRet.push(...arr.data)
} else if (rowHeight === height) {
} else {
for (let i = 0; i < height; i++) {
rowRet[i].push(...arr.data[i])
}
} else {
return new CellError(ErrorType.REF, ErrorMessage.SizeMismatch)
}
}
const width = rowRet[0].length
Expand Down
8 changes: 8 additions & 0 deletions src/interpreter/plugin/ArrayPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche
public vstack(ast: ProcedureAst, state: InterpreterState): InterpreterValue {
return this.runFunction(ast.args, state, this.metadata('VSTACK'), (...ranges: SimpleRangeValue[]) => {
const width = Math.max(...ranges.map(range => range.width()))
const height = ranges.reduce((total, range) => total + range.height(), 0)
if (new ArraySize(width, height).exceedsSheetSizeLimits(this.config.maxColumns, this.config.maxRows)) {
return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge)
}
const result: InternalScalarValue[][] = []

for (const range of ranges) {
Expand Down Expand Up @@ -224,6 +228,10 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche
public hstack(ast: ProcedureAst, state: InterpreterState): InterpreterValue {
return this.runFunction(ast.args, state, this.metadata('HSTACK'), (...ranges: SimpleRangeValue[]) => {
const height = Math.max(...ranges.map(range => range.height()))
const width = ranges.reduce((total, range) => total + range.width(), 0)
if (new ArraySize(width, height).exceedsSheetSizeLimits(this.config.maxColumns, this.config.maxRows)) {
return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge)
}
const result: InternalScalarValue[][] = [...Array(height).keys()].map(() => [])

for (const range of ranges) {
Expand Down
5 changes: 4 additions & 1 deletion src/interpreter/plugin/MatrixPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,11 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech

public transpose(ast: ProcedureAst, state: InterpreterState): InterpreterValue {
return this.runFunction(ast.args, state, this.metadata('TRANSPOSE'), (matrix: SimpleRangeValue) => {
const input = matrix.rawData()
const inputSize = matrix.size
if (new ArraySize(inputSize.height, inputSize.width).exceedsSheetSizeLimits(this.config.maxColumns, this.config.maxRows)) {
return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge)
}
const input = matrix.rawData()
const result: InternalScalarValue[][] = []
for (let i = 0; i < inputSize.width; ++i) {
result[i] = []
Expand Down
7 changes: 4 additions & 3 deletions src/interpreter/plugin/SequencePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export class SequencePlugin extends FunctionPlugin implements FunctionPluginType
return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne)
}

if (numRows > this.config.maxRows || numCols > this.config.maxColumns) {
if (new ArraySize(numCols, numRows).exceedsSheetSizeLimits(this.config.maxColumns, this.config.maxRows)) {
return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge)
}

Expand Down Expand Up @@ -167,10 +167,11 @@ export class SequencePlugin extends FunctionPlugin implements FunctionPluginType
return ArraySize.error()
}

if (rows > this.config.maxRows || cols > this.config.maxColumns) {
const size = new ArraySize(cols, rows)
if (size.exceedsSheetSizeLimits(this.config.maxColumns, this.config.maxRows)) {
return ArraySize.error()
}

return new ArraySize(cols, rows)
return size
}
}
Loading