Cells

Formulas

Calculated columns from an expression. The expression compiles to a tree and is walked, never evaluated, with 31 functions and a dependency graph that refuses a cycle.

Basic Usage

Expressions are keyed by column id. The column needs no accessor and no value on the row: the feature computes it and writes it in. Four of the seven columns below are calculated, and withVat reads total, which is itself calculated.

SKU
Qty
Price
Total
With VAT
Band
Label
dg-100
1
$40.00
$40.00
$44.00
small
DG-1
dg-210
4
$77.00
$308.00
$338.80
medium
DG-4
dg-315
7
$114.00
$798.00
$877.80
large
DG-7
dg-420
3
$151.00
$453.00
$498.30
medium
DG-3
dg-560
6
$188.00
$1,128.00
$1,240.80
large
DG-6
dg-675
2
$225.00
$450.00
$495.00
medium
DG-2
dg-780
5
$42.00
$210.00
$231.00
medium
DG-5
dg-885
1
$79.00
$79.00
$86.90
small
DG-1
8 rows
import { createDataGrid, formula, sorting } from '@sv5ui/datagrid';

const grid = createDataGrid<Line>({
  columns: [
    { id: 'sku',     header: 'SKU' },
    { id: 'qty',     header: 'Qty',       align: 'right' },
    { id: 'price',   header: 'Price',     align: 'right', type: 'currency' },
    { id: 'total',   header: 'Total',     align: 'right', type: 'currency' },
    { id: 'withVat', header: 'With VAT',  align: 'right', type: 'currency' },
    { id: 'band',    header: 'Band' },
    { id: 'label',   header: 'Label' }
  ],
  data: lines,
  getRowId: (line) => String(line.id),
  features: [
    sorting<Line>(),
    formula({
      columns: {
        total:   '[qty] * [price]',
        withVat: 'ROUND([total] * 1.1, 2)',
        band:    'IF([total] > 500, "large", IF([total] > 200, "medium", "small"))',
        label:   'UPPER(LEFT([sku], 2)) & "-" & TEXT([qty])'
      }
    })
  ]
});

// [id] reads another column, and whitespace inside the brackets is kept, so
// [unit price] is a column with a space in its id.

The Language

31 functions and a handful of operators. Nothing else resolves: there are no variables, no assignment and no way to reach anything outside the grid.

Four limits, and three of them are refused while the expression is being read, so they arrive at onParseError rather than in a cell: 2,000 characters of source, 512 parts, and 32 levels of brackets. The fourth is a result rather than a source, and lands in the cell as #LIMIT: text longer than 32,767 characters. The first matters most where an expression is built by code rather than typed.

formula({
  columns: {
    total:    '[qty] * [price]',
    withVat:  'ROUND([total] * 1.1, 2)',
    band:     'IF([total] > 500, "large", IF([total] > 200, "medium", "small"))',
    label:    'UPPER(LEFT([sku], 2)) & "-" & TEXT([qty])',
    safeUnit: 'IF(ISBLANK([qty]), 0, [total] / [qty])',
    year:     'YEAR([placed])'
  }
});

// withVat reads total, which is itself calculated. The order is worked out
// from the expressions, so they can be declared in any order.

Functions

Read the live list at runtime from FUNCTION_NAMES, which is what to build an editor's completion from.

GroupFunctions
NumbersABS, CEILING, FLOOR, MAX, MIN, NUMBER, ROUND, SIGN, SQRT, SUM, AVERAGE
TextCONCAT, CONTAINS, LEFT, LEN, LOWER, RIGHT, STARTSWITH, TEXT, TRIM, UPPER
LogicAND, COALESCE, IF, ISBLANK, ISERROR, NOT, OR
DatesDAY, MONTH, YEAR

Operators

OperatorWhat it does
+ - * / % ^Arithmetic, including modulo and power. 7 % 3 is 1, and 2 ^ 10 is 1024
= <> < <= > >=Comparisons, returning true or false
&Joins text. "a" & "-" & "b" is a-b
[column id]Reads another column, calculated or not. The name is taken as written, so [unit price] is a column with a space in its id. A bare word is the same thing, so [qty] and qty are one expression
( )Grouping, up to 32 levels deep

Errors Are Values

A calculated column never throws at you. When an expression cannot answer, the cell holds a FormulaError and the grid prints its code. Each column below fails a different way, and the grid around them keeps working.

An error is a cell, not a column. The three rows differ, so 10 / [qty] fails on the first and answers on the other two, and [text] * 2 fails until a row holds a number. NOSUCH([qty]) and SQRT(0 - 1) fail on every row, because nothing in the data could have saved them. The quiet column is the first expression again with onError: 'blank': the same failure, written as a blank value, so the cell falls back to the column's emptyText rather than showing the code.

Text
Qty
10 / [qty]
quiet
NOSUCH([qty])
[text] * 2
SQRT(0 - 1)
abc
0
#DIV/0
-
#NAME
#VALUE
#NUM
abc
4
2.5
2.5
#NAME
#VALUE
#NUM
12
8
1.25
1.25
#NAME
24
#NUM
3 rows
import { isFormulaError } from '@sv5ui/datagrid';

// What the grid above runs. Its three rows differ, so a column is seen both
// failing and answering: qty is 0, 4, 8 and text is 'abc', 'abc', '12'.
formula({
  columns: {
    divZero:   '10 / [qty]',        // #DIV/0 on row 1 only
    badName:   'NOSUCH([qty])',     // #NAME  on every row
    wrongType: '[text] * 2',        // #VALUE until text holds a number
    negRoot:   'SQRT(0 - 1)',       // #NUM   on every row
    quiet:     { expression: '10 / [qty]', onError: 'blank' }
  }
});

// onParseError is a separate thing, and nothing above reaches it: it fires
// once at registration for an expression that will not parse at all.
formula({
  columns: { broken: '[qty] * ' },
  onParseError: (columnId, message, at) => {
    console.warn(`${columnId}: ${message} at character ${at}`);
  }
});

const value = grid.getValue(node, column);
if (isFormulaError(value)) {
  console.warn(value.code, value.detail);
  // '#CYCLE', '"total" refers back to itself'
}

The Six Codes

They read the way a spreadsheet's do. Each carries a detail saying which name or which value caused it.

CodeWhat it means
#VALUEThe wrong kind of thing, such as text where a number was needed
#DIV/0Division by zero
#NAMEA name that could not be resolved: an unknown function, or an expression that would not parse at all, in which case every cell of the column carries it. A column that does not exist is not this: it reads as blank, the same as a column that exists and is empty
#NUMA number the operation cannot produce, such as the square root of a negative
#LIMITThe result is text longer than 32,767 characters, which is what a spreadsheet cell holds
#CYCLEThe column refers back to itself, directly or through others

An expression that will not parse is reported once, at registration, through onParseError, with the message and the character it stopped at. The column is still a calculated column, and every one of its cells reads #NAME: the same code an unknown function gives, because both are a name the grid could not resolve. The two are told apart by the error's detail, which carries the parse message rather than the function name. Press Break it above and watch the Total column fill with it.

A Cycle Takes Only Itself

The feature builds a dependency graph from the expressions and refuses a cycle rather than hanging on it. Below, a and b refer to each other and read #CYCLE down their length, while fine is untouched by their argument.

Qty
a = [b] + 1
b = [a] + 1
fine = [qty] * 2
2
#CYCLE
#CYCLE
4
4
#CYCLE
#CYCLE
8
6
#CYCLE
#CYCLE
12
3 rows
formula({
  columns: {
    a: '[b] + 1',
    b: '[a] + 1',
    fine: '[qty] * 2'
  }
});

// a and b read #CYCLE down their length. fine keeps working: only the
// columns actually caught in the cycle are refused.

No eval

The expression is tokenized, parsed into a tree, and walked. There is no eval, no new Function, and no way out of the language. A page under a strict Content-Security-Policy needs no 'unsafe-eval'.

Type an expression below and apply it. This is the honest test of the claim: an expression from an untrusted keyboard, run against real rows.

Try globalThis or fetch("/admin"). The first is read as a column called globalThis, which does not exist, so the cell is blank. The second is read as a call to a function that does not exist, so the cell reads #NAME. Neither reaches anything.

SKU
Qty
Price
Total
dg-100
1
$40.00
$40.00
dg-210
4
$77.00
$308.00
dg-315
7
$114.00
$798.00
dg-420
3
$151.00
$453.00
dg-560
6
$188.00
$1,128.00
dg-675
2
$225.00
$450.00
dg-780
5
$42.00
$210.00
dg-885
1
$79.00
$79.00
8 rows
import { FUNCTION_NAMES } from '@sv5ui/datagrid';

FUNCTION_NAMES.length;   // 31, and nothing else resolves

formula({
  columns: {
    a: 'globalThis',          // a column called globalThis: the cell is blank
    b: 'fetch("/admin")',     // an unknown function: the cell reads #NAME
    c: '[qty] * '             // will not parse: onParseError fires once, and
                              // every cell of the column reads #NAME
  },
  onParseError: (columnId, message, at) => console.warn(columnId, message, at)
});

// A bare word is a column read, which is what makes a hostile expression
// inert. The parser also caps an expression at 32 levels of nesting and 512
// parts, both refused while it is being read.

Formulas Over Rows a Server Sent

A formula reads one row and writes one cell, so it does not care where the row came from. The grid below holds a hundred of 5,000 people and computes monthly and band for each block the moment it arrives.

What a formula column cannot do is travel. Sort by Salary and the server answers in a new order. Sort by Monthly, which is the same number divided by twelve, and the request goes out naming a field the server has never heard of: the rows come back in the order they were already in, and the header draws an arrow over them anyway. The grid cannot sort them itself either, because it is holding a hundred of five thousand and sorting those would put the wrong hundred at the top.

So a calculated column in server mode is either sortable: false, or it is not a formula: it is a column the backend computes and sends, which can then be ordered and filtered like any other.

POST /datagrid/api/people ? rows on the server monthly = ROUND([salary] / 12, 2)

No data

0 rows

The last few requests

  • Nothing yet. Scroll or sort.
const grid = createDataGrid<ServerRow>({
  columns, data: [], getRowId,
  rowModel: 'server',
  features: [
    sorting(),
    // Computed in the browser, per row, as each block arrives.
    formula<ServerRow>({
      columns: {
        monthly: 'ROUND([salary] / 12, 2)',
        band: 'IF([salary] > 120000, "senior", IF([salary] > 90000, "mid", "junior"))'
      }
    }),
    virtualization({ rowHeight: 40, overscan: 6 }),
    serverRowModel(source, {
      mode: 'infinite',
      blockSize: 100,
      // A formula column needs a value on a row that has not arrived, so the
      // placeholder carries one.
      placeholder: (index) => ({ ...blankPerson, id: -1 - index, monthly: 0, band: '' })
    })
  ]
});

// Sorting by salary is answered by the server. Sorting by monthly is not:
// toSortRequest sends { field: 'monthly' }, the backend has no such column,
// and the rows come back in the order they were already in.
//
// The grid cannot fall back to sorting them itself, because it holds one
// block of five thousand rows: sorting those would put the wrong hundred at
// the top and call it the answer.
//
// Two honest ways out. Say the column cannot be ordered:
{ id: 'monthly', header: 'Monthly', sortable: false }

// Or stop making it a formula, and let the backend send it as a column:
SELECT salary, ROUND(salary / 12, 2) AS monthly FROM people ORDER BY monthly

Driving It From Code

import { getFormula } from '@sv5ui/datagrid';

const calc = getFormula(grid)!;

calc.set('total', '[qty] * [price]');
calc.set('total', { expression: '[qty] * [price]', onError: 'blank' });
calc.remove('total');
calc.clear();

calc.columnIds;                  // ['total', 'withVat']
calc.expressionOf('total');      // '[qty] * [price]'
calc.isFormulaColumn('total');   // true
calc.errorOf('total');           // a parse failure, or null

grid.api.setFormula?.('total', '[qty] * [price]');
grid.api.formulaFor?.('total');