Navigation

Computed Columns

Computed columns derive their values from other columns instead of storing data — a ComputedColumn carries a transformation describing how to compute each cell from its source column(s), evaluated lazily at read time.

How it works

A ComputedColumn is an ordinary column (ColumnType.Computed) whose transformation field is a discriminated union keyed by ColumnTransformationType: Aggregation, ConvertTo, DatePart, Math, RegexMatch, String, StringPattern, or StringSplit. Values are never written to row.data — computed columns are read-only. Every cell write of a row already in the sheet goes through writeCellValue, which drops a write to a computed column; a new row — an empty one, or one made from a pasted line (createPastedRowData) — carries no key for one; and alignRowDataToColumns, which rebuilds rows after a column is moved, renamed or retyped, keeps only stored columns' keys, so a column turned computed loses its values and one turned stored starts empty. The cell-scoped commands walk cells through collectAffectedCells, which skips a computed column whatever it is handed; NullStrategy.DropRow works on whole rows through getNullAffectedRows instead, which leaves computed columns out on its own, so a null an older row still carries under one never drops that row.

All reads go through one resolver, computeValue(rows, row, columns, column, rowIndex?, transformationReaderMap?, visited?). For a non-computed column it simply returns row.data[column.name]. For a computed column it dispatches to ColumnTransformationComputeMap[transformation.type], handing the computer a context with:

  • computeSource(sourceColumnId) — resolves a source column's value by calling computeValue recursively, so a computed column can source another computed column (chaining). A visited set of column ids guards against cycles: revisiting a column short-circuits to null.
  • findSource(sourceColumnId) — looks up the source Column definition (used when the computer needs column metadata, e.g. a date column's format).
  • rows and rowIndex — the full filtered dataset and the current row's position, consumed only by aggregation transformations.
  • transformationReaderMap — optional, and passed only by a caller walking rows nothing can edit mid-walk: export and range copy, statistics, the outlier sweep, and the server's dataset read (dataSourceToDataset). An aggregation walks its source column once into a per-row reader and keeps it there, so a pass over every row costs one walk per aggregation rather than one per cell. The grid's own cell reads pass none, since nothing would invalidate it (computed-value cache).

The read sites are: the row store's table headers (each header's value function calls computeValue, so UiDataTable's sorting and search operate on computed values), the cell renderer (ResourceSheetRowField), export and range copy (filterDataSourceColumns materializes computed values into plain row data before the serializers or the clipboard run, so exported CSV/JSON and a copied range include them — copy computed values), and statistics (computeColumnStatisticsForColumn, plus the outlier store reading the same values back per cell).

flowchart TD
  headers["Row store table headers<br/>(sort / global search)"] -->|value fn| CV[computeValue]
  cell["Row/Field/Index.vue<br/>(cell render)"] --> CV
  export["ExportDialog → filterDataSourceColumns<br/>(materialize before serialize)"] --> CV
  stats["computeColumnStatisticsForColumn<br/>(stats · charts · outliers)"] --> CV
  CV -->|"type ≠ Computed"| raw["row.data[column.name]"]
  CV -->|"already visited (cycle)"| cyc["null"]
  CV -->|"type = Computed"| map["ColumnTransformationComputeMap[transformation.type]"]
  map -->|"computeSource(sourceColumnId)<br/>recurse for chained computed sources"| CV
  map -->|"Aggregation"| agg["computeAggregationValue<br/>→ AggregationTransformationComputeMap"]
  map --> computer["the type's own computer<br/>one per transformation type"]

Computed columns are created and edited through the same schema form column dialog as every other column type — computedColumnFormSchema renders the transformation as a form, with per-transformation Zod validation surfacing errors before save.

Each transformation declares its output type via getComputedColumnEffectiveType: Aggregation, DatePart, and Math produce numbers; RegexMatch, String, StringPattern, and StringSplit produce strings; ConvertTo outputs its runtime targetType. The effective type drives filters, statistics, and charts for the computed column — computeColumnStatisticsForColumn reports it as the statistics row's columnType, which is what lets the chart map and the outlier sweep treat a number-producing computed column as a number column.

Transformation categories

Single-source transformations carry sourceColumnId; StringPattern carries sourceColumnIds (multi-source); Math binds sources through its variables list.

Math

A mathjs expression string with column values bound as variables:

  • expression — e.g. col0 * (1 - col1); supports the full mathjs operator set (+ - * / ^ %, comparisons) and built-in functions (abs, round, sqrt, …).
  • variables — an ordered list of { name, sourceColumnId } bindings. Names are auto-generated col0, col1, … — valid mathjs identifiers that never collide with built-ins; users insert them via the form rather than typing them.

Evaluation coerces null source values to 0 and returns null for non-finite results (NaN, Infinity). The schema validates the expression at edit time by running mathjs parse inside a superRefine, so the exact parser message (e.g. "Unexpected end of expression") surfaces as the form error.

String operations

Three string-producing variants:

  • String — a basic single-column operation selected by StringTransformationType: LowerCase, TitleCase, Trim, or UpperCase. The source value is stringified first.
  • StringSplit — splits the source string on a delimiter (default ,) and returns the segment at segmentIndex; out-of-range segments yield null. Restricted to string source columns.
  • StringPattern — multi-column templating: a pattern like {0} {1} substitutes positional {N} tokens with the values of sourceColumnIds[N] (e.g. first name + last name → full name). The schema rejects any {N} index outside the source list's bounds.

Conversion and dates

  • ConvertTo — coerces the source value to a targetType of String, Number, Boolean, or Date using the same coerceValue rules as manual type recasts.
  • DatePart — extracts a calendar field (DatePartType: Year, Month, Day, Weekday, Hour, Minute) from a date source. The source must be a Date column; its format field is used to parse the stored value, so no separate input-format setting exists on the transformation.

RegexMatch

Extracts a capture group from a string source: pattern plus groupIndex (e.g. @(.+) with group 1 pulls the domain out of an email column).

Aggregation

Dataset-level aggregates — the only category that consumes the whole row set (rows + rowIndex from the compute context). computeAggregationValue resolves the numeric values of the source column across all filtered rows, then dispatches on AggregationTransformationType to a computer that does its whole-column work once (the total, the descending ranks, the running sums) and returns the reader that answers each row:

TypeResult per row
AverageMean of all non-null source values (same for every row)
CountCount of rows with a non-null source value
MaximumLargest source value
MinimumSmallest source value
PercentOfTotalThis row's value ÷ column total × 100
Rank1-based position of this row's value, sorted descending
RunningSummationCumulative sum of source values from row 0 to this row

Non-numeric and null source cells are ignored; an all-null column yields null.

Key files

Paths relative to apps/web.

FileRole
shared/models/resource/sheet/column/ComputedColumn.tsColumn class + Zod schema
shared/models/resource/sheet/column/transformation/ColumnTransformation.tsDiscriminated union of all transformation variants
shared/models/resource/sheet/column/transformation/ColumnTransformationType.tsDiscriminant enum
shared/services/resource/sheet/column/computeValue.tsLazy resolver with inline cycle guard
app/services/resource/sheet/column/computeColumnStatisticsForColumn.tsPer-column statistics over the resolved values
shared/services/resource/sheet/column/transformation/ColumnTransformationComputeMap.tsDispatch map: transformation type → computer
shared/services/resource/sheet/column/transformation/computeAggregationValue.tsAggregation entry point (source resolution + numeric filtering)
shared/services/resource/sheet/column/transformation/AggregationTransformationComputeMap.tsPer-aggregation-type computers
shared/services/resource/sheet/column/transformation/computeMathTransformation.tsmathjs evaluate with variable scope
shared/services/resource/sheet/column/getComputedColumnEffectiveType.tsTransformation type → output ColumnType
shared/services/resource/sheet/dataSourceToDataset.tsServes computed columns in the Sheet's dataset
app/services/resource/sheet/dataSource/filterDataSourceColumns.tsMaterializes computed values for export
app/models/resource/sheet/commands/CreateComputedColumnCommand.tsUndoable create command

Datasets

A Sheet's dataset serves its computed columns like any other, so a Dashboard can bind and chart them. The compute lives in shared/, so the server runs the same computeValue the grid does: dataSourceToDataset types each computed column by getEffectiveColumnType and values it over every row of the Sheet, then serves the first rows up to the dataset cap. An aggregation therefore reads the whole Sheet, where the grid reads the rows its filters leave.

Notes

  • Values are recomputed on every read — there is no cache. Row data are plain objects with no dirty-tracking, so a cache would need an invalidation story spanning every mutation path (computed-value cache).
  • Cycle handling is deliberately inline (the visited set) rather than a separate pre-validation pass; a cycle renders as empty cells instead of an error.

Details

Command palette

Keyboard shortcuts