Skip to content

Data: live What-If data tables ({=TABLE()}) that recalculate, with XLSX round-trip - #548

Draft
prxnvt wants to merge 3 commits into
storytold:mainfrom
prxnvt:pr/live-data-tables
Draft

prxnvt wants to merge 3 commits into
storytold:mainfrom
prxnvt:pr/live-data-tables

Conversation

@prxnvt

@prxnvt prxnvt commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Summary

At present, data.dataTable computes every result of a What-If data table once, by substitution, and writes the results as static values. The command documents the result as "a snapshot, not a live {=TABLE()}" and returns "static": true. Consequently, the results do not change when an input value or a formula changes, and opening an XLSX file that contains a data table keeps only its cached values, with a warning.

This pull request makes data tables live, as they are in Excel. A data table is a multi-cell formula {=TABLE(row_input, column_input)} over its results. It is recalculated whenever one of its input values, one of its formulas or a precedent of those formulas changes. Edits of part of a table are refused, and tables round-trip through XLSX as <f t="dataTable">. The previous snapshot behaviour remains available through static: true.

Changes

Model (crates/model)

  • DataTable (range, row input cell, column input cell) and Sheet::data_tables. Helpers return the results area, the edge cells, the TABLE(B1,B2) text and the substitutions that fill the results. MAX_DATA_TABLE_CELLS (100,000 result cells, the limit the command already used) is shared by calc, the engine and XLSX.

Calculation (crates/calc)

  • Trial moves from the engine to calc so that recalculation can use it; Goal Seek, Scenario Manager, Solver and static data tables use it unchanged.
  • After the formulas have been recalculated, every table with an edge cell in the changed or dirty set is calculated again. Each result substitutes the edge value into the input cell on a copy of the workbook and reads the formula. The results are written as values, and their dependents are recalculated. A table whose formulas read another table's results is calculated after that table.
  • F9 and opening a workbook calculate every table. In Automatic Except for Data Tables mode and in Manual mode, tables are calculated only on F9.
  • A table whose edge cells depend on its own results, directly or through other tables, is treated as a circular reference: its results are #CIRC!. The result does not depend on the order of calculation, so incremental and full recalculation agree.
  • Calculation is bounded: at most 100,000 result cells per table and 1,000,000 per refresh, no table calculation inside a trial (this prevents recursion), and at most 64 passes.

Command (crates/engine)

  • data.dataTable creates a live table by default and returns formula (for example {=TABLE(,B2)}) together with "static": false. Input cells must be single cells on the table's sheet and outside the table ("Input cell reference is not valid."). A table may not overlap another table. Running Data Table again over a table's range replaces that table.
  • static: true runs the previous code path unchanged; input cells may then be on any sheet.
  • An edit that changes some, but not all, of a table's results is refused with "Cannot change part of a data table." This covers typing, pasting, clearing part of the results and sorting. Clearing all of the results removes the table. The check is made once, in commit, so every command is covered.
  • Inserting or deleting rows, columns or cells through a table, and pasting one table onto another, are refused with the same message. A table above or beside such an edit, or a table cut and pasted whole, moves together with its input cells. A table whose input cell is deleted, or moves into the table, keeps its results as values.
  • TABLE() typed into a cell is refused ("That function isn't valid."), as in Excel.
  • The result cells show {=TABLE(...)} in cell.get, in Show Formulas and in the formula bar. A Hidden format on a protected sheet hides the formula, as it does for other formulas.

XLSX (crates/xlsx)

  • Writing: the first result cell carries <f t="dataTable" ref="..." dt2D="..." dtr="..." r1="..." r2="..."/> and its cached value; the other result cells carry their cached values.
  • Reading: the table is rebuilt from that formula, so it is live again after opening. Tables without a results area, with a deleted (del1/del2) or misplaced input cell, with more than 100,000 result cells, overlapping an earlier table, or beyond 1,000 per sheet keep their values and produce a warning.

Documentation

  • docs/target-app-parity.md, docs/file-format-parity.md and the ROADMAP.md progress log. The scores are unchanged.

Compatibility

  • Static mode. {"static": true} gives exactly the previous behaviour: the same code path, values written once, input cells on any sheet, and "static": true in the response. The only addition is that a snapshot taken over the range of a live table replaces that table.
  • Default. Without static, the command now creates a live table. Callers that only read range, cells and mode receive the same values as before, and the cells hold the same results. The response's static field is now false, and formula is new. The default also requires input cells on the table's sheet, as Excel does; callers that relied on an input cell on another sheet should pass static: true.
  • Existing tests. Upstream's data_tables test passes unchanged against the new default, so no existing test was adapted. The engine-to-calc move of Trial keeps the Goal Seek, Scenario Manager and Solver tests unchanged.
  • Files. Workbooks without data tables are read and written exactly as before (the new Sheet field is skipped when empty). XLSX files with data tables no longer report the warning "what-if data tables are not supported".
  • UI. The ribbon entry still runs data.dataTable without parameters, which returns an error as it does today. A Data Table dialog can follow separately.

