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 callingcomputeValuerecursively, so a computed column can source another computed column (chaining). Avisitedset of column ids guards against cycles: revisiting a column short-circuits tonull.findSource(sourceColumnId)— looks up the sourceColumndefinition (used when the computer needs column metadata, e.g. a date column's format).rowsandrowIndex— 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-generatedcol0,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, orUpperCase. The source value is stringified first. - StringSplit — splits the source string on a
delimiter(default,) and returns the segment atsegmentIndex; out-of-range segments yieldnull. Restricted to string source columns. - StringPattern — multi-column templating: a
patternlike{0} {1}substitutes positional{N}tokens with the values ofsourceColumnIds[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
targetTypeofString,Number,Boolean, orDateusing the samecoerceValuerules as manual type recasts. - DatePart — extracts a calendar field (
DatePartType:Year,Month,Day,Weekday,Hour,Minute) from a date source. The source must be aDatecolumn; itsformatfield 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:
| Type | Result per row |
|---|---|
Average | Mean of all non-null source values (same for every row) |
Count | Count of rows with a non-null source value |
Maximum | Largest source value |
Minimum | Smallest source value |
PercentOfTotal | This row's value ÷ column total × 100 |
Rank | 1-based position of this row's value, sorted descending |
RunningSummation | Cumulative 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.
| File | Role |
|---|---|
shared/models/resource/sheet/column/ComputedColumn.ts | Column class + Zod schema |
shared/models/resource/sheet/column/transformation/ColumnTransformation.ts | Discriminated union of all transformation variants |
shared/models/resource/sheet/column/transformation/ColumnTransformationType.ts | Discriminant enum |
shared/services/resource/sheet/column/computeValue.ts | Lazy resolver with inline cycle guard |
app/services/resource/sheet/column/computeColumnStatisticsForColumn.ts | Per-column statistics over the resolved values |
shared/services/resource/sheet/column/transformation/ColumnTransformationComputeMap.ts | Dispatch map: transformation type → computer |
shared/services/resource/sheet/column/transformation/computeAggregationValue.ts | Aggregation entry point (source resolution + numeric filtering) |
shared/services/resource/sheet/column/transformation/AggregationTransformationComputeMap.ts | Per-aggregation-type computers |
shared/services/resource/sheet/column/transformation/computeMathTransformation.ts | mathjs evaluate with variable scope |
shared/services/resource/sheet/column/getComputedColumnEffectiveType.ts | Transformation type → output ColumnType |
shared/services/resource/sheet/dataSourceToDataset.ts | Serves computed columns in the Sheet's dataset |
app/services/resource/sheet/dataSource/filterDataSourceColumns.ts | Materializes computed values for export |
app/models/resource/sheet/commands/CreateComputedColumnCommand.ts | Undoable 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
visitedset) rather than a separate pre-validation pass; a cycle renders as empty cells instead of an error.