Skip to content

Write specs

Expressions

The small language behind expr, map, child counts and rules.

On this page

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:

Speccompiles with dogm 0.1.0
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#

TypeWhat it holds
intA 64-bit signed integer
decimalFixed-point, at most 18 decimal places
moneyA decimal in the currency's minor units
stringText. Write literals in single or double quotes; double the quote to include it: 'it''s'
booltrue or false
date, datetimeCounted in days, and in seconds, since 1970-01-01 UTC. There's no time-zone database
nullNo 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 rules only, table.column names 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.

FunctionArgumentsWhat it does
if3if(c, a, b): a when c is true, else b. a and b widen to one type.
coalesce1 or moreThe first argument that isn't null.
min1 or moreThe smallest argument. Numbers, strings, dates; not bool.
max1 or moreThe largest argument. Numbers, strings, dates; not bool.
abs1Absolute value of a number.
round2round(x, places): half away from zero. places is an int literal from 0 to 18.
floor1Rounds a number down to an int.
ceil1Rounds a number up to an int.
date_add2date_add(d, days): a date or datetime plus an int number of days.
minutes_add2minutes_add(t, minutes): a datetime (a date widens) plus an int number of minutes.
days_between2days_between(a, b): whole days between two dates or datetimes, as an int.
lower1Lowercase a string.
upper1Uppercase a string.
len1Length of a string, as an int.
concat1 or moreJoins its arguments into one string.

Arithmetic#

  • int with int stays int, 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.