Testing

  • crates/calc/src/tests.rs: one-variable tables (column and row oriented) and two-variable tables update after edits of an input value, a formula and a precedent; dependents of the results recalculate; Automatic Except for Data Tables and Manual modes wait for F9; a table that reads its own results, and two tables that read each other's results, give #CIRC! and recover when the loop is broken; a table that reads another table follows it, even when it is listed first; formulas with INDIRECT are not treated as circular; hostile tables (whole-sheet ranges, no input, an input inside the table, a single-cell range) are skipped.
  • crates/engine/src/cmd/whatif.rs: live updates of one- and two-variable tables, cell.get and Show Formulas text, undo and redo of edits and of the table itself, refusal of partial edits, sorting, inserted and deleted rows and cells through a table and overlapping tables, movement with rows inserted above, cut and paste of a whole table, clearing all results, a deleted input cell, TABLE() typed into a cell, Automatic Except for Data Tables mode, hostile parameters (empty, missing input, input inside the table or on another sheet, a range as input, a one-column range, a whole-sheet range, 100,001 result cells) and the largest table allowed (100,000 result cells), static mode, and an XLSX save and reopen after which the tables are still live.
  • crates/xlsx/src/tests/data_table_tests.rs: the written XML for the three kinds of table, cached values (including text), the round trip of the tables and their values, and damaged dataTable formulas that keep their values with a warning.
  • cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace --no-fail-fast (including the command-sequence fuzz with its default seeds) and cargo xtask assets, layers, wasm, parity --check and keytips --check pass on each commit. The exceptions are three tests that already fail on main (the French i18n test and two CSV separator tests, addressed by CSV/text export: General numbers without exponents (integers below 1e16 exact); & switches to scientific past 21 characters #449 and fix(i18n): correct scattered wrong French translations and add 2 missing keys #454).
  • A longer soak of the command-sequence fuzz (150 seeds, 120 steps) gives the same six failing seeds as on main, none of which involves a data table. A local randomised sequence of edits around data tables (1,500 seeds of 80 commands: values, formulas, live and static tables, undo, redo, inserted and deleted rows, columns and cells, cut and paste, sorting, clearing) checks after every step that a full recalculation and an XLSX save and reload give the values that incremental recalculation left. It found the cases that the overlap, moved-input and circular-table handling now cover. It is not part of this pull request.
  • The dataTable attributes follow ECMA-376. They have not yet been checked against files written by Excel, because no corpus file with a data table is available.

Generative AI use: The code and descriptions in this pull request were written with the assistance of Claude Opus 5.5 (Claude Code). I directed the work, chose its scope, and reviewed and tested the changes.

…rmulas

A What-If data table is now part of the model (Sheet::data_tables: its
range and its row and column input cells) and of recalculation. After the
formulas, every table with an edge cell (an input value or a formula) in
the changed or dirty set is calculated again: each result substitutes the
edge value into the input cell on a copy of the workbook and reads the
formula (Trial, moved from the engine to calc). Results are written as
values and their dependents recalculated; a table whose formulas read
another table's results is calculated after it.

F9 and opening a workbook calculate every table. Automatic Except for Data
Tables and Manual calculation leave tables to F9. A table whose edge cells
depend on its own results, directly or through other tables, is a circular
reference: its results are #CIRC!. Calculation is bounded: at most 100,000
result cells per table and 1,000,000 per refresh, no table calculation
inside a trial, and at most 64 passes.
data.dataTable now adds a live data table over the results instead of
writing a snapshot: the results recalculate when an input value, a formula
or a precedent of the formulas changes (on F9 in Automatic Except for Data
Tables mode), and undo removes the table with its results. The result
cells show {=TABLE(row_input,column_input)} in the formula bar, in Show
Formulas and in cell.get. Input cells must be single cells on the table's
sheet, outside it; a table may not overlap another one, and running Data
Table again over a table's range replaces that table.

`static: true` keeps the previous behaviour: every result is computed once
and written as a value, with input cells on any sheet. The response also
gains `formula`, and `static` is now false for a live table.

Like Excel, an edit of some of a table's results (typing, pasting,
clearing part of them, sorting) is refused with "Cannot change part of a
data table."; clearing all of them removes the table. Inserting or
deleting rows, columns or cells through a table, and pasting one table
onto another, are refused the same way; a table above or beside the edit,
or cut and pasted whole, moves with its input cells, and a table whose
input cell is deleted or ends up inside it keeps its results as values.
TABLE() typed into a cell is refused ("That function isn't valid.").
A data table is written as Excel stores it: the first result cell carries
<f t="dataTable" ref="..." dt2D=".." dtr=".." r1=".." r2=".."/> for the
whole results area, and every result cell its cached value. Reading
rebuilds the table from that formula (previously it was dropped with a
warning and only the values kept), so the table is live again after
opening. Tables without a results area, with a deleted or misplaced input
cell, with more than 100,000 result cells, overlapping an earlier table,
or beyond 1,000 per sheet keep their values with a warning.

Docs: target-app and file-format parity, ROADMAP progress log.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant