Computed fields
A computed field is derived rather than supplied, the engine evaluates an expression and either returns the result on read or stores it in a real column.
A field is computed when it declares computed:, script:, or a compute: block. The
expression is evaluated by the script runtime. It is not pushed into SQL, so SQL syntax in
a computed expression does not evaluate.
| Strategy | Evaluated | Persisted |
|---|---|---|
virtual | on read, after rows are scanned | no |
materialized | on create and update: after the field’s transforms have run, before the statement is compiled | yes, into a real column |
The strategy lives in the compute: block as strategy:; the flat compute-strategy: sibling
is the older spelling and still works. Any token other than virtual, including a typo such
as virtaul, resolves to materialized, and so does omitting it, whichever declaration form
you used. A block that declares triggers: is materialized by definition.
fields: - name: takehome type: int computed: "basic + bonus - (professionaltax + incometax)" compute-strategy: materialized
- name: display_name type: string nullable: true compute: language: expr expression: "first_name + ' ' + last_name" compute-strategy: virtualBindings available to the expression are the same on both paths: every field of the row (or
payload) by its bare name, the whole row as row, plus entity (the entity name), tenant and
user_id. So qty * unit_price and row.qty * row.unit_price are equivalent, the four reserved
names win over a column that happens to share one, a column called entity is only reachable
as row.entity.
language: accepts expr (the default), cel, js, javascript, lua, starlark,
go and wasm. The body goes in expression: for a one-liner or script: for a full
program; function: names the entry point for the module languages. On data.svc only the languages the deployment offers run — by default expr, cel, js / javascript, starlark and wasm (scripts.runtimes, see Configuration). lua cannot be offered yet and go is not available on data.svc; a schema that uses a language not offered is refused when the tenant’s engine is built.
The data.* service namespace, data.query, data.count, data.aggregate and the rest,
is injected only for js / javascript, lua, starlark and go, an expr, cel or
wasm body cannot reach it.
Two further limits are to check before you rely on computed values:
- The engine never emits
GENERATED ALWAYS AS … STORED. Derived values are the engine’s responsibility, so anything writing to the table with direct SQL bypasses computation. - An expression that fails to evaluate does not fail the request. The row is served, the field
is left absent, and the failure is reported in the envelope’s
warnings[]ascompute_failedwithpathnaming the field, on the create or update for a materialized field, on every read for a virtual one. Watch for it: a warning is easy to ignore in a client that only checks the status.
Choosing a language
Section titled “Choosing a language”| Language | Reaches data.* | Use it for |
|---|---|---|
expr (default) | no | Arithmetic, concatenation, conditionals over the current row |
cel | no | The same, when you already write CEL elsewhere |
js / javascript | yes | Anything needing other rows, loops, or real string work |
starlark | yes | Same capability as JavaScript; pick on team familiarity |
lua | — | Not offered on data.svc yet: it cannot be confined |
go | — | Not available on data.svc |
wasm | no | Precompiled modules |
The line that matters: expr and cel see only the row in front of them. The moment a field
depends on another entity, it has to be one of the scripting languages, and it has to be
materialized with triggers.
Examples: same row, with expr
Section titled “Examples: same row, with expr”expr is the default and the right choice for most computed fields. No language key needed.
Line total on an invoice line:
- name: line_total type: double computed: "qty * unit_price"A total with tax, rounded to whole currency units:
- name: gross_total type: double computed: "round((qty * unit_price) * (1 + tax_rate), 2)"Full name, tolerating a missing middle name:
- name: full_name type: string nullable: true compute-strategy: virtual computed: "first_name + (middle_name != nil ? ' ' + middle_name : '') + ' ' + last_name"Take-home pay:
- name: takehome type: double computed: "basic + bonus - (professional_tax + income_tax + provident_fund)"A margin percentage, guarding the divide:
- name: margin_pct type: double nullable: true computed: "revenue > 0 ? ((revenue - cost) / revenue) * 100 : 0"That guard is not decoration. An expression that fails does not fail the request, the field is
left absent and the request carries a compute_failed warning, so a divide-by-zero produces a
missing value and a warning, not a 4xx. Guard the arithmetic rather than relying on the warning
being noticed.
A display label built from parts:
- name: display_ref type: string nullable: true compute-strategy: virtual computed: "'INV-' + string(year) + '-' + string(sequence)"Examples: predicates, with cel
Section titled “Examples: predicates, with cel”CEL suits a boolean or a small classification, and reads well when the condition is the point.
Whether an order qualifies for free shipping:
- name: free_shipping type: bool compute: language: cel expression: "row.subtotal >= 5000.0 && row.country == 'IN'"A risk band:
- name: risk_band type: string nullable: true compute-strategy: virtual compute: language: cel expression: > row.credit_score >= 750 ? 'low' : (row.credit_score >= 600 ? 'medium' : 'high')Overdue, from a date already on the row:
- name: is_overdue type: bool compute: language: cel expression: "row.due_date < row.as_of_date && row.status != 'paid'"Note the shape: these read through row., which every language can do; a bare field name works
just as well in every language and on both strategies. Pick one style per schema and stick to it.
Examples: other entities, with JavaScript
Section titled “Examples: other entities, with JavaScript”Once a field depends on rows the current entity does not hold, you need a scripting language, a
materialized strategy, and triggers so the stored value is refreshed when the source changes.
How many completed orders a customer has:
- name: completed_orders type: int nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | return data.count("orders", { customer_id: row.id, status: "completed" }); triggers: - entity: orders on: [create, update, delete] affected_via: customer_id when_columns: [customer_id, status]when_columns is what keeps this cheap: an order whose shipping address changes does not enqueue a
refresh, because neither listed column moved.
Lifetime value, summing a related entity:
- name: lifetime_value type: double nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | const orders = data.query("orders", { customer_id: row.id, status: "completed" }); return orders.reduce((sum, o) => sum + (o.total || 0), 0); triggers: - entity: orders on: [create, update, delete] affected_via: customer_id when: language: expr expression: "row.status == 'completed'"The when predicate is a second gate on top of on: a draft order never enqueues a refresh at all,
so the queue only carries work that can change the answer.
A denormalised label from a parent, kept current:
- name: customer_name type: string nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | const c = data.find_by_id("customers", row.customer_id); return c ? c.name : null; triggers: - entity: customers on: [update] affected_via: id when_columns: [name]This is the classic reason to reach for materialization: the join is paid once on write instead of on every read, and the trigger keeps it honest when the customer is renamed.
A stock position across warehouses:
- name: total_on_hand type: int nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | const rows = data.query("inventory", { sku: row.sku }); return rows.reduce((n, r) => n + (r.on_hand || 0), 0); triggers: - entity: inventory on: [create, update, delete] affected_via: sku when_columns: [sku, on_hand]Aggregation without pulling rows:
- name: avg_rating type: double nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | const res = data.aggregate("reviews", { filter: { product_id: row.id, published: true }, avg: "rating" }); return res ? res.avg : null; triggers: - entity: reviews on: [create, update, delete] affected_via: product_id when_columns: [product_id, rating, published]Prefer data.aggregate to querying and summing in the script when you only need the number, it
does the work in the database rather than moving every row into the runtime.
Picking a strategy
Section titled “Picking a strategy”| Ask | Answer |
|---|---|
| Does it depend only on this row, and is it cheap? | virtual. Nothing to keep in sync |
| Is it read far more often than written? | materialized |
| Does it depend on another entity? | materialized, with triggers. There is no other option |
| Do you need to filter or sort by it in a query? | materialized. A virtual field has no value in the column to filter on |
That last row is the one people hit late. A virtual field is computed after rows are scanned, so
the database cannot use it in a WHERE or an ORDER BY.
Cross-entity refresh triggers
Section titled “Cross-entity refresh triggers”A materialized field can declare the source entities whose mutations invalidate it. Same entity computation needs no triggers.
| Key | Meaning |
|---|---|
entity | Source entity. Required. |
on | create, update, delete. Empty matches all three. |
affected_via | Single source column selecting the target row. |
affected_via_cols | Composite key columns into the target row. |
affected_via_expr | Script expression deriving the target key. |
when_columns | Update-only: skip the refresh when none of these columns changed. |
when | Script predicate gate. Empty always enqueues. |
Exactly one of affected_via, affected_via_cols and affected_via_expr may be set;
more than one is a load error, as is a trigger naming an unknown entity or sitting on a
field that is not materialized.
fields: - name: order_count type: int nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | return data.count("orders", { customer_id: row.id, status: "completed" }); triggers: - entity: orders on: [create, update, delete] affected_via: customer_id when_columns: [customer_id, status] when: language: expr expression: "row.status == 'completed'"Cycle detection
Section titled “Cycle detection”The trigger graph is checked when the schema loads. Two things happen there, and neither is a runtime concern:
A cyclic graph is refused. If orders triggers a refresh on customer.order_total, and
customers triggers one back on orders.customer_tier, the schema fails to load and the error
names the full cycle path. This is a load-time refusal rather than a runtime guard because a cycle
has no correct behaviour to fall back to. It would refresh forever.
A missing trigger is warned about. If a compute script appears to reference an entity that is
not in its own triggers: list, the loader says so:
compute: language: javascript script: | return data.count("orders", { customer_id: row.id }); triggers: - entity: invoices # orders is referenced but not triggered on: [create] affected_via: customer_idThat is the “I forgot to add the trigger” case, and it is worth a warning rather than an error because the reference may be deliberate. Left alone, the field simply goes stale, no error, no failed request, just a number that stops moving. That is the hardest kind of bug to notice, which is why the loader looks for it.
See also
Section titled “See also”- Fields: every other field key
- Materialization: how the refresh queue behaves at runtime, and its limits
- Transforms: changing a supplied value rather than deriving one
- Scripted endpoints: the
data.*namespace a script body can call