Skip to content

Commit b2da92e

Browse files
feat(config): add arrayFunctionResultOverwritesData opt-in overwrite flag (HF-305)
Adds a public boolean Config option `arrayFunctionResultOverwritesData` (default `false`). When `false`, behavior is unchanged: an array spill onto an occupied cell yields `#SPILL!` and leaves the occupant intact. When `true`, the spill overwrites the occupied cells (clears occupants, spills the array, reroutes dependents) instead of emitting `#SPILL!`. Implementation reuses the existing spill-placement exchange primitive: - ConfigParams/Config: new option, mirrored on `useArrayArithmetic`. - DependencyGraph gains `config` + two guarded early-returns in exchangeOrAddFormulaVertex / setAddressMappingForArrayVertex. - Evaluator: recompute no longer emits `#SPILL!` when overwrite is allowed. Array-vs-array safety: a spill colliding with ANOTHER array always keeps `#SPILL!` and leaves that array intact, even in overwrite mode (canOverwriteArrayResult / overwriteWouldHitArray). This matches Excel and avoids corrupting a pre-existing array; overwrite only clears static data. Default `false` guarantees no existing embedder loses data silently. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6e87a37 commit b2da92e

6 files changed

Lines changed: 281 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1111

1212
- Added new functions: VSTACK, HSTACK. [#1698](https://github.com/handsontable/hyperformula/pull/1698)
1313
- Added a new function: `XIRR`. [#1701](https://github.com/handsontable/hyperformula/pull/1701)
14+
- Added the `arrayFunctionResultOverwritesData` configuration option (default `false`). When enabled, an array function whose result spills onto occupied cells overwrites them instead of returning a `#SPILL!` error. This is an opt-in, destructive behavior; a collision with another array still yields `#SPILL!`.
1415
- Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674)
1516
- 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)
1617

src/Config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,14 @@ export class Config implements ConfigParams, ParserConfig {
6969
useColumnIndex: false,
7070
useStats: false,
7171
useArrayArithmetic: false,
72+
arrayFunctionResultOverwritesData: false,
7273
}
7374

