Skip to content
Draft
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 a named range evaluating to the `#VALUE!` error instead of spilling when passed to an array-returning function, and, with `useArrayArithmetic` enabled, when used as an operand. A named expression now predicts the same array size as the expression it stands for, so `=TRANSPOSE(myRange)` and `=myRange + 1` behave as `=TRANSPOSE(A1:A5)` and `=A1:A5 + 1` do. [#1772](https://github.com/handsontable/hyperformula/pull/1772)
- 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
4 changes: 2 additions & 2 deletions docs/guide/named-expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,15 @@ hfInstance.setCellContents({sheet: 0, col: 2, row: 1}, [['=SUM(SalesData) * TaxR

A named expression that resolves to a range of cells behaves differently depending on where it is used:

- **As a function argument** — it works as expected. `=SUM(myRange)`, `=COUNT(myRange)`, and `=INDEX(myRange, 1, 1)` all operate on the full range.
- **As a function argument** — it works as expected. `=SUM(myRange)`, `=COUNT(myRange)`, and `=INDEX(myRange, 1, 1)` all operate on the full range. A function that returns an array receives the full range too, and its result spills: `=TRANSPOSE(myRange)` produces the same block as `=TRANSPOSE(Sheet1!$A$1:$A$5)`.
- **As an operand of an operator** — the range is reduced to a single cell before the operation. In `=myRange + 1`, only the cell of the range that shares the formula's row (for a vertical range) or column (for a horizontal range) is used. If the formula's row or column falls outside the range, or the range is two-dimensional, the result is a `#VALUE!` error.
- **As a bare reference** — `=myRange` on its own returns a `#VALUE!` error; a range cannot be placed directly into a single cell.

In the default mode the range is reduced before the operator runs, so `=SUM(myRange + 1)` adds 1 to that single reduced value rather than to every element (for a formula in row 1 of a vertical range, the result is `SUM(A1 + 1)`).

When array arithmetic is enabled (`useArrayArithmetic: true`), named ranges still work as function arguments and aggregate correctly, but as an operand they behave differently from the default mode:

- A bare `=myRange + 1` does not spill — it returns a `#VALUE!` error rather than producing one result per element.
- `=myRange + 1` spills, producing one result per element, exactly as the range it stands for does: the result is the same as for `=A1:A5 + 1`.
- Inside an aggregate the operator becomes element-wise. `=SUM(myRange + 1)` adds 1 to every element and then sums, so for `myRange` covering values `1..5` it returns `20` (`SUM(2, 3, 4, 5, 6)`), not the single reduced value of the default mode.

## Named columns
Expand Down
56 changes: 55 additions & 1 deletion src/ArraySize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
import {AbsoluteCellRange} from './AbsoluteCellRange'
import {SimpleCellAddress} from './Cell'
import {Config} from './Config'
import {DependencyGraph} from './DependencyGraph'
import {FunctionRegistry} from './interpreter/FunctionRegistry'
import {InterpreterState} from './interpreter/InterpreterState'
import {FunctionArgumentType} from './interpreter'
import {Ast, AstNodeType, ProcedureAst} from './parser'
import {InternalNamedExpression} from './NamedExpressions'
import {Ast, AstNodeType, NamedExpressionAst, ProcedureAst} from './parser'

export class ArraySize {
constructor(
Expand Down Expand Up @@ -40,9 +42,19 @@ function arraySizeForUnaryOp(arraySize: ArraySize): ArraySize {
}

export class ArraySizePredictor {
/**
* Named expressions whose size is currently being predicted.
*
* Names may refer to one another, and a cycle of such references would make the prediction
* recurse until the stack overflows. A name already on this set is treated as unpredictable,
* which leaves the referring cell a scalar formula and lets the evaluator report the cycle.
*/
private readonly namedExpressionsBeingPredicted = new Set<InternalNamedExpression>()

constructor(
private config: Config,
private functionRegistry: FunctionRegistry,
private dependencyGraph: DependencyGraph,
) {
}

Expand Down Expand Up @@ -84,6 +96,8 @@ export class ArraySizePredictor {
return ArraySize.scalar()
case AstNodeType.CELL_REFERENCE:
return new ArraySize(1, 1, true)
case AstNodeType.NAMED_EXPRESSION:
return this.checkArraySizeForNamedExpression(ast, state)
case AstNodeType.DIV_OP:
case AstNodeType.CONCATENATE_OP:
case AstNodeType.EQUALS_OP:
Expand Down Expand Up @@ -122,6 +136,46 @@ export class ArraySizePredictor {
}
}

/**
* Predicts the size of a named expression referred to by a formula.
*
* A name stands for an expression, so its size is the size of that expression, the `isRef`
* flag included: a name bound to a range predicts like the range literal it stands for, and a
* name bound to an array-returning formula predicts like that formula. Without this, every
* named expression was predicted as a scalar, the referring cell was never turned into an
* array vertex, and an array-shaped result reached the exporter, which rejects it as a
* `#VALUE!` error.
*
* The prediction deliberately uses the engine's own array-arithmetic setting instead of the
* calling state's: the named expression has a cell of its own, and the evaluator always
* computes that cell with `Config.useArrayArithmetic`, no matter where the name is used.
* Inheriting the caller's flag would predict a shape the named expression never produces -
* for example a range-arithmetic name is a scalar error under the default configuration even
* when the name appears inside an array function.
*/
private checkArraySizeForNamedExpression(ast: NamedExpressionAst, state: InterpreterState): ArraySize {
const namedExpression = this.dependencyGraph.namedExpressions.nearestNamedExpression(ast.expressionName, state.formulaAddress.sheet)

if (namedExpression === undefined || this.namedExpressionsBeingPredicted.has(namedExpression)) {
return ArraySize.error()
}

const expression = this.dependencyGraph.getFormulaAst(namedExpression.address)

if (expression === undefined) {
return ArraySize.scalar()
}

this.namedExpressionsBeingPredicted.add(namedExpression)

try {
const size = this.checkArraySize(expression, namedExpression.address)
return new ArraySize(size.width, size.height, size.isRef)
} finally {
this.namedExpressionsBeingPredicted.delete(namedExpression)
}
}

private checkArraySizeForFunction(ast: ProcedureAst, state: InterpreterState): ArraySize {
const pluginArraySizeFunction = this.functionRegistry.getArraySizeFunction(ast.procedureName)

Expand Down
2 changes: 1 addition & 1 deletion src/BuildEngineFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export class BuildEngineFactory {
const arithmeticHelper = new ArithmeticHelper(config, dateTimeHelper, numberLiteralHelper)
const cellContentParser = new CellContentParser(config, dateTimeHelper, numberLiteralHelper)

const arraySizePredictor = new ArraySizePredictor(config, functionRegistry)
const arraySizePredictor = new ArraySizePredictor(config, functionRegistry, dependencyGraph)
const operations = new Operations(config, dependencyGraph, columnSearch, cellContentParser, parser, stats, lazilyTransformingAstService, namedExpressions, arraySizePredictor)
const undoRedo = new UndoRedo(config, operations)
lazilyTransformingAstService.undoRedo = undoRedo
Expand Down
11 changes: 11 additions & 0 deletions src/DependencyGraph/DependencyGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,17 @@ export class DependencyGraph {
return this.addressMapping.getCell(address, { throwIfSheetNotExists: true })
}

/**
* Gets the formula stored at the specified address.
*
* Returns `undefined` when the address holds anything other than a formula: a static value,
* an empty cell, a formula that failed to parse, or a cell of a sheet that does not exist.
*/
public getFormulaAst(address: SimpleCellAddress): Maybe<Ast> {
const vertex = this.addressMapping.getCell(address)
return vertex instanceof FormulaVertex ? vertex.getFormula(this.lazilyTransformingAstService) : undefined
}

public getCellValue(address: SimpleCellAddress): InterpreterValue {
if (this.isPlaceholder(address.sheet)) {
return new CellError(ErrorType.REF, ErrorMessage.SheetRef)
Expand Down
Loading