Relationships
Relationships connect entities. Ontogen supports three kinds — belongs_to, has_many, and many_to_many — and each one affects every stage of the pipeline differently. This guide covers what each relationship generates, how to annotate them, and how they interact across entities.
The Three Relationship Kinds
Section titled “The Three Relationship Kinds”| Kind | Direction | Storage | Schema Type |
|---|---|---|---|
belongs_to | This entity points to target | FK column on this table | Option<String> or String |
has_many | Target entity points back here | FK column on target table | Vec<String> |
many_to_many | Both entities linked through junction | Separate junction table | Vec<String> |
belongs_to
Section titled “belongs_to”A belongs_to is the simplest relationship. It means “this entity has a foreign key column pointing to another entity.”
#[serde(default)]#[ontology(relation(belongs_to, target = "Workout"))]pub workout_id: String,What gets generated
Section titled “What gets generated”SeaORM entity — the FK column appears as a regular column, plus a Relation enum variant:
// In the entity Model structpub workout_id: String,
// In the Relation enum#[sea_orm( belongs_to = "super::workout::Entity", from = "Column::WorkoutId", to = "super::workout::Column::Id")]WorkoutId,Conversion layer — the FK is treated like a regular field in from_model() and to_active_model():
// from_modelworkout_id: model.workout_id.clone(),
// to_active_modelworkout_id: Set(self.workout_id.clone()),Store layer — no special handling. The FK is just a field value. No population step needed.
Self-referential belongs_to
Section titled “Self-referential belongs_to”When an entity points to itself (like a tree structure), the generated code references Entity directly instead of super::other::Entity:
#[ontology(relation(belongs_to, target = "Node"))]pub parent_id: Option<String>,Generates:
#[sea_orm( belongs_to = "Entity", // self-reference from = "Column::ParentId", to = "Column::Id" // Column::Id, not super::node::Column::Id)]ParentId,Required vs Optional
Section titled “Required vs Optional”The field type controls nullability:
// Required FK -- the entity MUST reference a target#[ontology(relation(belongs_to, target = "Workout"))]pub workout_id: String,
// Optional FK -- the reference can be NULL#[ontology(relation(belongs_to, target = "Node"))]pub parent_id: Option<String>,A required String FK generates a NOT NULL column. An Option<String> FK generates a nullable column.
has_many
Section titled “has_many”A has_many is the reverse side of a belongs_to. It represents “other entities have a FK column pointing to this entity.” The relationship doesn’t add a column to this table — it reads from the other table.
#[serde(default)]#[ontology(relation(has_many, target = "Node", foreign_key = "parent_id"))]pub contains: Vec<String>,The foreign_key parameter is required and must name the FK column on the target entity. The target must be the declaring entity itself; a has_many to another entity fails the build (see has_many writes for the workaround).
What gets generated
Section titled “What gets generated”SeaORM entity — no column is generated. has_many fields are not stored on this table.
Conversion layer — initialized as empty in from_model(), excluded from to_active_model():
// from_modelcontains: Vec::new(), // populated later by Store
// to_active_model -- field not presentStore layer — populated via a query against the target table:
// Inside populate_node_relations()node.contains = { use crate::persistence::db::entities::node; let children = node::Entity::find() .filter(node::Column::ParentId.eq(&node.id)) .order_by_asc(node::Column::Id) .all(self.db()) .await .map_err(|e| crate::schema::AppError::DbError(e.to_string()))?; children.into_iter().map(|m| m.id).collect()};The list is ordered by child id (byte order) on both backends.
On create and update, the store sets the FK on child entities:
// In create_node()for child_id in &contains { self.set_node_parent(child_id, Some(&id)).await?;}The set_node_parent helper sets the FK column on the target table with one UPDATE, which sea-query builds for the connection’s backend, quoting the table and column names.
For what an update does to children the list drops, the ParentRequired error, and the supported shape, see has_many writes.
On the HTTP wire
Section titled “On the HTTP wire”Over HTTP, a relation field is not an attribute. The generated JSON:API server moves every relation field into the resource’s relationships object, with its linkage in data. (Tauri IPC, MCP and the TypeScript Transport methods carry the entity flat, with relation fields as ids under their field names; only the HTTP wire has relationships.)
| Relation | Field | Relationship | data |
|---|---|---|---|
belongs_to | epic_id: Option<String> | epic | {"type": "epics", "id": "…"}, or null |
belongs_to | workout_id: String | workout | always an identifier |
many_to_many | tags: Vec<String> | tags | array of identifiers, in stored order |
has_many | contains: Vec<String> | contains | array of identifiers, id ascending |
A belongs_to field name that ends in _id loses the suffix; every other relation field keeps its name. The type is the target entity’s URL plural. A relationship name that equals an attribute name, type, id or relationships, or that another relationship already uses, fails a build that generates an HTTP server or client.
Writes use the same shape. A create or update body sets a to-one with "data": {"type": "epics", "id": "…"} (or null for an Option field, 403 relationship_required for a required one) and a to-many with a data array that replaces the stored list; a repeated identifier counts once. An identifier whose type is not the relationship’s target is 409 type_mismatch. A required to-one left out of a create body is 400 missing_relationship; on an update, any relationship left out is unchanged. A has_many array goes through the children’s foreign keys with the rules above, and a dropped child whose key is required is 403 with the {child}_parent_required code. Every linked id is checked first: a missing one is 404 no_such_related_resource, and nothing is written. A relation field sent as an attribute (epic_id) is 400 unknown_attribute.
Relationship endpoints
Section titled “Relationship endpoints”When the entity’s module serves get_by_id, each relationship also gets its own routes, and its object in the resource carries links to them:
"epic": { "links": { "self": "/api/tasks/ship-the-emitter/relationships/epic", "related": "/api/tasks/ship-the-emitter/epic" }, "data": { "type": "epics", "id": "markdown-backend" }}| Route | Methods | What it does |
|---|---|---|
/api/tasks/{id}/relationships/{rel} | GET | the linkage: an identifier, null, or an array |
PATCH | set or clear a to-one, replace a to-many ({"data": []} clears it) | |
POST, DELETE | add or remove one member of a to-many: {"data": [{"type": "tags", "id": "release"}]} | |
/api/tasks/{id}/{rel} | GET | the related resource (or null), or the related resources in linkage order |
- Writes go through the module’s
update, so they follow the rules above, hooks andParentRequiredincluded, and answer204. Adding a member that is already there, or removing one that is not, is204and writes nothing, as is{"data": []}onPOSTorDELETE. - A body with more than one identifier on
POSTorDELETEis403 relationship_batch_unsupported.POSTorDELETEon a to-one, and any write when the module has noupdate, is403 relationship_update_unsupported. - The related route reads each resource with the target module’s
get_by_id, so every target needs one (the build fails otherwise). A dangling id is skipped; a to-one whose target is gone isnull. - An unknown
{rel}is404 no_such_relationship, and a missing parent404with its{entity}_not_foundcode. - An entity whose module serves no
get_by_idgets no relationship routes, and its relationships carrydataonly.
Junction ops
Section titled “Junction ops”A relationship can also be defined by functions instead of a field. In an API module named after an entity, list_X(parent_id) with add_Y(parent_id, child_id) and/or remove_Y(parent_id, child_id) define a to-many relationship named X, served on the same endpoints:
// src/api/v1/task.rs: the relationship `labels`, whose target is `tags`.pub async fn list_labels(store: &Store, id: &str) -> Result<Vec<Tag>, AppError> { /* … */ }pub async fn add_label(store: &Store, id: &str, tag_id: &str) -> Result<(), AppError> { /* … */ }pub async fn remove_label(store: &Store, id: &str, tag_id: &str) -> Result<(), AppError> { /* … */ }GET /api/tasks/{id}/labelslists the tags,GET /api/tasks/{id}/relationships/labelstheir linkage, andPOSTandDELETE /api/tasks/{id}/relationships/labelscalladd_labelandremove_label.PATCHis403, and so isPOSTwithout anadd_YorDELETEwithout aremove_Y.- Add and remove call
list_Xfirst and skip your function when the tag is already a member, or is not one. An add checks that the tag exists; a remove does not. - On a paginated surface the two
GETs pagelist_X’s result in memory withpage[offset]andpage[limit]. - The resource object lists the relationship after the relation fields, with
linksonly. It cannot be included (includeon a list or get) or sent in a create or update body. - The target is
list_X’s element type, or for aVec<String>list of ids, the entity whose plural isX. Each id parameter is a&str,Stringor&String, the module must serveget_by_id, and the name must not clash with an attribute or another relationship:list_tagsbeside thetagsfield fails the build. Alist_Xwith noadd_Yorremove_Ybeside it is not a junction op but a customGET.
Junction ops in a module with no entity behind it are served as custom ops instead, at /api/{module}/{parent_id}/{segment}; see Operations that are not CRUD. Over IPC and MCP, junction ops are commands and tools of their own wherever they live, with flat arguments and results.
The full rules for relationship documents and endpoints, with each error’s step in the check order, are §5.4 and §9 of the JSON:API wire contract. Server Transports covers the routes, and Client Generation how the TypeScript transport calls them.
many_to_many
Section titled “many_to_many”A many_to_many relationship requires a junction (association) table. Neither entity has a FK column — the junction table has two FK columns, one for each side.
#[serde(default)]#[ontology(relation(many_to_many, target = "Requirement"))]pub fulfills: Vec<String>,What gets generated
Section titled “What gets generated”SeaORM entity — no column on the main entity, but a has_many link to the junction table, plus a Related impl for the target entity:
// Relation enum variant#[sea_orm(has_many = "super::node_fulfills::Entity")]FulfillsLink,
// Related impl for traversing through junctionimpl Related<super::requirement::Entity> for Entity { fn to() -> RelationDef { super::node_fulfills::Relation::Requirement.def() } fn via() -> Option<RelationDef> { Some(super::node_fulfills::Relation::Node.def().rev()) }}Junction table entity — a complete SeaORM entity is generated:
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]#[sea_orm(table_name = "node_fulfills")]pub struct Model { #[sea_orm(primary_key)] pub id: i32, // auto-increment PK pub node_id: String, // FK to source entity pub requirement_id: String, // FK to target entity}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]pub enum Relation { #[sea_orm( belongs_to = "super::node::Entity", from = "Column::NodeId", to = "super::node::Column::Id" )] Node, #[sea_orm( belongs_to = "super::requirement::Entity", from = "Column::RequirementId", to = "super::requirement::Column::Id" )] Requirement,}Conversion layer — initialized as empty in from_model(), excluded from to_active_model():
fulfills: Vec::new(), // populated later by StoreStore layer — populated via load_junction_ids and synced via sync_junction:
// In populate_node_relations()node.fulfills = self .load_junction_ids("node_fulfills", "node_id", "requirement_id", &node.id) .await?;
// In create_node()self.sync_junction("node_fulfills", "node_id", "requirement_id", &id, &fulfills).await?;
// In update_node() (only if the field changed)if fulfills_changed { self.sync_junction("node_fulfills", "node_id", "requirement_id", id, ¤t.fulfills).await?;}The list keeps the order it was written in, on both backends. On SeaORM that holds because your sync_junction inserts rows in list order and your load_junction_ids reads them back with ORDER BY rowid (see Junction Sync).
Junction Table Naming
Section titled “Junction Table Naming”By default, the junction table name is {source_snake}_{field_name}:
| Source Entity | Field Name | Junction Table |
|---|---|---|
Node | fulfills | node_fulfills |
Specification | capability_ids | specification_capability_ids |
Workout | tags | workout_tags |
You can override this:
#[ontology(relation(many_to_many, target = "Requirement", junction = "node_fulfills_req"))]pub fulfills: Vec<String>,Junction Column Naming
Section titled “Junction Column Naming”The junction table always has three columns:
id— auto-increment integer primary key{source_snake}_id— FK to the source entity{target_snake}_id— FK to the target entity
For the node -> fulfills -> requirement example:
node_fulfills├── id: i32 (PK, auto-increment)├── node_id: String (FK → nodes.id)└── requirement_id: String (FK → requirements.id)Self-Referential many_to_many
Section titled “Self-Referential many_to_many”When both sides of the relationship point to the same entity, the column names change to avoid ambiguity:
// Task depends on other Tasks#[ontology(relation(many_to_many, target = "Task"))]pub depends_on: Vec<String>,Generates:
#[sea_orm(table_name = "task_depends_on")]pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub task_id: String, // source pub target_id: String, // NOT "task_id" again -- disambiguated}The variants in the Relation enum also change:
pub enum Relation { Source, // instead of Task Target, // instead of Task}How Relationships Affect Each Pipeline Stage
Section titled “How Relationships Affect Each Pipeline Stage”| Stage | belongs_to | has_many | many_to_many |
|---|---|---|---|
| SeaORM Entity | FK column + belongs_to variant | No column | No column + has_many to junction + Related impl |
| Junction Table | — | — | Full entity generated |
| from_model() | Cloned from model | Vec::new() | Vec::new() |
| to_active_model() | Set(value) | Excluded | Excluded |
| Store: create | Direct insert | Set FK on children | sync_junction() |
| Store: update | Direct update | Conditional FK update; dropped children cleared | Conditional sync_junction() |
| Store: list/get | Direct read | populate_relations() | populate_relations() |
| Update struct | Option<Option<String>> or Option<String> | Option<Vec<String>> | Option<Vec<String>> |
Complete Three-Entity Example
Section titled “Complete Three-Entity Example”Here’s a real-world pattern showing all three relationship types between Workout, WorkoutSet, and Tag:
#[derive(OntologyEntity)]#[ontology(entity, table = "workouts")]pub struct Workout { #[ontology(id)] pub id: String,
pub date: String,
// many_to_many: workouts have tags (junction: workout_tags) #[serde(default)] #[ontology(relation(many_to_many, target = "Tag"))] pub tags: Vec<String>,}
// workout_set.rs#[derive(OntologyEntity)]#[ontology(entity, table = "workout_sets")]pub struct WorkoutSet { #[ontology(id)] pub id: String,
// belongs_to: each set belongs to a workout #[ontology(relation(belongs_to, target = "Workout"))] pub workout_id: String,
// belongs_to: each set uses an exercise #[ontology(relation(belongs_to, target = "Exercise"))] pub exercise_id: String,
pub reps: i32, pub weight_grams: i32,}
// tag.rs#[derive(OntologyEntity)]#[ontology(entity, table = "tags")]pub struct Tag { #[ontology(id)] pub id: String,
pub name: String,}From these three structs, Ontogen generates:
workout.rsentity: columns foridanddate, ahas_manylink toworkout_tagsjunction, and aRelated<Tag>implworkout_set.rsentity: columns for all fields includingworkout_idandexercise_idFK columns,belongs_tovariants for bothworkout_tags.rsjunction entity:id,workout_id,tag_idcolumns with relations to bothworkoutandtagtag.rsentity: columns foridandname, no relations on this side
The store layer generates:
list_workouts(order, limit, offset)/get_workout()callspopulate_workout_relations()which loadstagsviaload_junction_ids("workout_tags", ...)create_workout()callssync_junction("workout_tags", ...)to insert junction rowsupdate_workout()conditionally re-syncs iftagswas included in the updateWorkoutSetCRUD has no extra relation handling —belongs_tofields are just regular columns
Common Patterns
Section titled “Common Patterns”Tree Structure (Self-Referential)
Section titled “Tree Structure (Self-Referential)”#[ontology(entity, table = "nodes")]pub struct Node { #[ontology(id)] pub id: String,
#[serde(default)] #[ontology(relation(belongs_to, target = "Node"))] pub parent_id: Option<String>,
#[serde(default)] #[ontology(relation(has_many, target = "Node", foreign_key = "parent_id"))] pub contains: Vec<String>,}Multiple FK to Same Target
Section titled “Multiple FK to Same Target”#[ontology(entity, table = "contracts")]pub struct Contract { #[ontology(id)] pub id: String,
#[ontology(relation(belongs_to, target = "Node"))] pub scope: Option<String>,
#[ontology(relation(belongs_to, target = "Node"))] pub from_id: Option<String>,
#[ontology(relation(belongs_to, target = "Node"))] pub to_id: Option<String>,}Each belongs_to generates its own Relation enum variant (Scope, FromId, ToId), all pointing to super::node::Entity.
Dependencies (Self-Referential many_to_many)
Section titled “Dependencies (Self-Referential many_to_many)”#[ontology(entity, table = "requirements")]pub struct Requirement { #[ontology(id)] pub id: String,
#[serde(default)] #[ontology(relation(many_to_many, target = "Requirement"))] pub depends_on: Vec<String>,}The junction table requirement_depends_on uses requirement_id and target_id (disambiguated because source and target are the same entity).