7475
/** @inheritDoc */
7576
public readonly useArrayArithmetic: boolean
7677
/** @inheritDoc */
78+
public readonly arrayFunctionResultOverwritesData: boolean
79+
/** @inheritDoc */
7780
public readonly caseSensitive: boolean
7881
/** @inheritDoc */
7982
public readonly chooseAddressMappingPolicy: ChooseAddressMapping
@@ -203,6 +206,7 @@ export class Config implements ConfigParams, ParserConfig {
203206
timeFormats,
204207
thousandSeparator,
205208
useArrayArithmetic,
209+
arrayFunctionResultOverwritesData,
206210
useStats,
207211
undoLimit,
208212
maxPendingLazyTransformations,
@@ -216,6 +220,7 @@ export class Config implements ConfigParams, ParserConfig {
216220
}
217221

218222
this.useArrayArithmetic = configValueFromParam(useArrayArithmetic, 'boolean', 'useArrayArithmetic')
223+
this.arrayFunctionResultOverwritesData = configValueFromParam(arrayFunctionResultOverwritesData, 'boolean', 'arrayFunctionResultOverwritesData')
219224
this.accentSensitive = configValueFromParam(accentSensitive, 'boolean', 'accentSensitive')
220225
this.caseSensitive = configValueFromParam(caseSensitive, 'boolean', 'caseSensitive')
221226
this.caseFirst = configValueFromParam(caseFirst, ['upper', 'lower', 'false'], 'caseFirst')

src/ConfigParams.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,25 @@ export interface ConfigParams {
390390
* @category Engine
391391
*/
392392
useArrayArithmetic: boolean,
393+
/**
394+
* When set to `true`, an array function whose result spills onto already-occupied cells
395+
* overwrites those cells (clearing their previous content, spilling the array, and rerouting
396+
* any dependents to the spilled values) instead of returning a `#SPILL!` error.
397+
*
398+
* **Warning:** this is a destructive, opt-in behavior. Enabling it can silently discard data
399+
* that happens to sit in the spill range, so use it only when overwriting is the intended
400+
* outcome. Overwritten cells are **not** restored by `undo()`.
401+
*
402+
* The overwrite is applied when the array formula is evaluated (e.g. via `setCellContents`).
403+
* When set to `false`, an array spill onto an occupied cell yields `#SPILL!` and leaves the
404+
* occupant intact (the default, Excel-compatible behavior).
405+
*
406+
* Even when set to `true`, a spill that would collide with *another array* still yields
407+
* `#SPILL!` and leaves that array intact — overwrite mode never clobbers another array.
408+
* @default false
409+
* @category Engine
410+
*/
411+
arrayFunctionResultOverwritesData: boolean,
393412
/**
394413
* When set to `true`, switches column search strategy from binary search to column index.
395414
*

src/DependencyGraph/DependencyGraph.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export class DependencyGraph {
6363
public readonly lazilyTransformingAstService: LazilyTransformingAstService,
6464
public readonly functionRegistry: FunctionRegistry,
6565
public readonly namedExpressions: NamedExpressions,
66+
public readonly config: Config,
6667
) {
6768
this.graph = new Graph<Vertex>(this.dependencyQueryVertices)
6869
this.sheetReferenceRegistrar = new SheetReferenceRegistrar(sheetMapping, addressMapping)
@@ -82,7 +83,8 @@ export class DependencyGraph {
8283
stats,
8384
lazilyTransformingAstService,
8485
functionRegistry,
85-
namedExpressions
86+
namedExpressions,
87+
config
8688
)
8789
}
8890

@@ -480,6 +482,30 @@ export class DependencyGraph {
480482
return true
481483
}
482484

485+
/**
486+
* True when an array spill collision may be resolved by overwriting the occupants
487+
* (i.e. `arrayFunctionResultOverwritesData` is on) AND doing so would not clobber
488+
* another array. Array-vs-array collisions always keep `#SPILL!` (matches Excel and
489+
* avoids corrupting the pre-existing array), even in overwrite mode.
490+
*/
491+
public canOverwriteArrayResult(arrayVertex: ArrayFormulaVertex): boolean {
492+
return this.config.arrayFunctionResultOverwritesData && !this.overwriteWouldHitArray(arrayVertex)
493+
}
494+
495+
private overwriteWouldHitArray(arrayVertex: ArrayFormulaVertex): boolean {
496+
const range = arrayVertex.getRangeOrUndef()
497+
if (range === undefined) {
498+
return false
499+
}
500+
for (const address of range.addresses(this)) {
501+
const vertexUnderAddress = this.addressMapping.getCell(address)
502+
if (vertexUnderAddress instanceof ArrayFormulaVertex && vertexUnderAddress !== arrayVertex) {
503+
return true
504+
}
505+
}
506+
return false
507+
}
508+
483509
public moveCells(sourceRange: AbsoluteCellRange, toRight: number, toBottom: number, toSheet: number) {
484510
for (const sourceAddress of sourceRange.addressesWithDirection(toRight, toBottom, this)) {
485511
const targetAddress = simpleCellAddress(toSheet, sourceAddress.col + toRight, sourceAddress.row + toBottom)
@@ -1119,7 +1145,7 @@ export class DependencyGraph {
11191145
this.addressMapping.setCell(address, vertex)
11201146

11211147
if (vertex instanceof ArrayFormulaVertex) {
1122-
if (!this.isThereSpaceForArray(vertex)) {
1148+
if (!this.isThereSpaceForArray(vertex) && !this.canOverwriteArrayResult(vertex)) {
11231149
return
11241150
}
11251151
for (const cellAddress of range.addresses(this)) {
@@ -1149,7 +1175,7 @@ export class DependencyGraph {
11491175
}
11501176
this.setArray(range, vertex)
11511177

1152-
if (!this.isThereSpaceForArray(vertex)) {
1178+
if (!this.isThereSpaceForArray(vertex) && !this.canOverwriteArrayResult(vertex)) {
11531179
return
11541180
}
11551181

src/Evaluator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export class Evaluator {
133133

134134
private recomputeFormulaVertexValue(vertex: FormulaVertex): InterpreterValue {
135135
const address = vertex.getAddress(this.lazilyTransformingAstService)
136-
if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || !this.dependencyGraph.isThereSpaceForArray(vertex))) {
136+
if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || (!this.dependencyGraph.isThereSpaceForArray(vertex) && !this.dependencyGraph.canOverwriteArrayResult(vertex)))) {
137137
return vertex.setNoSpace()
138138
} else {
139139
const formula = vertex.getFormula(this.lazilyTransformingAstService)

test/hf-305-overwrite.spec.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import {DetailedCellError, ErrorType, HyperFormula} from '../src'
2+
import {SimpleCellAddress, simpleCellAddress} from '../src/Cell'
3+
4+
const adr = (stringAddress: string, sheet: number = 0): SimpleCellAddress => {
5+
const result = /^(\$([A-Za-z0-9_]+)\.)?(\$?)([A-Za-z]+)(\$?)([0-9]+)$/.exec(stringAddress)!
6+
const row = Number(result[6]) - 1
7+
return simpleCellAddress(sheet, colNumber(result[4]), row)
8+
}
9+
10+
const colNumber = (input: string): number => {
11+
if (input.length === 1) {
12+
return input.toUpperCase().charCodeAt(0) - 65
13+
} else {
14+
return input.split('').reduce((currentColumn, nextLetter) => {
15+
return currentColumn * 26 + (nextLetter.toUpperCase().charCodeAt(0) - 64)
16+
}, 0) - 1
17+
}
18+
}
19+
20+
describe('HF-305 arrayFunctionResultOverwritesData', () => {
21+
it('OFF (flag omitted, default): array spilling onto an occupied cell yields #SPILL! and leaves the occupant intact', () => {
22+
const data = [
23+
['=TRANSPOSE(C1:E1)', null, 1, 2, 3],
24+
['x'],
25+
]
26+
27+
const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'})
28+
29+
const a1 = hf.getCellValue(adr('A1'))
30+
expect(a1 instanceof DetailedCellError).toBe(true)
31+
expect((a1 as DetailedCellError).type).toBe(ErrorType.SPILL)
32+
expect(hf.getCellValue(adr('A2'))).toBe('x')
33+
34+
hf.destroy()
35+
})
36+
37+
it('OFF (explicit false): array spilling onto an occupied cell yields #SPILL! and leaves the occupant intact', () => {
38+
const data = [
39+
['=TRANSPOSE(C1:E1)', null, 1, 2, 3],
40+
['x'],
41+
]
42+
43+
const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: false})
44+
45+
const a1 = hf.getCellValue(adr('A1'))
46+
expect((a1 as DetailedCellError).type).toBe(ErrorType.SPILL)
47+
expect(hf.getCellValue(adr('A2'))).toBe('x')
48+
49+
hf.destroy()
50+
})
51+
52+
it('OFF: setting an array formula that collides with an occupied cell yields #SPILL! (recalc parity)', () => {
53+
const data = [
54+
[null, null, 1, 2, 3],
55+
['x'],
56+
]
57+
58+
const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: false})
59+
60+
hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']])
61+
62+
const a1 = hf.getCellValue(adr('A1'))
63+
expect((a1 as DetailedCellError).type).toBe(ErrorType.SPILL)
64+
expect(hf.getCellValue(adr('A2'))).toBe('x')
65+
66+
hf.destroy()
67+
})
68+
69+
it('ON: setting an array formula that collides with an occupied cell overwrites it and the spill lands', () => {
70+
const data = [
71+
[null, null, 1, 2, 3],
72+
['x'],
73+
]
74+
75+
const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true})
76+
77+
hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']])
78+
79+
expect(hf.getCellValue(adr('A1'))).toBe(1)
80+
expect(hf.getCellValue(adr('A2'))).toBe(2)
81+
expect(hf.getCellValue(adr('A3'))).toBe(3)
82+
83+
hf.destroy()
84+
})
85+
86+
it('ON: a cell referencing the overwritten occupant reflects the new spilled value (dependent reroute)', () => {
87+
const data = [
88+
[null, '=A2', 1, 2, 3],
89+
['x'],
90+
]
91+
92+
const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true})
93+
94+
hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']])
95+
96+
expect(hf.getCellValue(adr('A2'))).toBe(2)
97+
expect(hf.getCellValue(adr('B1'))).toBe(2)
98+
99+
hf.destroy()
100+
})
101+
102+
it('OFF: free spill into empty cells still works (no regression)', () => {
103+
const data = [
104+
['=TRANSPOSE(C1:E1)', null, 1, 2, 3],
105+
]
106+
107+
const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: false})
108+
109+
expect(hf.getCellValue(adr('A1'))).toBe(1)
110+
expect(hf.getCellValue(adr('A2'))).toBe(2)
111+
expect(hf.getCellValue(adr('A3'))).toBe(3)
112+
113+
hf.destroy()
114+
})
115+
116+
it('ON: free spill into empty cells still works (no regression when the flag is on but there is no collision)', () => {
117+
const data = [
118+
['=TRANSPOSE(C1:E1)', null, 1, 2, 3],
119+
]
120+
121+
const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true})
122+
123+
expect(hf.getCellValue(adr('A1'))).toBe(1)
124+
expect(hf.getCellValue(adr('A2'))).toBe(2)
125+
expect(hf.getCellValue(adr('A3'))).toBe(3)
126+
127+
hf.destroy()
128+
})
129+
130+
// OUT OF SCOPE (documented current behavior): the overwrite primitive lives on the
131+
// setFormulaToCell / setCellContents path (`exchangeOrAddFormulaVertex`). When an array
132+
// formula AND a conflicting occupant are declared *inline in the same buildFromArray call*,
133+
// the occupant is processed after the array and GraphBuilder.shrinkArrayIfNeeded shrinks the
134+
// array back to its corner, so the inline occupant wins at build time even with the flag ON.
135+
// Asserting current behavior so a future change to this edge is a conscious decision.
136+
it('ON (out of scope): an occupant declared inline in the same buildFromArray still wins at build time', () => {
137+
const data = [
138+
['=TRANSPOSE(C1:E1)', null, 1, 2, 3],
139+
['x'],
140+
]
141+
142+
const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true})
143+
144+
expect(hf.getCellValue(adr('A2'))).toBe('x')
145+
expect(hf.getCellValue(adr('A3'))).toBe(null)
146+
147+
hf.destroy()
148+
})
149+
150+
// Array-vs-array collisions ALWAYS keep #SPILL!, even in overwrite mode: the flag clears
151+
// static occupants but must never clobber another array (matches Excel, and avoids corrupting
152+
// the pre-existing array). Guarded by DependencyGraph.canOverwriteArrayResult / overwriteWouldHitArray.
153+
it('ON: array-vs-array collision stays #SPILL! and leaves the pre-existing array intact', () => {
154+
// First array at B2 spills B2:B4 = 1,2,3 into free space.
155+
const hf = HyperFormula.buildFromArray([
156+
[null, null, 1, 2, 3],
157+
[null, '=TRANSPOSE(C1:E1)'],
158+
], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true})
159+
expect(hf.getCellValue(adr('B2'))).toBe(1)
160+
161+
// A second array at B1 would spill B1:B2, hitting the first array's anchor at B2.
162+
hf.setCellContents(adr('B1'), [['=TRANSPOSE(C1:D1)']])
163+
164+
const b1 = hf.getCellValue(adr('B1'))
165+
expect(b1 instanceof DetailedCellError).toBe(true)
166+
expect((b1 as DetailedCellError).type).toBe(ErrorType.SPILL)
167+
// the pre-existing array is left untouched (no corruption)
168+
expect(hf.getCellValue(adr('B2'))).toBe(1)
169+
expect(hf.getCellValue(adr('B3'))).toBe(2)
170+
expect(hf.getCellValue(adr('B4'))).toBe(3)
171+
172+
hf.destroy()
173+
})
174+
175+
it('ON: overwrites a formula occupant (recalc path)', () => {
176+
const hf = HyperFormula.buildFromArray([
177+
[null, null, 1, 2, 3],
178+
['=C1+100'],
179+
], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true})
180+
expect(hf.getCellValue(adr('A2'))).toBe(101)
181+
182+
hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']])
183+
184+
expect(hf.getCellValue(adr('A1'))).toBe(1)
185+
expect(hf.getCellValue(adr('A2'))).toBe(2) // formula occupant overwritten
186+
expect(hf.getCellValue(adr('A3'))).toBe(3)
187+
188+
hf.destroy()
189+
})
190+
191+
it('ON: overwrites a 2-D block of occupants (MMULT 2x2)', () => {
192+
const hf = HyperFormula.buildFromArray([
193+
[null, null, null, 1, 0, null, 1, 0],
194+
['x', 'y', null, 0, 1, null, 0, 1],
195+
], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true})
196+
197+
hf.setCellContents(adr('A1'), [['=MMULT(D1:E2,G1:H2)']]) // 2x2 spill A1:B2 over A2='x', B2='y'
198+
199+
expect(hf.getCellValue(adr('A1'))).toBe(1)
200+
expect(hf.getCellValue(adr('B1'))).toBe(0)
201+
expect(hf.getCellValue(adr('A2'))).toBe(0) // occupant 'x' overwritten
202+
expect(hf.getCellValue(adr('B2'))).toBe(1) // occupant 'y' overwritten
203+
204+
hf.destroy()
205+
})
206+
207+
// KNOWN LIMITATION (documented in ConfigParams JSDoc + PR): overwrite is destructive and the
208+
// cleared occupants are NOT recorded on the undo stack, so undo does not restore them. Asserted
209+
// here so the gap is explicit and a future fix has a red anchor.
210+
it('ON: undo of an overwrite does NOT restore the overwritten occupant (known limitation)', () => {
211+
const hf = HyperFormula.buildFromArray([
212+
[null, null, 1, 2, 3],
213+
['x'],
214+
], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true})
215+
hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) // overwrites A2='x'
216+
expect(hf.getCellValue(adr('A2'))).toBe(2)
217+
218+
hf.undo()
219+
220+
expect(hf.getCellValue(adr('A1'))).toBe(null) // formula removed (correct)
221+
// KNOWN GAP: the overwritten 'x' is lost rather than restored.
222+
expect(hf.getCellValue(adr('A2'))).toBe(null)
223+
224+
hf.destroy()
225+
})
226+
})

0 commit comments

Comments
 (0)