Relationships
Fullfinity supports three types of relationships between models.
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 |
# 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)
# Filter by relationorders = await Order.filter(customer__name__icontains="Acme").all()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}")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 |
|---|---|---|
L (Link) | ["L", id] | Link an existing record by ID |
U (Unlink) | ["U", id] | Unlink a record (remove from relation) |
C (Create) | ["C", {...}] | Create a new record and link it |
D (Delete) | ["D", id] | Delete the related record entirely |
E (Edit) | ["E", id, {...}] | Edit an existing related record |
R (Replace) | ["R", [id1, id2, ...]] | Replace all relations with the given IDs |
A (Unlink All) | ["A"] | Unlink all related records |
Examples
Section titled “Examples”# Link multiple tagsawait product.update( tags=[["L", 1], ["L", 2], ["L", 3]])
# Unlink a tagawait product.update( tags=[["U", 2]])
# Create and link a new tagawait product.update( tags=[["C", {"name": "New Tag", "color": "#FF0000"}]])
# Mix of operationsawait product.update( tags=[ ["L", existing_tag_id], # Link existing ["C", {"name": "Sale"}], # Create new ["U", old_tag_id], # Unlink ])
# Replace all tags with a new setawait product.update( tags=[["R", [1, 2, 3]]] # Now only tags 1, 2, 3 are linked)
# Remove all tagsawait product.update( tags=[["A"]] # 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 (L/U/D/R), so ["L", 5] and ["L", [5]] are
equivalent; C and E are never wrapped (their second part is a dict / id).
| Command | Exact shape | Parts | Notes |
|---|---|---|---|
C | ["C", {vals}] | 2 | Second part must be a dict — there is no None id slot. |
E | ["E", id, {vals}] | 3 | Edits an already-linked record; errors if id isn’t currently linked. |
D | ["D", [ids]] | 2 | Deletes the related record(s) entirely (M2M: drops the through rows). |
L | ["L", [ids]] | 2 | Links existing records; never creates. Already-linked ids are skipped. |
U | ["U", [ids]] | 2 | Detaches. See O2M note below. |
A | ["A"] | 1 | No arguments. Unlinks all related records. |
R | ["R", [ids]] | 2 | The set becomes exactly ids. No-ops if the set is unchanged. |
U/A/R 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 D).
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=[["L", cid] for cid in contact_ids], attachments=[["L", 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.
# M2M default — link an existing record (["L", id]):routes = ManyToMany("Route", through="FkProductRoute", related_name="products", default=_default_routes) # _default_routes() -> [["L", id]]
# O2M default — create default child rows (["C", {vals}]):lines = OneToMany("OrderLine", related_name="order", default=_default_lines) # _default_lines() -> [["C", {...}]]Both halves work from one command-notation default:
- Persistence —
create()runs the default through_default_get()andsave_related(), which consumes the commands directly:["L", id]links the record,["C", {vals}]creates the child row. - UI prefill — the new-record form path (
Model.new()) resolves the same commands into instances so the form can render them: an M2M["L"/"R", ids]default becomes chips, an O2M["C", {vals}]default becomes draft line rows.
Use a module-level function (or arg-ignoring lambda) for the default, not a
self-bound method — the form-prefill path evaluates it with no instance:
async def _default_routes(instance=None): buy = await get_model("Route").filter(identifier="buy_route").first() return [["L", buy.id]] if buy else []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 recomputed (and re-propagated to all descendants) whenever a node is created or reparented, via the computed-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=[["L", tag1.id], ["L", 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()Next Steps
Section titled “Next Steps”- Computed Fields - Calculate from relations
- Querying Data - Advanced filtering