Write specs
Expressions
The small language behind expr, map, child counts and rules.
One small language serves expr columns, map, rows_per_parent.count, expression-valued generator parameters and rules. The compiler parses and type-checks every expression, so a typo or a type mismatch is a compile error with a path, not a surprise halfway through a run. There are no loops, no user-defined functions, no I/O and no randomness: when a value needs both chance and logic, put the random part in a hidden generated column and compute from it.
An example#
Invoices with due dates, VAT, an optional discount and three rules. It compiles as is:
spec_version: 1
tables:
invoices:
rows: 100
primary_key: { column: invoice_id, format: "INV-{n:05}" }
columns:
customer: { gen: person.full_name }
issued_on: { gen: date, between: [2026-01-01, 2026-06-30] }
terms_days: { gen: choice, values: [15, 30, 60] }
due_on: { expr: "date_add(issued_on, terms_days)" }
net_php: { gen: money, between: [500, 25000] }
vat_php: { expr: "round(net_php * 0.12, 2)", type: money }
total_php: { expr: "net_php + vat_php" }
label: { expr: "upper(customer) || ' / ' || invoice_id" }
discount: { gen: decimal, between: [0, 0.2], nullable: 0.3 }
final_php: { expr: "round(total_php * (1 - coalesce(discount, 0)), 2)", type: money }
size: { expr: "if(total_php >= 10000, 'large', 'small')" }
rules:
- "invoices.due_on > invoices.issued_on"
- "days_between(invoices.issued_on, invoices.due_on) == invoices.terms_days"
- "invoices.final_php <= invoices.total_php"
Values#
| Type | What it holds |
|---|---|
int | A 64-bit signed integer |
decimal | Fixed-point, at most 18 decimal places |
money | A decimal in the currency's minor units |
string | Text. Write literals in single or double quotes; double the quote to include it: 'it''s' |
bool | true or false |
date, datetime | Counted in days, and in seconds, since 1970-01-01 UTC. There's no time-zone database |
null | No value |
References#
column: a column defined earlier in the same table.prev.column: the same column of the previous sibling row; null on the first one.ordinal: the row's 0-based position among its siblings, or its row number in a root table.parent.column,parent.parent.column: columns up the owning-parent chain.- In
rulesonly,table.columnnames the table the rule runs on.
Reading a column before it's defined is E_EXPR_FORWARD_REF; a name that doesn't exist is E_EXPR_UNKNOWN_COLUMN, usually with a "did you mean" hint.
Operators#
- Arithmetic:
+,-,*,/,% - Comparison:
==,!=,<,<=,>,>= - Logic:
and,or,not - String concatenation:
||
Functions#
Generated from the function table in the engine's evaluator. Arguments of if, coalesce, min and max widen to one type: an int and a decimal give a decimal, a date and a datetime give a datetime.
| Function | Arguments | What it does |
|---|---|---|
if | 3 | if(c, a, b): a when c is true, else b. a and b widen to one type. |
coalesce | 1 or more | The first argument that isn't null. |
min | 1 or more | The smallest argument. Numbers, strings, dates; not bool. |
max | 1 or more | The largest argument. Numbers, strings, dates; not bool. |
abs | 1 | Absolute value of a number. |
round | 2 | round(x, places): half away from zero. places is an int literal from 0 to 18. |
floor | 1 | Rounds a number down to an int. |
ceil | 1 | Rounds a number up to an int. |
date_add | 2 | date_add(d, days): a date or datetime plus an int number of days. |
minutes_add | 2 | minutes_add(t, minutes): a datetime (a date widens) plus an int number of minutes. |
days_between | 2 | days_between(a, b): whole days between two dates or datetimes, as an int. |
lower | 1 | Lowercase a string. |
upper | 1 | Uppercase a string. |
len | 1 | Length of a string, as an int. |
concat | 1 or more | Joins its arguments into one string. |
Arithmetic#
intwithintstaysint, except/, which gives a decimal with 6 places.- A decimal result keeps the larger scale of its operands.
- Rounding is half away from zero everywhere, the way invoices round.
- Division by zero gives null.
- Overflow stops the run with an error that names the table, column and row.
Nulls#
Logic is three-valued, as in SQL. null passes through operators, comparisons with null are null, null and false is false, and null or true is true.
That matters for rules: a rule fails only when it's false, so a row where the rule evaluates to null passes. Comparing with null never catches a missing value, because x != null is itself null. Wrap the column in coalesce when a missing value should fail the rule: "coalesce(orders.total_php, -1) >= 0".
What isn't there#
Aggregates over children, such as a customer's total spent or a check that reply_count matches the number of replies, aren't in this version. Make them true by construction instead: count: "parent.reply_count" creates exactly that many replies.
An expression is at most 1,000 characters.
