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.
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)) .all(self.db()) .await .map_err(|e| crate::schema::AppError::DbError(e.to_string()))?; children.into_iter().map(|m| m.id).collect()};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 executes a raw SQL UPDATE to set the FK column on the target table.
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?;}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 | 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()/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).