Calculated Fields
Calculated fields automatically calculate their values from other fields.
Basic Calculated Field
Section titled “Basic Calculated Field”from fullfinity.engine.base import *
class Order(Model): quantity = Integer(default=1) unit_price = Monetary(default=0.0)
# Calculated field total = Monetary( calculate="_compute_total", store=True )
@Model.calculate("quantity", "unit_price") async def _compute_total(self): for record in self: record.total = record.quantity * record.unit_priceA calculate method receives the whole recordset — iterate it
Section titled “A calculate method receives the whole recordset — iterate it”Assign in every branch — nothing checks this for you. A field your method declares but never assigns on some record is left unset rather than reported, so a conditional must cover its else case with an explicit value. Don’t rely on the field “staying” at a default:
# CORRECT — the else branch assigns an explicit value@Model.calculate("delivery_address")async def _compute_delivery_label(self): for record in self: if record.delivery_address: record.delivery_label = record.delivery_address.name else: record.delivery_label = "" # ← explicit; not "leave it unset"The @calculate Decorator
Section titled “The @calculate Decorator”Declares which fields trigger recomputation:
@Model.calculate("quantity", "unit_price")async def _compute_total(self): for record in self: record.total = record.quantity * record.unit_priceWhen quantity or unit_price changes, _compute_total is called automatically.
Calculated fields are readonly by default
Section titled “Calculated fields are readonly by default”A calculated field’s value belongs to its calculate method, not to the user — so
readonly defaults to True for any field with a calculate and no setter.
You don’t declare it, and you shouldn’t repeat it in the view:
# Readonly everywhere — forms, list inline editing, and import. Nothing to declare.total = Monetary(calculate="_compute_total", store=True)
# Writable: the setter routes the write back to whatever the value derives from.full_name = Char(calculate="_compute_full_name", setter="_set_full_name")
# Explicit always wins, in either direction.estimate = Float(calculate="_compute_estimate", readonly=False) # user may overrideThis is the same rule related_field already follows, and for the same reason: without
it a write is accepted and then discarded by the next recalculate, so a form input on
such a field is a dead end — the user types a number, saves, and it reverts the moment
any dependency changes.
The flag travels in the model metadata, so every surface honours it from one declaration: form inputs render read-only, list cells refuse inline editing, and the import endpoint drops the column rather than reporting a success that never landed.
Stored vs Non-Stored
Section titled “Stored vs Non-Stored”Stored (store=True)
Section titled “Stored (store=True)”- Saved in database
- Computed on save
- Can be filtered/sorted
- Better for frequently accessed data
total = Monetary(calculate="_compute_total", store=True)Non-Stored (store=False)
Section titled “Non-Stored (store=False)”- Computed on-the-fly
- Not in database
- Cannot be filtered
- Better for derived display values
- Only hydrated on root-level records by default
display_name = Char( max_length=255, calculate="get_display_name", store=False):::warning Hydration Depth
Non-stored calculated fields are only automatically hydrated on root-level query results. For nested records (via prefetch_related), you must explicitly request them using with_fields().
# Root level: margin is hydrated ✓products = await Product.filter().all()print(product.margin) # Works
# Nested: margin is NOT hydrated ✗order = await Order.filter(id=1).prefetch_related("lines__product").first()print(order.lines[0].product.margin) # Raises AttributeError!
# Fix: Use with_fields to explicitly request nested calculated fieldsorder = await Order.filter(id=1).with_fields("lines__product__margin").first()print(order.lines[0].product.margin) # Works ✓See Querying Data - Select Specific Fields for more details. :::
Chained Dependencies
Section titled “Chained Dependencies”Calculated fields can depend on other calculated fields:
class Order(Model): quantity = Integer(default=1) unit_price = Monetary(default=0.0) discount_percent = Float(default=0.0) tax_rate = Float(default=10.0)
# Subtotal depends on quantity and price subtotal = Monetary(calculate="_compute_subtotal", store=True)
# Discount depends on subtotal discount_amount = Monetary(calculate="_compute_discount", store=True)
# Total depends on subtotal, discount, and tax total = Monetary(calculate="_compute_total", store=True)
@Model.calculate("quantity", "unit_price") async def _compute_subtotal(self): for record in self: record.subtotal = record.quantity * record.unit_price
@Model.calculate("subtotal", "discount_percent") async def _compute_discount(self): for record in self: record.discount_amount = record.subtotal * (record.discount_percent / 100)
@Model.calculate("subtotal", "discount_amount", "tax_rate") async def _compute_total(self): for record in self: after_discount = record.subtotal - record.discount_amount record.total = after_discount * (1 + record.tax_rate / 100)Reacting when a calculated field changes
Section titled “Reacting when a calculated field changes”Override update(). A stored calculated field reaches it like any other write — whether a caller
set the value or the engine derived it — so one override covers both:
class InvoiceHook(Model): __inherit__ = "FinancialDocument"
async def update(cls, records, **vals): result = await super().update(records, **vals) if vals.get("payment_status") == "Paid": for record in records: await record.notify_accounts() return resultThis is the seam for anything that follows from a derived value: an invoice becoming fully paid,
a stock rollup crossing a reorder point, an order total passing an approval threshold. You do not
need to know what caused the recalculate — a payment reconciling three models away still arrives
here as payment_status in vals.
Two things to keep in mind:
- Make the reaction idempotent. A recalculate can report the same value more than once for a single business event, so key your effect on something stable rather than assuming one call.
- Don’t write the same field back. It is a function of its dependencies; the next recalculate overwrites whatever you set. React by writing elsewhere.
Depending on Relations
Section titled “Depending on Relations”Compute from related records. fetch_related can stay outside the loop — it batches the
fetch across the whole recordset — then assign per record inside:
class Invoice(Model): customer = ManyToOne("Contact", related_name="invoices")
# Depends on lines (OneToMany) total = Monetary(calculate="_compute_total", store=True)
@Model.calculate("lines", "lines__amount") async def _compute_total(self): await self.fetch_related("lines") # batches for every record for record in self: record.total = sum(line.amount for line in (record.lines or []))
class InvoiceLine(Model): invoice = ManyToOne("Invoice", related_name="lines") quantity = Integer(default=1) unit_price = Monetary() amount = Monetary(calculate="_compute_amount", store=True)
@Model.calculate("quantity", "unit_price") async def _compute_amount(self): for record in self: record.amount = record.quantity * record.unit_priceThe dependency can be a ManyToMany just as well — declare the M2M field, and a
store=True value recalculates whenever the set of linked records changes (a member is linked
or unlinked), not only at create. This makes a stored, indexed signature of a relation a
reliable lookup key:
class Basket(Model): tags = ManyToMany("Tag", related_name="baskets")
# A canonical, indexed signature of the linked tags — filter by it to find the basket # for a given tag combination. It stays correct as tags are added or removed. tag_signature = Char(calculate="_compute_tag_signature", store=True, index=True)
@Model.calculate("tags") async def _compute_tag_signature(self): for record in self: await record.fetch_related("tags") ids = sorted(tag.id for tag in (record.tags or [])) record.tag_signature = ":".join(str(i) for i in ids)Read the relation with fetch_related (or iterate the field) inside the method — the value
you compute always reflects the relation’s current members, including links written after
the record was first created.
Related Fields
Section titled “Related Fields”Simpler alternative for single-value lookups:
class Contact(Model): company = ManyToOne("Company", related_name="contacts")
# Auto-computed from relation company_name = Char( max_length=255, related_field="company__name", store=False )
country_code = Char( max_length=10, related_field="company__country__code", store=False )Display Name
Section titled “Display Name”get_display_name is a calculated field like any other — it receives the recordset and must
assign display_name on every record. It returns nothing: the framework supplies the
method’s return value, so an implementation only populates the field.
class Contact(Model): first_name = Char(max_length=100) last_name = Char(max_length=100) email = Char(max_length=255)
async def get_display_name(self): for record in self: if record.first_name and record.last_name: record.display_name = f"{record.first_name} {record.last_name}" elif record.email: record.display_name = record.email else: record.display_name = f"Contact #{record.id}"Count/Aggregate Fields
Section titled “Count/Aggregate Fields”A statistic over a relation is not a compute — declare it with aggregate= and the
database resolves it. No method, no fetch_related, and no child rows fetched:
class TaskList(Model): name = Char(max_length=255)
task_count = Integer(aggregate=Count("tasks"), store=False) completed_count = Integer(aggregate=Count("tasks", filter=Q(status="Done")), store=False) progress = Float(calculate="_compute_progress", store=False)
@Model.calculate("task_count", "completed_count") async def _compute_progress(self): for record in self: total = record.task_count or 0 record.progress = ((record.completed_count or 0) / total * 100) if total else 0Both counts share one grouped query with a FILTER clause each, and progress is then
arithmetic over two numbers this model already has — no second pass over materialised
children. See Relationships for the full aggregate= rules.
The version to reach for a compute is the one that isn’t an aggregate: a ratio, a weighted figure, anything needing the children themselves.
One method can still calculate several fields — but then it must assign all of them on every record, in every branch. Nothing verifies this for you: a declared field the method skips is simply left unset.
@Model.calculate("lines", "lines__quantity", "lines__unit_price") async def _compute_totals(self): await self.fetch_related("lines") for record in self: lines = record.lines or [] record.net = sum((l.quantity or 0) * (l.unit_price or 0) for l in lines) record.heaviest = max((l.weight or 0) for l in lines) if lines else 0One query per PAGE, never per record
Section titled “One query per PAGE, never per record”A compute is handed the whole page: self is the recordset, and every record on screen is
computed in one call. So a query written inside the loop costs a round trip per row:
# WRONG — one query per record. A 40-row list issues 40 queries to fill one column,# and because the field is non-stored it pays that again on every render.@Model.calculate()async def _compute_open_count(self): for record in self: record.open_count = await Ticket.filter(team=record, closed=False).count()Most calculated fields are store=False, so this is not a one-off cost at write time — it is
paid on every list render, every time. A compute doing two or six such queries multiplies
from there. It will not show up in a unit test, which computes a single record; it shows up
as a slow page that is hard to attribute to anything.
This is enforced: ./fullfinity-server check --only computes (part of the default
check) fails the build on a query inside for ... in self. There are three right answers.
1. Declare an aggregate= on the field — the best option when it fits. The database
groups it, no child rows are fetched, and several statistics over the same relation coalesce
into one query:
open_count = Integer(aggregate=Count("tickets", filter=Q(closed=False)), store=False)An aggregate needs a declared collection, and its filter may only test the child’s own
columns — a SQL FILTER clause has nowhere to put a join, so Q(stage__is_done=False) is
refused rather than silently ignored.
2. Group ONE query over the page’s ids — for exactly that case, where the filter walks a relation:
@Model.calculate()async def _compute_open_count(self): records = [record for record in self] for record in records: record.open_count = 0 ids = [record.id for record in records if record.id] if not ids: return
rows = ( await Ticket.filter(Q(team__in=ids), stage__is_done=False) .order_by() .annotate(open_count=Count()) .group_by("team__id") .all() ) by_team = {row["team__id"]: row.get("open_count") or 0 for row in rows} for record in records: record.open_count = by_team.get(record.id, 0)3. await self.fetch_related(...) — when the rows themselves are needed. On a recordset
this loads the relation for the whole page, one query per level, so it is not flagged:
@Model.calculate("lines", "lines__stage")async def _compute_done_lines(self): await self.fetch_related("lines", "lines__stage") # one query per level, whole page for record in self: record.done_lines = sum(1 for l in (record.lines or []) if l.stage and l.stage.is_done)record.fetch_related(...) inside the loop is equally fine — on a record belonging to a
loaded page it loads the relation for that whole page, not for the one row.
Conditional Computation
Section titled “Conditional Computation”class Product(Model): type = Selection(choices=["Physical", "Digital"], default="Physical") weight = Float(default=0.0) file_size = Integer(default=0)
shipping_required = Boolean(calculate="_compute_shipping", store=True)
@Model.calculate("type") async def _compute_shipping(self): for record in self: record.shipping_required = record.type == "Physical"Best Practices
Section titled “Best Practices”1. Always Use the @Model.calculate Decorator
Section titled “1. Always Use the @Model.calculate Decorator”Every calculated field method must have the @Model.calculate decorator. Without it, the system won’t know to call your method:
# Good - decorator present@Model.calculate("quantity", "unit_price")async def compute_total(self): for record in self: record.total = record.quantity * record.unit_price
# Bad - missing decorator (raises error at startup!)async def compute_total(self): for record in self: record.total = record.quantity * record.unit_priceFor fields with no dependencies (computed from external sources), use empty parentheses:
@Model.calculate()async def compute_is_current(self): Website = get_model("Website") for record in self: website = await Website.filter(theme__id__eq=record.id).first() record.is_current = website is not None2. Iterate the recordset and assign every field on every record
Section titled “2. Iterate the recordset and assign every field on every record”See the recordset rule above.
Loop for record in self:, write record.<field>, and cover every branch with an explicit
value. Assigning on the recordset itself (self.<field> = ...) raises
CalculateAssignmentError; leaving a declared field unassigned on some record is not
reported, which is why every branch needs an explicit value.
3. Declare All Dependencies
Section titled “3. Declare All Dependencies”# Good@Model.calculate("quantity", "unit_price", "discount")async def _compute_total(self): for record in self: record.total = (record.quantity * record.unit_price) - record.discount
# Bad - missing dependency@Model.calculate("quantity", "unit_price") # Missing "discount"!async def _compute_total(self): for record in self: record.total = (record.quantity * record.unit_price) - record.discount4. Never query once per record
Section titled “4. Never query once per record”# Good - fetch_related batches across the whole recordset@Model.calculate("lines")async def _compute_line_count(self): await self.fetch_related("lines") for record in self: record.line_count = len(record.lines or [])
# Better - the database counts it, and no child rows are fetched at allline_count = Integer(aggregate=Count("lines"), store=False)
# Bad - one query per row on screen. Fails `check --only computes`.@Model.calculate("lines")async def _compute_line_count(self): for record in self: record.line_count = await Line.filter(order=record).count()See One query per PAGE, never per record for the three sanctioned shapes.
5. Use store=False for Display-Only
Section titled “5. Use store=False for Display-Only”# Good - not stored, just for displaydisplay_name = Char(calculate="get_display_name", store=False)
# Consider storing if you need to filter/sortsearchable_name = Char(calculate="get_searchable_name", store=True, index=True)6. Handle None Values
Section titled “6. Handle None Values”@Model.calculate("quantity", "unit_price")async def _compute_total(self): for record in self: qty = record.quantity or 0 price = record.unit_price or 0 record.total = qty * priceNext Steps
Section titled “Next Steps”- Querying Data - Filter and search records
- Defining Models - Model structure