Skip to content

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.

KindDirectionStorageSchema Type
belongs_toThis entity points to targetFK column on this tableOption<String> or String
has_manyTarget entity points back hereFK column on target tableVec<String>
many_to_manyBoth entities linked through junctionSeparate junction tableVec<String>

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,

SeaORM entity — the FK column appears as a regular column, plus a Relation enum variant:

// In the entity Model struct
pub 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_model
workout_id: model.workout_id.clone(),
// to_active_model
workout_id: Set(self.workout_id.clone()),

Store layer — no special handling. The FK is just a field value. No population step needed.

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,

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.

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

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_model
contains: Vec::new(), // populated later by Store
// to_active_model -- field not present

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

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

RelationFieldRelationshipdata
belongs_toepic_id: Option<String>epic{"type": "epics", "id": "…"}, or null
belongs_toworkout_id: Stringworkoutalways an identifier
many_to_manytags: Vec<String>tagsarray of identifiers, in stored order
has_manycontains: Vec<String>containsarray 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.

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" }
}
RouteMethodsWhat it does
/api/tasks/{id}/relationships/{rel}GETthe linkage: an identifier, null, or an array
PATCHset or clear a to-one, replace a to-many ({"data": []} clears it)
POST, DELETEadd or remove one member of a to-many: {"data": [{"type": "tags", "id": "release"}]}
/api/tasks/{id}/{rel}GETthe 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 and ParentRequired included, and answer 204. Adding a member that is already there, or removing one that is not, is 204 and writes nothing, as is {"data": []} on POST or DELETE.
  • A body with more than one identifier on POST or DELETE is 403 relationship_batch_unsupported. POST or DELETE on a to-one, and any write when the module has no update, is 403 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 is null.
  • An unknown {rel} is 404 no_such_relationship, and a missing parent 404 with its {entity}_not_found code.
  • An entity whose module serves no get_by_id gets no relationship routes, and its relationships carry data only.

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}/labels lists the tags, GET /api/tasks/{id}/relationships/labels their linkage, and POST and DELETE /api/tasks/{id}/relationships/labels call add_label and remove_label. PATCH is 403, and so is POST without an add_Y or DELETE without a remove_Y.
  • Add and remove call list_X first 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 page list_X’s result in memory with page[offset] and page[limit].
  • The resource object lists the relationship after the relation fields, with links only. It cannot be included (include on a list or get) or sent in a create or update body.
  • The target is list_X’s element type, or for a Vec<String> list of ids, the entity whose plural is X. Each id parameter is a &str, String or &String, the module must serve get_by_id, and the name must not clash with an attribute or another relationship: list_tags beside the tags field fails the build. A list_X with no add_Y or remove_Y beside it is not a junction op but a custom GET.

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.

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>,

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 junction
impl 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 Store

Store 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, &current.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).

By default, the junction table name is {source_snake}_{field_name}:

Source EntityField NameJunction Table
Nodefulfillsnode_fulfills
Specificationcapability_idsspecification_capability_ids
Workouttagsworkout_tags

You can override this:

#[ontology(relation(many_to_many, target = "Requirement", junction = "node_fulfills_req"))]
pub fulfills: Vec<String>,

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)

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”
Stagebelongs_tohas_manymany_to_many
SeaORM EntityFK column + belongs_to variantNo columnNo column + has_many to junction + Related impl
Junction Table——Full entity generated
from_model()Cloned from modelVec::new()Vec::new()
to_active_model()Set(value)ExcludedExcluded
Store: createDirect insertSet FK on childrensync_junction()
Store: updateDirect updateConditional FK update; dropped children clearedConditional sync_junction()
Store: list/getDirect readpopulate_relations()populate_relations()
Update structOption<Option<String>> or Option<String>Option<Vec<String>>Option<Vec<String>>

Here’s a real-world pattern showing all three relationship types between Workout, WorkoutSet, and Tag:

workout.rs
#[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.rs entity: columns for id and date, a has_many link to workout_tags junction, and a Related<Tag> impl
  • workout_set.rs entity: columns for all fields including workout_id and exercise_id FK columns, belongs_to variants for both
  • workout_tags.rs junction entity: id, workout_id, tag_id columns with relations to both workout and tag
  • tag.rs entity: columns for id and name, no relations on this side

The store layer generates:

  • list_workouts(order, limit, offset)/get_workout() calls populate_workout_relations() which loads tags via load_junction_ids("workout_tags", ...)
  • create_workout() calls sync_junction("workout_tags", ...) to insert junction rows
  • update_workout() conditionally re-syncs if tags was included in the update
  • WorkoutSet CRUD has no extra relation handling — belongs_to fields are just regular columns
#[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>,
}
#[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).