5. Relationships¶
blog.post is useful on its own, but real models link to other models. This chapter adds three relationships to the example:
Reference(many-to-one) — every post has one author. The author is ares.partner— the platform's universal party master.One2Many— every post has many comments. You'll create a newblog.commentmodel that points back at the post.Many2Many— every post has many tags, every tag can label many posts. You'll create ablog.tagmodel and a join table will be auto-generated.
@api.model("blog.post")
class BlogPost(DomainModel):
title = fields.Char(required=True)
author_id = fields.Reference("res.partner", on_delete="restrict", required=True)
comment_ids = fields.One2Many("blog.comment", backref_field_name="post_id")
tag_ids = fields.Many2Many("blog.tag")
@api.model("blog.comment")
class BlogComment(DomainModel):
post_id = fields.Reference("blog.post", on_delete="cascade", required=True)
author = fields.Char(max_length=200, required=True)
body = fields.Char(multi_line=True, required=True)
@api.model("blog.tag")
class BlogTag(DomainModel):
name = fields.Char(max_length=80, required=True, unique=True)
By the end you can navigate from a post to its author, list its comments, set its tags, and understand exactly which on_delete policy applies in each case.
What you're going to build¶
Add the two new models (blog.comment, blog.tag) and extend blog.post with the three relational fields above. After the migration runs you will:
- Browse a post →
post.author_id.namereturns the author's name fromres.partner. - Browse a post →
post.comment_idsreturns aRecordSetof every comment. - Add a tag with
RelationalCommand.link(tag.id). - See the
blog_post_blog_tag_reljoin table that the framework auto-derived.
1. The three relational types — and when to reach for each¶
| Field type | Storage | Use when |
|---|---|---|
Reference(...) |
One UUID column on this table, pointing at the target's record_uuid. |
The child knows its single parent — author of a post, country of a partner, organization of a record. |
One2Many(...) |
Not stored. Reads find rows on the other table whose Reference field points back at this record. |
The parent wants to walk its children — comments on a post, lines on an order, addresses of a partner. Requires a matching Reference on the target. |
Many2Many(...) |
An auto-derived join table — {this_table}_{target_table}_rel. |
An unordered set on each side — tags on a post, roles on a user, organizations shared with a record. |
Critical: One2Many is the mirror of a Reference. It never stores anything itself; it just reads the inverse direction. You declare the FK on the child (as a Reference), then declare the reverse-view on the parent (as a One2Many).
2. Create the two new models¶
Create src/domains/blog/post/models/comment.py:
from __future__ import annotations
from ede.core import api
from ede.core.kernel import fields
from ede.core.kernel.model import DomainModel
@api.model(
"blog.comment",
description="Blog Comment",
record_name="author",
default_order="created_at_utc desc",
)
class BlogComment(DomainModel):
"""A reader-submitted comment on a blog post."""
post_id = fields.Reference(
"blog.post",
on_delete="cascade",
required=True,
index=True,
label="Post",
)
author = fields.Char(max_length=200, required=True, label="Author")
body = fields.Char(multi_line=True, required=True, label="Body")
active = fields.Boolean(default=True)
Create src/domains/blog/post/models/tag.py:
from __future__ import annotations
from ede.core import api
from ede.core.kernel import fields
from ede.core.kernel.model import DomainModel
@api.model(
"blog.tag",
description="Blog Tag",
record_name="name",
default_order="name asc",
)
class BlogTag(DomainModel):
"""A categorisation label that can be attached to any post."""
name = fields.Char(max_length=80, required=True, unique=True, label="Name")
active = fields.Boolean(default=True)
Update src/domains/blog/post/models/__init__.py to import them:
3. Extend blog.post with the three relations¶
Add three fields to BlogPost in src/domains/blog/post/models/post.py:
@api.model(
"blog.post",
record_name="title",
display_name_format="[{slug}] {title}",
default_order="created_at_utc desc",
name_search_fields=["title", "slug"],
)
class BlogPost(DomainModel):
# ... existing fields ...
# Many-to-one — every post has one author.
author_id = fields.Reference(
"res.partner",
on_delete="restrict",
required=True,
label="Author",
)
# One-to-many — every post has many comments.
comment_ids = fields.One2Many(
"blog.comment",
backref_field_name="post_id",
label="Comments",
)
# Many-to-many — posts and tags are independent sets.
tag_ids = fields.Many2Many(
"blog.tag",
label="Tags",
)
The One2Many's backref_field_name is the name of the Reference field on blog.comment that points back here. The framework uses that to find children at read time.
4. on_delete — what happens when the target is deleted¶
Reference fields take an on_delete policy. Pick deliberately:
| Policy | Behaviour | Use when |
|---|---|---|
"restrict" (default) |
Deleting the target is rejected as long as any record still references it. | The relationship is structural and breaking it should be a deliberate act. Default — e.g. you can't delete a res.partner while posts still cite them as author. |
"cascade" |
Deleting the target also deletes every record referencing it. | The child is meaningless without its parent — delete a post and all its comments go with it. |
"set null" |
Deleting the target sets the reference to NULL on referencing rows. | The reference is informational and the child should survive — e.g. an "owner" pointer that becomes orphaned. |
In the example: blog.post.author_id is restrict (an author isn't deletable while their posts exist); blog.comment.post_id is cascade (delete the post → comments go).
5. Generate and apply the migration¶
ede migrate generate -m "blog comment + tag + relations" --app blog.post
ede migrate upgrade -t system
Open the generated file. You'll see:
op.create_table("blog_comment", ...)with apost_idcolumn (FK toblog_post.record_uuid) andON DELETE CASCADE.op.create_table("blog_tag", ...)with the unique index onname.op.create_table("blog_post_blog_tag_rel", ...)— the auto-derived M2M join table with two FK columns:blog_post_idandblog_tag_id.op.add_column("blog_post", ...)forauthor_id, plus an index.
The Many2Many join table name follows a fixed convention: {this_table}_{target_table}_rel. The columns are {this_table}_id and {target_table}_id, each pointing at the relevant record_uuid.
6. Reading relations — RecordSet navigation¶
In a command handler, an HTTP controller, or a test, you traverse relations as attribute access on the RecordSet:
post = env.models["blog.post"].browse(some_uuid)
# Reference — returns a single-record RecordSet of the target model.
author = post.author_id
print(author.name) # res.partner.name
# One2Many — returns a (possibly empty) RecordSet of the target model.
comments = post.comment_ids
for c in comments:
print(c.author, c.body)
# Many2Many — same shape.
tags = post.tag_ids
print([t.name for t in tags])
Notes:
- Empty references are not
None— they're an emptyRecordSet.if post.author_id:works (truthy when non-empty). post.comment_idsis a fresh read each time you access it — no stale cache. For loops that re-read, materialise once:comments = post.comment_idsand iterate.- The
activefilter applies transitively: archived comments and tags are hidden by default from these reads. Useenv.with_active_test(False)to see them.
7. Writing to relations — RelationalCommand¶
You don't assign lists directly to relational fields. Instead, pass a list of RelationalCommand tuples that describe what to add, link, or remove:
from ede.core.orm.commands import RelationalCommand as Rel
# Add a tag the user just picked from a dropdown.
post.write({"tag_ids": [Rel.link(tag_uuid)]})
# Replace the entire tag set in one shot.
post.write({"tag_ids": [Rel.set([tag_a, tag_b, tag_c])]})
# Remove a tag (does NOT delete the tag — just unlinks).
post.write({"tag_ids": [Rel.unlink(tag_uuid)]})
# Strip every tag from this post.
post.write({"tag_ids": [Rel.clear()]})
# Create-and-link a brand-new tag in one call.
post.write({"tag_ids": [Rel.create({"name": "Fresh"})]})
# One2Many — create a new comment as a child of this post in one call.
post.write({
"comment_ids": [
Rel.create({"author": "Alice", "body": "Loved it"}),
Rel.update(existing_comment_uuid, {"body": "Edited"}),
Rel.delete(stale_comment_uuid),
]
})
The seven factories:
| Factory | Effect |
|---|---|
Rel.create(values) |
Create a new related record and link it. For One2Many, the FK is stamped automatically. |
Rel.update(id, values) |
Write values to the related record id. |
Rel.delete(id) |
Delete the related record from the database, removing the link. |
Rel.unlink(id) |
Remove the link only. The related record stays (Many2Many) or has its FK set to null (One2Many with on_delete="set null"). |
Rel.link(id) |
Add an existing related record to the relation (Many2Many only). |
Rel.clear() |
Remove every link (Many2Many) or unlink every child (One2Many). |
Rel.set([ids]) |
Replace the entire relation with exactly this set. |
Why a command list instead of plain values? Because the relation is a multi-row collection — there's no single "value" to assign. The command tuples let you express add / remove / replace atomically in one write() call, all inside the same transaction.
8. Cross-domain references and the platform layer¶
blog.post.author_id points at res.partner — a model that ships with the platform (foundation.base), not with the blog domain. That's the right shape.
The framework's mental model has two layers:
DOMAIN LAYER your code: blog.post, blog.comment, blog.tag
depends on the platform; never depends on other domains.
│
▼
PLATFORM LAYER framework: res.partner, res.organization, res.user,
res.country, res.currency, res.uom, ir.session,
ir.menu, ir.action, ir.rbac.* …
Never references any domain model.
Rules of thumb:
- A
Referencefrom your code to ares.*orir.*model is always fine — that's the platform there for you to use. Theres.partnermaster,res.organizationlegal entity,res.useruser,res.currencyfor money,res.countryfor addresses — all available. - A
Referencefrom your domain to another domain is a smell. Ifblogneeds to point at something that lives in another business domain, the model probably belongs in the platform instead.
See Features: Base — Platform Substrate for what the platform models actually offer.
9. Add the relations to the form view¶
Update src/domains/blog/post/views/blog_post_views.xml so the form shows the new fields:
<FormView>
<header>
<field name="status" widget="statusbar"/>
</header>
<sheet>
<group>
<field name="title"/>
<field name="slug"/>
<field name="author_id"/>
<field name="tag_ids" widget="tags"/>
</group>
<notebook>
<page string="Body">
<field name="body" widget="text"/>
</page>
<page string="Comments">
<field name="comment_ids" widget="one2many">
<ListView>
<field name="author"/>
<field name="body"/>
<field name="created_at_utc" widget="datetime"/>
</ListView>
</field>
</page>
</notebook>
</sheet>
</FormView>
Two new widgets:
widget="tags"on aMany2Manyrenders chip-style pills with an inline picker.widget="one2many"embeds a list view of the related rows directly inside the form. The inner<ListView>declares which columns to show.
Restart the server, open a post in the web client. The Tags pill bar lives on the right; the Comments tab shows an inline editable list — add a row, save, and the framework dispatches RelationalCommand.create(...) under the hood.
What you just did¶
- Created two new models —
blog.commentandblog.tag— each registered, migrated, and queryable like any other model. - Added a
Reference, aOne2Many, and aMany2Manytoblog.post, each with the righton_deletepolicy. - Read related records by walking the
RecordSet(post.author_id.name,post.comment_ids,post.tag_ids). - Wrote relations using the seven
RelationalCommandfactories —link/unlink/create/update/delete/clear/set. - Learned the cross-layer rule: domain → platform
References are fine; domain → domain ones aren't.