Skip to content

Relationships

Fullfinity supports three types of relationships between models.

A foreign key relationship where many records point to one.

class Order(Model):
customer = ManyToOne(
"Contact", # Related model
related_name="orders", # Reverse relation name
on_delete="CASCADE", # Delete behavior
required=True
)
OptionBehavior
CASCADEDelete related records
SET NULLSet field to NULL
RESTRICTPrevent deletion if related records exist
# Create with relation
contact = await Contact.filter(id=1).get()
order = await Order.create(customer=contact, name="ORD-001")
# Or by ID
order = await Order.create(customer=1, name="ORD-002")
# Access related object
order = await Order.filter(id=1).get()
await order.fetch_related("customer")
print(order.customer.name)
# Filter by relation
orders = await Order.filter(customer__name__icontains="Acme").all()

A one-to-one relationship, similar to ManyToOne but with a UNIQUE constraint.

class User(Model):
name = Char(max_length=255)
profile = OneToOne(
"UserProfile",
related_name="user",
on_delete="CASCADE"
)
AspectManyToOneOneToOne
ConstraintForeign keyForeign key + UNIQUE
Forward accessSingle objectSingle object
Reverse accessList of objectsSingle object
# Create with relation
profile = 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 list

The reverse of ManyToOne. Automatically created from related_name.

contact = await Contact.filter(id=1).get()
await contact.fetch_related("orders")
for order in contact.orders:
print(f"Order: {order.name}")
# Get contacts with at least one paid order
contacts = await Contact.filter(orders__status="paid").all()

Many records can relate to many other records.

class Product(Model):
tags = ManyToMany(
"Tag",
related_name="products",
through="fkproducttag" # Through table name
)

Use command arrays with update() to link, unlink, create, or modify related records.

CommandSyntaxDescription
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
# Link multiple tags
await product.update(
tags=[["L", 1], ["L", 2], ["L", 3]]
)
# Unlink a tag
await product.update(
tags=[["U", 2]]
)
# Create and link a new tag
await product.update(
tags=[["C", {"name": "New Tag", "color": "#FF0000"}]]
)
# Mix of operations
await 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 set
await product.update(
tags=[["R", [1, 2, 3]]] # Now only tags 1, 2, 3 are linked
)
# Remove all tags
await product.update(
tags=[["A"]] # Unlink all
)

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).

CommandExact shapePartsNotes
C["C", {vals}]2Second part must be a dict — there is no None id slot.
E["E", id, {vals}]3Edits an already-linked record; errors if id isn’t currently linked.
D["D", [ids]]2Deletes the related record(s) entirely (M2M: drops the through rows).
L["L", [ids]]2Links existing records; never creates. Already-linked ids are skipped.
U["U", [ids]]2Detaches. See O2M note below.
A["A"]1No arguments. Unlinks all related records.
R["R", [ids]]2The 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 Model.create(), ManyToMany fields are RecordList objects. Use update() with command arrays to modify relations:

# Create a new record
message = await Message.create(
content="Hello",
author=author,
)
# ManyToMany fields are RecordList after create
# Use update() with command arrays to add relations
await 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. :::

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:

  • Persistencecreate() runs the default through _default_get() and save_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 []

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 records
tag_ids = product.tags.ids # [1, 2, 3]
# Use directly in filters
related = await Something.filter(tag_id__in=product.tags.ids).all()
# Works like a normal list
for tag in product.tags:
print(tag.name)
PropertyReturnsDescription
.idsList[int]List of IDs from all records
product = await Product.filter(id=1).get()
await product.fetch_related("tags")
for tag in product.tags:
print(tag.name)
tag = await Tag.filter(id=1).get()
await tag.fetch_related("products")
for product in tag.products:
print(product.name)

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 table
class 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 M2M
class 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:

  1. First element: The FK that points to the “owning” record (the record this field belongs to)
  2. 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. :::

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 are 1 → 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).

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()

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")
order = await Order.filter(id=1).get()
await order.fetch_related("customer")
print(order.customer.name)
order = await Order.filter(id=1).get()
await order.fetch_related("customer", "lines")
order = await Order.filter(id=1).get()
await order.fetch_related("customer__company__country")
print(order.customer.company.country.name)
# Bad: N+1 queries
orders = await Order.filter().all()
for order in orders:
await order.fetch_related("customer") # Query per order!
print(order.customer.name)
# Good: Single query with JOIN
orders = await Order.filter().prefetch_related("customer").all()
for order in orders:
print(order.customer.name) # Already loaded
orders = await Order.filter().prefetch_related(
"customer",
"lines",
"lines__product"
).all()
# Prefetch all immediate relations
orders = await Order.filter().prefetch_all().all()
# Orders for customers named "Acme"
orders = await Order.filter(customer__name="Acme Corp").all()
# Products in "Electronics" category
products = await Product.filter(category__name="Electronics").all()
# Orders for US customers
orders = await Order.filter(
customer__country__code="US"
).all()
# Invoices for customers with active subscriptions
invoices = await Invoice.filter(
customer__subscription__status="active"
).all()
# Orders for customers with 10+ employees
orders = await Order.filter(
customer__employee_count__gte=10
).all()
# Products with tags containing "sale"
products = await Product.filter(
tags__name__icontains="sale"
).all()
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()
# Usage
async 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()