Relationships
Fullfinity supports three types of relationships between models.
A relation is a record on the form path, an id on the data path
Section titled “A relation is a record on the form path, an id on the data path”The same field is handed over two different ways depending on which side of the form boundary you are on, and mixing them fails only when someone opens the screen.
A ManyToOne default returns the record:
# Correct — the form can label the pickerowner = ManyToOne("User", default=lambda self: get_current_user(self), ...)
# Wrong — an id hydrates into a stub with no name, and the form refuses to render itowner = ManyToOne("User", default=lambda self: env.user_id, ...)A new-record form hydrates the default in memory. An id becomes an {id}-only stub with no
display name, so the picker would show the raw record number — and because the row is
transient, nothing can resolve it later.
_default_get returns records too, so take the id off before querying with it:
async def _default_get(cls, context=None): defaults = await super()._default_get(context) board = defaults.get("board") # this is a RECORD if board: board_id = board if isinstance(board, int) else board.id ...Seed a form through the context, not through values. Put default_<field> in the
action’s context and the server resolves it into the record for you:
return await get_action( identifier="board_card_action", ctx={"default_board": self.id}, # resolved to the record; the picker gets its label)Passing the value directly instead pins the bare id as an explicitly-set value, with the same result as a bad default.
Ids remain correct everywhere else — Model.create(board=4), Q(company_id=3),
record.field_id. This rule is about the form boundary only.
ManyToOne
Section titled “ManyToOne”A foreign key relationship where many records point to one.
Definition
Section titled “Definition”class Order(Model): customer = ManyToOne( "Contact", # Related model related_name="orders", # Reverse relation name on_delete="CASCADE", # Delete behavior required=True )on_delete Options
Section titled “on_delete Options”| Option | Behavior |
|---|---|
CASCADE | Delete related records |
SET NULL | Set field to NULL |
RESTRICT | Prevent deletion if related records exist |
on_delete is enforced by the database, as part of the foreign key the framework creates
for the relation — deleting a record issues a plain DELETE, and Postgres performs the
cascade or the null. This holds wherever the relation is declared: a relation you contribute
to another module’s model with __inherit__ gets its foreign key exactly like one declared on
your own model, so its on_delete behaves identically.
The practical consequence is that the behaviour is real rather than advisory — a RESTRICT
relation makes the delete fail, and a CASCADE relation removes children the ORM never
loaded. Choose the option for what should happen to the data, not for what your code happens
to do before deleting.
# Create with relationcontact = await Contact.filter(id=1).get()order = await Order.create(customer=contact, name="ORD-001")
# Or by IDorder = await Order.create(customer=1, name="ORD-002")
# Access related objectorder = await Order.filter(id=1).get()await order.fetch_related("customer")print(order.customer.name)print(order.customer.id) # read the related id via the fetched record
# Filter by relationorders = await Order.filter(customer__name__icontains="Acme").all()Scoping the picker — filter=
Section titled “Scoping the picker — filter=”A relation’s dropdown offers every record of the related model. That is rarely what the field means: a payroll journal is a Miscellaneous journal, a cart-recovery email needs a template written against SaleOrder, a lot has to belong to the line’s product. Declare that scope on the field and every picker bound to it inherits it:
payroll_journal = ManyToOne("Journal", related_name="payroll_configs", on_delete="SET NULL", description="Payroll Journal", filter="Q(type='Miscellaneous')")
lot = ManyToOne("Lot", related_name="transfer_lines", on_delete="RESTRICT", description="Lot / Serial", filter="Q(product=product_variant__product)")The scope is a Q-expression string, not a built Q object, because the right-hand side
routinely names a field of the record being edited — a value that does not exist until
there is a record on screen. ManyToOne, OneToOne and ManyToMany accept it;
OneToMany does not (see below).
Left of the = is the related model; right of it is the record being edited.
Q(product=product_variant__product) searches Lot for rows whose product matches the
editing record’s product_variant.product. Inside an embedded line, _parent. reaches
the parent form: Q(company=_parent.company). Both are resolved against the live form
values, so the options follow what the user has typed.
A reference with no value drops its condition, leaving the rest of the scope to apply.
Before a product is chosen, “lots belonging to this line’s product” has no subject, so it
stops constraining rather than emptying the picker — a dropdown with no options reads to
the user as “there is no data” and nothing explains otherwise. “No value” means null or
missing, not falsy: an unticked Boolean is a real answer and still narrows. Where a pairing
must genuinely hold, enforce it with a Model.constraint at the write, which can say why —
a picker hiding rows is an affordance, never a guarantee.
Declaring it on the field, rather than in each view, is the point: view composition stamps
it onto every relation picker bound to the field — Form, List, embedded line grid, Kanban,
Search — so a view inherits the scope without saying anything, exactly as it inherits the
field’s description, required and groups. A view added next year cannot forget it.
A view still has the last word, because a few scopes really are per-screen — one
contact field offers customers on an invoice and vendors on a bill:
| The view’s node | What the picker uses |
|---|---|
no filter: | the field’s declared scope |
filter: Q(...) | the view’s scope, instead of the field’s — not intersected |
filter: false | nothing; deliberately unscoped here |
Override rather than intersection, because the two-scopes case above would AND to an empty dropdown. If a view narrows further, write the whole condition.
A mistyped scope has no loud failure mode — it reaches the query, matches nothing, and shows an empty dropdown with no explanation. So both halves are checked for you: each Q key must resolve on the target model, and each right-hand reference on the source model (that side names the record being edited, not the model being searched).
Three places run that check, all off one validator, so they cannot disagree:
| Where | Covers | When |
|---|---|---|
check --only refs | every filter= declared on a field | pre-commit / CI, no database |
| Saving a view | a view’s own filter: property | at save, and at install/-u |
core/tests/test_relation_filters.py | the declared set, plus that composition really stamps it | test suite |
Literals, ambient bindings (uid, cid, term) and _parent. paths are values rather
than fields of the source model, so they are skipped rather than resolved.
Why OneToMany has no filter=
Section titled “Why OneToMany has no filter=”A OneToMany renders as an embedded grid of records that already exist, where filter
means which of these rows to show — a different question that happens to share a name,
and one that belongs to the screen showing them. Declaring it raises; author it on the
view node instead.
OneToOne
Section titled “OneToOne”A one-to-one relationship, similar to ManyToOne but with a UNIQUE constraint.
Definition
Section titled “Definition”class User(Model): name = Char(max_length=255) profile = OneToOne( "UserProfile", related_name="user", on_delete="CASCADE" )Key Differences from ManyToOne
Section titled “Key Differences from ManyToOne”| Aspect | ManyToOne | OneToOne |
|---|---|---|
| Constraint | Foreign key | Foreign key + UNIQUE |
| Forward access | Single object | Single object |
| Reverse access | List of objects | Single object |
# Create with relationprofile = await UserProfile.create(bio="Developer")user = await User.create(name="John", profile=profile)
# Access forward (same as ManyToOne)user = await User.filter(id=1).get()await user.fetch_related("profile")print(user.profile.bio)
# Access reverse (returns single object, not list)profile = await UserProfile.filter(id=1).get()await profile.fetch_related("user")print(profile.user.name) # Single object, not listOneToMany
Section titled “OneToMany”The reverse of ManyToOne. Automatically created from related_name.
Access
Section titled “Access”contact = await Contact.filter(id=1).get()await contact.fetch_related("orders")
for order in contact.orders: print(f"Order: {order.name}")Statistics over a relation — aggregate=
Section titled “Statistics over a relation — aggregate=”A count, total, average or extreme over a collection is declared on the field and resolved
by the database. No calculate method, no fetch_related, and no child rows crossing the
wire:
lesson_count = Integer(aggregate=Count("lessons"), store=True)certified_count = Integer(aggregate=Count("enrollments", filter=Q(state="Certified")), store=True)posted_total = Monetary(aggregate=Sum("lines__amount", filter=Q(state="Posted")), currency_field="currency", store=True)last_activity = Datetime(aggregate=Max("events__occurred_at"), store=False)The argument names a relation on this model — Sum/Avg/Min/Max add the child
field to aggregate (lines__amount). That is different from what the same class means in
annotate(), where it names a column of the table being queried, and the difference
matters because the query here runs against the child.
Why it is not just tidier. The compute it replaces pulls every child row with all of its columns and builds one object per row to produce a single number. Measured against a real table — 292 parents, 5,576 children, 24 columns — fetching the children takes 18.5ms and the grouped aggregate 1.4ms.
Statistics over the same relation coalesce into one query. They share a synthesised
compute, so three counts over enrollments ride in a single grouped statement with a
FILTER (WHERE …) clause each — a third and fourth statistic cost 0.06ms on top of the
first, against 1.4ms each as separate queries.
filter= is an ordinary Q over the child’s own fields, foreign keys included
(Q(account=account.id) tests the account_id column). It is rendered by the same code
as any other Q, so it cannot drift from ordinary filter semantics.
Rules the declaration is checked against at load, because each of these fails silently otherwise:
| Aggregate | Field must be | Notes |
|---|---|---|
Count | Integer | a count into a Float reads as 3.0 |
Sum, Avg | Integer, Float, Monetary | a Monetary must declare currency_field |
Min, Max | the child field’s own type | a Max over a Date cannot land in an Integer |
Works on OneToMany and ManyToMany. A ManyToOne/OneToOne is refused — an aggregate
over one record is always 0 or 1.
A stored aggregate stays current on its own. It registers the relation and the
filter’s fields as dependencies, so flipping an enrolment to Certified refreshes
certified_count even though no row was added or removed. That second half is the one a
hand-written compute usually forgets to declare.
Reads apply the same company isolation, record rules and archived filter as reading the children would, so the number never counts rows the caller could not see.
Counting and totalling what you are HOLDING — count_of / sum_of
Section titled “Counting and totalling what you are HOLDING — count_of / sum_of”aggregate= answers for the rows in the database. When you need the children this
recordset is holding right now — including ones not saved yet — the answer can only come
from memory:
await order.fetch_related("lines")
order.count_of("lines") # the number, nothing builtawait order.sum_of("lines", "amount") # the total, nothing builtorder.count_of("lines", Q(state="Reviewed")) # both take an optional Q filterA form with unsaved lines has nothing in the database to group, which is why
User.groups_count is computed this way — as a COUNT over the join table it read 0
for every new user and ignored unsaved edits.
Both read from the loaded page rather than materialising records: count_of is a single
index lookup, and sum_of walks a column of values instead of building a record per child.
sum_of is await because a non-stored computed child field has no column until its
compute has run for the page; for a stored field the await does nothing.
Fetch first — both raise if the relation was never loaded, rather than reporting zero.
Reading an unfetched collection measures as empty, so len(record.children or []) and
sum(c.amount or 0 for c in (record.children or [])) quietly yield 0 on a page that
simply did not prefetch. That is harmless when you are looking at the value and silently
wrong when you are storing it — a zero total is indistinguishable from a real one.
For a stored count or total, prefer aggregate=: it removes the fetch, which these two
still pay for.
Filter
Section titled “Filter”# Get contacts with at least one paid ordercontacts = await Contact.filter(orders__status="paid").all()ManyToMany
Section titled “ManyToMany”Many records can relate to many other records.
Definition
Section titled “Definition”class Product(Model): tags = ManyToMany( "Tag", related_name="products", through="fkproducttag" # Through table name )Managing Relations with Command Arrays
Section titled “Managing Relations with Command Arrays”Use command arrays with update() to link, unlink, create, or modify related records.
Command Types
Section titled “Command Types”| Command | Syntax | Description |
|---|---|---|
link | ["link", id] | Link an existing record by ID |
unlink | ["unlink", id] | Detach a record — the record itself survives |
create | ["create", {...}] | Create a new record and link it |
delete | ["delete", id] | Destroy the related record entirely |
edit | ["edit", id, {...}] | Edit an already-linked record |
set | ["set", [id1, id2, ...]] | The collection becomes exactly these IDs |
clear | ["clear"] | Detach every related record |
unlink and delete are the pair worth reading twice: one detaches, the other
destroys the row. Same for clear (detach all) versus set (replace the set).
Examples
Section titled “Examples”# Link multiple tagsawait product.update( tags=[["link", 1], ["link", 2], ["link", 3]])
# Unlink a tagawait product.update( tags=[["unlink", 2]])
# Create and link a new tagawait product.update( tags=[["create", {"name": "New Tag", "color": "#FF0000"}]])
# Mix of operationsawait product.update( tags=[ ["link", existing_tag_id], # Link existing ["create", {"name": "Sale"}], # Create new ["unlink", old_tag_id], # Unlink ])
# Replace all tags with a new setawait product.update( tags=[["set", [1, 2, 3]]] # Now only tags 1, 2, 3 are linked)
# Remove all tagsawait product.update( tags=[["clear"]] # Unlink all)Exact command shapes & semantics
Section titled “Exact command shapes & semantics”Each command is a list with a fixed number of parts — the wrong arity raises
ValidationError. A bare id is auto-wrapped into a single-element list for the
id-list commands (link/unlink/delete/set), so ["link", 5] and
["link", [5]] are equivalent; create and edit are never wrapped (their
second part is a dict / id).
| Command | Exact shape | Parts | Notes |
|---|---|---|---|
create | ["create", {vals}] | 2 | Second part must be a dict — there is no None id slot. |
edit | ["edit", id, {vals}] | 3 | Edits an already-linked record; errors if id isn’t currently linked. |
delete | ["delete", [ids]] | 2 | Deletes the related record(s) entirely (M2M: drops the through rows). |
link | ["link", [ids]] | 2 | Links existing records; never creates. Already-linked ids are skipped. |
unlink | ["unlink", [ids]] | 2 | Detaches. See O2M note below. |
clear | ["clear"] | 1 | No arguments. Detaches all related records. |
set | ["set", [ids]] | 2 | The collection becomes exactly ids. No-ops if unchanged. |
A relational field accepts only command notation. A bare list — of records or
of ids — raises ValidationError naming the field and the shapes it expects; it
is never interpreted as “replace with these”.
unlink/clear/set on a OneToMany depend on the child’s FK: for a O2M, “unlinking”
a child means clearing its foreign key — but if that FK is required the child
can’t be orphaned, so the engine deletes it instead. If the FK is optional it
is set to None (detached). For M2M these commands only ever drop through-table
rows; the related records themselves are untouched (except delete).
After create()
Section titled “After create()”After Model.create(), ManyToMany fields are RecordList objects. Use update() with command arrays to modify relations:
# Create a new recordmessage = await Message.create( content="Hello", author=author,)
# ManyToMany fields are RecordList after create# Use update() with command arrays to add relationsawait message.update( contacts=[["link", cid] for cid in contact_ids], attachments=[["link", aid] for aid in attachment_ids],):::warning Important
Do NOT use .add() or .remove() methods on ManyToMany fields - they don’t exist in this ORM. Always use update() with command arrays.
:::
Relational field defaults
Section titled “Relational field defaults”ManyToMany and OneToMany fields support a default=, written in command
notation (the same verbs as update()). The default returns a list of
commands; the engine persists them on create and resolves them for the
new-record form.
class Product(Model): # M2M default — link an existing record (["link", [ids]]): routes = ManyToMany("Route", through="FkProductRoute", related_name="products", default=lambda self: self._default_routes())
async def _default_routes(self): buy = await get_model("Route").filter(identifier="buy_route").first() return [["link", [buy.id]]] if buy else None
class Order(Model): # O2M default — create default child rows (["create", {vals}]): lines = OneToMany("OrderLine", related_name="order", default=lambda self: self._default_lines())Write the default as a normal self-bound method, like any other field default. It is
evaluated against the record being built, on both paths:
- Persistence —
create()evaluates the default for any relational field the caller didn’t supply and hands the commands tosave_related(), which consumes them directly:["link", [ids]]links,["create", {vals}]creates the child row. - UI prefill — the new-record form path (
Model.new()) evaluates the same default and resolves the commands into records so the form can render them: an M2M default becomes chips, an O2M default becomes draft line rows.
An empty collection does not suppress a default on a record that doesn’t exist yet. A
new-record form posts every field in its view, so an untouched M2M arrives as []; that is
treated as “nothing chosen yet”, not as a deliberate clear. Clearing the field on a saved
record is still honoured.
RecordList
Section titled “RecordList”ManyToMany and OneToMany fields return RecordList objects - a list subclass with convenience properties.
product = await Product.filter(id=1).prefetch_related("tags").first()
# Get list of IDs from related recordstag_ids = product.tags.ids # [1, 2, 3]
# Use directly in filtersrelated = await Something.filter(tag_id__in=product.tags.ids).all()
# Works like a normal listfor tag in product.tags: print(tag.name)| Property | Returns | Description |
|---|---|---|
.ids | List[int] | List of IDs from all records |
Access
Section titled “Access”product = await Product.filter(id=1).get()await product.fetch_related("tags")
for tag in product.tags: print(tag.name)Reverse Access
Section titled “Reverse Access”tag = await Tag.filter(id=1).get()await tag.fetch_related("products")
for product in tag.products: print(product.name)Self-Referential ManyToMany
Section titled “Self-Referential ManyToMany”For ManyToMany relationships where a model relates to itself (e.g., Group → Group), you must use the through_fields parameter to specify which FK is which.
# Junction tableclass FkGroupImpliedGroup(Model): _verbose_name = "Group Implied Group" group = ManyToOne("Group", related_name="implied_group_links", on_delete="CASCADE") implied_group = ManyToOne("Group", related_name="implying_group_links", on_delete="CASCADE")
# Model with self-referential M2Mclass Group(Model): _verbose_name = "User Group" name = Char(max_length=255) implied_groups = ManyToMany( "Group", through="FkGroupImpliedGroup", through_fields=("group", "implied_group"), # (current_fk, related_fk) related_name="implying_groups", )The through_fields tuple specifies:
- First element: The FK that points to the “owning” record (the record this field belongs to)
- Second element: The FK that points to the “related” records (what you’re linking to)
:::warning Required
through_fields is required for self-referential ManyToMany relationships. Without it, the ORM cannot determine which FK represents the current record vs. the related records.
:::
Hierarchical (Tree) Models
Section titled “Hierarchical (Tree) Models”For models that form a parent/child tree via a self-referential ManyToOne
(categories, locations, BoMs…), set _parent_field to the name of that field.
The ORM then provides tree support automatically — you don’t hand-write any of it.
class ProductCategory(Model): _verbose_name = "Product Category" _parent_field = "parent" # opt into the hierarchy primitive
name = Char(max_length=255, required=True) parent = ManyToOne( "ProductCategory", related_name="child_categories", on_delete="SET NULL", )Declaring _parent_field auto-injects:
parent_path— an indexed, stored, auto-maintained materialised path of ids with a trailing slash, e.g."1/4/9/"for a node whose ancestors are1 → 4. It is recalculated (and re-propagated to all descendants) whenever a node is created or reparented, via the calculated-field cascade. Because it is id-based it is immune to duplicate names across levels, and the trailing slash makes prefix matching boundary-safe ("1/4/"matches"1/4/9/"but never"1/40/").- a cycle guard — a constraint on the parent field that rejects making a
node its own ancestor (raises
UserError).
Querying descendants
Section titled “Querying descendants”Filter a node and its whole subtree with the materialised path, or the
child_of helper:
from fullfinity.engine.base import child_of
# All products in "Electronics" (id 4) or any descendant category:electronics = await ProductCategory.filter(name="Electronics").first()products = await Product.filter( Q(category__parent_path__startswith=electronics.parent_path)).all()
# Same thing via the helper (accepts a record or an id):products = await Product.filter(await child_of(ProductCategory, electronics.id)).all()Reusing the cycle guard directly
Section titled “Reusing the cycle guard directly”The guard is also exported on its own for models that need it without the rest
of the primitive (e.g. a self-referential field not named parent):
from fullfinity.engine.base import assert_no_parent_cycle
class Package(Model): parent_package = ManyToOne("Package", related_name="packages", on_delete="CASCADE")
@Model.validate("parent_package") async def validate_no_parent_cycle(self): await assert_no_parent_cycle(self, "parent_package")Fetching Related Data
Section titled “Fetching Related Data”Single Relation
Section titled “Single Relation”order = await Order.filter(id=1).get()await order.fetch_related("customer")print(order.customer.name)Multiple Relations
Section titled “Multiple Relations”order = await Order.filter(id=1).get()await order.fetch_related("customer", "lines")Nested Relations
Section titled “Nested Relations”order = await Order.filter(id=1).get()await order.fetch_related("customer__company__country")print(order.customer.company.country.name)Prefetching (Avoiding N+1)
Section titled “Prefetching (Avoiding N+1)”The Problem
Section titled “The Problem”# Bad: N+1 queriesorders = await Order.filter().all()for order in orders: await order.fetch_related("customer") # Query per order! print(order.customer.name)The Solution
Section titled “The Solution”# Good: Single query with JOINorders = await Order.filter().prefetch_related("customer").all()for order in orders: print(order.customer.name) # Already loadedMultiple Prefetches
Section titled “Multiple Prefetches”orders = await Order.filter().prefetch_related( "customer", "lines", "lines__product").all()Prefetch All
Section titled “Prefetch All”# Prefetch all immediate relationsorders = await Order.filter().prefetch_all().all()Filtering Across Relations
Section titled “Filtering Across Relations”Single Level
Section titled “Single Level”# Orders for customers named "Acme"orders = await Order.filter(customer__name="Acme Corp").all()
# Products in "Electronics" categoryproducts = await Product.filter(category__name="Electronics").all()Multiple Levels
Section titled “Multiple Levels”# Orders for US customersorders = await Order.filter( customer__country__code="US").all()
# Invoices for customers with active subscriptionsinvoices = await Invoice.filter( customer__subscription__status="active").all()With Operators
Section titled “With Operators”# Orders for customers with 10+ employeesorders = await Order.filter( customer__employee_count__gte=10).all()
# Products with tags containing "sale"products = await Product.filter( tags__name__icontains="sale").all()Complete Example
Section titled “Complete Example”from fullfinity.engine.base import *
class Author(Model): name = Char(max_length=255, required=True) email = Char(max_length=255)
class Category(Model): name = Char(max_length=100, required=True) parent = ManyToOne("Category", related_name="children")
class Tag(Model): name = Char(max_length=50, required=True) color = Char(max_length=20, default="#6366F1")
class Book(Model): title = Char(max_length=255, required=True) author = ManyToOne("Author", related_name="books", required=True) category = ManyToOne("Category", related_name="books") tags = ManyToMany("Tag", related_name="books", through="fkbooktag") published_date = Date()
# Usageasync def example(): # Create author and book author = await Author.create(name="John Doe") book = await Book.create( title="Python Guide", author=author )
# Add tags tag1 = await Tag.create(name="Programming") tag2 = await Tag.create(name="Python")
await book.update(tags=[["link", tag1.id], ["link", tag2.id]])
# Query with prefetch books = await Book.filter().prefetch_related("author", "tags").all()
for book in books: print(f"{book.title} by {book.author.name}") print(f"Tags: {', '.join(t.name for t in book.tags)}")
# Filter across relations python_books = await Book.filter( tags__name="Python" ).prefetch_related("author").all()Fetch before you traverse
Section titled “Fetch before you traverse”Relations are not lazily loaded on attribute access — the ORM is async, so
traversing order.customer.name can’t do a hidden database round-trip. Load what
you need first with fetch_related (single record) or prefetch_related (query):
order = await Order.filter(id=1).get()await order.fetch_related("customer") # now order.customer is the recordprint(order.customer.name)
# Nested paths in one call:await order.fetch_related("customer__company__currency")Accessing a relation you haven’t fetched returns an awaitable proxy — you can
await order.customer to fetch it on the spot, and if order.customer: still
works as an existence check (it reads the FK id without fetching).
Loud failures, never silent None
Section titled “Loud failures, never silent None”If you read a field off a record you only hold by reference — a bare id, or a
{id, …} payload from the client, i.e. a partial record — before it’s loaded,
the ORM raises a clear error instead of returning a misleading None:
Field 'size' on Sequence is not loaded — this is a partial record(loaded by reference). Fetch it before use: … fetch_related that path.The fix is always to fetch first. (A brand-new record you’re building for
create() is not partial, so its not-yet-set fields still read as None.)
fetch_related works on a bare reference
Section titled “fetch_related works on a bare reference”You don’t have to fully load a record before fetching its relations. Calling
fetch_related on a record you hold only by id/reference completes its own
columns from the database first, then resolves the requested relations:
company = SomeModel_stub_or_reference # e.g. hydrated from {"id": 5}await company.fetch_related("currency") # loads the company row, then currencyprint(company.currency.symbol) # worksFully-loaded records skip this entirely, so it adds no cost to normal access.
Next Steps
Section titled “Next Steps”- Calculated Fields - Calculate from relations
- Querying Data - Advanced filtering