Persistence (SeaORM)
The SeaORM generator is one of the core pipeline stages. It takes parsed EntityDef values and produces three things: entity modules with Model structs and Relation enums, junction table entities for many-to-many relationships, and conversion functions that bridge your schema types and the database layer.
Configuration
Section titled “Configuration”let seaorm_output = ontogen::gen_seaorm(&schema.entities, &ontogen::SeaOrmConfig { entity_output: PathBuf::from("src/persistence/db/entities/generated"), conversion_output: PathBuf::from("src/persistence/db/conversions/generated"), skip_conversions: vec!["AcceptanceCriterion".to_string()],})?;SeaOrmConfig Fields
Section titled “SeaOrmConfig Fields”| Field | Type | Description |
|---|---|---|
entity_output | PathBuf | Directory for generated SeaORM entity modules |
conversion_output | PathBuf | Directory for generated from_model/to_active_model code |
skip_conversions | Vec<String> | Entity names to exclude from conversion generation (for hand-written overrides) |
The generator creates both directories if they don’t exist. Stale files from renamed entities are cleaned up automatically — any .rs file in the output directory that doesn’t match a current entity gets deleted.
What Gets Generated
Section titled “What Gets Generated”For a schema with entities Workout, WorkoutSet, Exercise, and Tag (where Workout has many_to_many tags), you get:
src/persistence/db/entities/generated/ mod.rs workout.rs workout_set.rs exercise.rs tag.rs workout_tags.rs <-- junction table
src/persistence/db/conversions/generated/ mod.rs workout.rs workout_set.rs exercise.rs tag.rsEach entity gets its own module. Junction tables get their own modules too. A mod.rs re-exports everything.
Generated Entity Structure
Section titled “Generated Entity Structure”Let’s walk through what Ontogen generates for a Workout entity:
#[derive(OntologyEntity)]#[ontology(entity, table = "workouts")]pub struct Workout { #[ontology(id)] pub id: String, #[serde(default)] pub name: Option<String>, pub date: String, #[serde(default)] pub duration_minutes: Option<i32>, #[serde(default)] pub notes: Option<String>, #[serde(default)] #[ontology(relation(many_to_many, target = "Tag"))] pub tags: Vec<String>, pub created_at: String,}//! Generated by ontogen. DO NOT EDIT.
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]#[sea_orm(table_name = "workouts")]pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: String, pub name: Option<String>, pub date: String, pub duration_minutes: Option<i32>, pub notes: Option<String>, pub created_at: String,}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]pub enum Relation { #[sea_orm(has_many = "super::workout_tags::Entity")] TagsLink,}
impl Related<super::tag::Entity> for Entity { fn to() -> RelationDef { super::workout_tags::Relation::Tag.def() } fn via() -> Option<RelationDef> { Some(super::workout_tags::Relation::Workout.def().rev()) }}
impl ActiveModelBehavior for ActiveModel {}Notice what happened:
- The
tagsfield (many_to_many) has no column in the Model struct - A
TagsLinkvariant in the Relation enum points to the junction table - A
Related<tag::Entity>impl enables SeaORM’s relation traversal through the junction
Model Struct Rules
Section titled “Model Struct Rules”The Model struct includes a column for every field except has_many and many_to_many relations. Those don’t have storage on this table.
The primary key attribute varies by type:
// String PK -- needs auto_increment = false#[sea_orm(primary_key, auto_increment = false)]pub id: String,
// Integer PK -- auto-increment by default#[sea_orm(primary_key)]pub id: i32,Column Type Mapping
Section titled “Column Type Mapping”| FieldType | DB Column Type | Notes |
|---|---|---|
String | String | |
OptionString | Option<String> | |
I32 | i32 | |
OptionI32 | Option<i32> | |
I64 | i64 | Also used for u64 |
OptionI64 | Option<i64> | |
Bool | bool | |
OptionBool | Option<bool> | |
VecString | String | JSON-encoded |
VecStruct(T) | String | JSON-encoded |
OptionEnum(T) | Option<String> | Stored as string; Option<i32> if T is a primitive |
Other(T) | String | i32 if T is a numeric primitive |
Relation Enum
Section titled “Relation Enum”The Relation enum gets one variant per belongs_to field and one *Link variant per many_to_many field:
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]pub enum Relation { // From belongs_to fields #[sea_orm( belongs_to = "super::workout::Entity", from = "Column::WorkoutId", to = "super::workout::Column::Id" )] WorkoutId,
#[sea_orm( belongs_to = "super::exercise::Entity", from = "Column::ExerciseId", to = "super::exercise::Column::Id" )] ExerciseId,
// From many_to_many fields (links to junction tables) #[sea_orm(has_many = "super::workout_tags::Entity")] TagsLink,}The variant name comes from to_pascal_case of the field name. For junction links, it’s {PascalField}Link.
Junction Table Generation
Section titled “Junction Table Generation”For each many_to_many relation, a complete SeaORM entity module is generated:
//! Generated by ontogen. DO NOT EDIT.
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]#[sea_orm(table_name = "workout_tags")]pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub workout_id: String, pub tag_id: String,}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]pub enum Relation { #[sea_orm( belongs_to = "super::workout::Entity", from = "Column::WorkoutId", to = "super::workout::Column::Id" )] Workout, #[sea_orm( belongs_to = "super::tag::Entity", from = "Column::TagId", to = "super::tag::Column::Id" )] Tag,}
impl Related<super::workout::Entity> for Entity { fn to() -> RelationDef { Relation::Workout.def() }}
impl Related<super::tag::Entity> for Entity { fn to() -> RelationDef { Relation::Tag.def() }}
impl ActiveModelBehavior for ActiveModel {}The junction entity always has:
- An auto-increment
i32primary key (id) - Two FK columns named
{source}_idand{target}_id belongs_torelations to both side entitiesRelatedimpls for both side entities
Table Name Derivation
Section titled “Table Name Derivation”| Source | Field | Junction Table Name |
|---|---|---|
Node | fulfills | node_fulfills |
Specification | capability_ids | specification_capability_ids |
Specification | depends_on | specification_depends_on |
Constraint | scope_ids | constraint_scope_ids |
Formula: {source_snake}_{field_name}. Override with the junction attribute:
#[ontology(relation(many_to_many, target = "Requirement", junction = "custom_name"))]Conversion Functions
Section titled “Conversion Functions”For each entity, the conversion generator produces from_model() and to_active_model() as methods on your schema type:
//! Generated by ontogen. DO NOT EDIT.
use crate::persistence::db::entities::workout as entity;use crate::schema::Workout;
impl Workout { pub fn from_model(model: &entity::Model) -> Self { Self { id: model.id.clone(), name: model.name.clone(), date: model.date.clone(), duration_minutes: model.duration_minutes, notes: model.notes.clone(), tags: Vec::new(), // populated by Store created_at: model.created_at.clone(), } }
pub fn to_active_model(&self) -> entity::ActiveModel { use sea_orm::Set;
entity::ActiveModel { id: Set(self.id.clone()), name: Set(self.name.clone()), date: Set(self.date.clone()), duration_minutes: Set(self.duration_minutes), notes: Set(self.notes.clone()), created_at: Set(self.created_at.clone()), } }}Key behaviors:
has_manyandmany_to_manyfields are initialized asVec::new()infrom_model(). The store layer populates them later.- Those same fields are absent from
to_active_model(). Junction table management is handled by the store. VecStringplain fields usedecode_json_vec()infrom_model()andserde_json::to_string()into_active_model().- Enum fields use a serde roundtrip through JSON string representation.
Skipfields are treated as plain fields in conversions (they have DB columns).
The skip_conversions Option
Section titled “The skip_conversions Option”Sometimes you need hand-written conversion logic — custom defaults, conditional fields, or complex nested deserialization. The skip_conversions config option lets you exclude specific entities:
ontogen::gen_seaorm(&entities, &ontogen::SeaOrmConfig { skip_conversions: vec!["AcceptanceCriterion".to_string()], // ...})?;The conversion file is still generated (for reference), but it’s excluded from mod.rs. You provide your own implementation in a non-generated file.
The SeaOrmOutput IR
Section titled “The SeaOrmOutput IR”gen_seaorm returns a SeaOrmOutput struct that downstream generators can use:
pub struct SeaOrmOutput { pub entity_tables: Vec<EntityTableMeta>, pub junction_tables: Vec<JunctionMeta>, pub conversion_fns: Vec<ConversionMeta>,}This is passed to gen_store as an optional enrichment:
let store_output = ontogen::gen_store( &schema.entities, Some(&seaorm_output), // enriches store with exact table/column names &store_config,)?;When present, the store generator uses the exact table names and junction metadata from SeaOrmOutput. When absent, it infers these from naming conventions.
EntityTableMeta
Section titled “EntityTableMeta”pub struct EntityTableMeta { pub entity_name: String, // "Workout" pub table_name: String, // "workouts" pub module_path: String, // "crate::persistence::db::entities::generated::workout" pub columns: Vec<ColumnMeta>,}JunctionMeta
Section titled “JunctionMeta”pub struct JunctionMeta { pub table_name: String, // "workout_tags" pub source_entity: String, // "Workout" pub target_entity: String, // "Tag" pub source_fk: String, // "workout_id" pub target_fk: String, // "tag_id"}Generated File Layout
Section titled “Generated File Layout”After running gen_seaorm, your output directories look like this:
entities/generated/ mod.rs // pub mod workout; pub mod tag; pub mod workout_tags; ... workout.rs // Model, Relation enum, Related impls workout_set.rs exercise.rs tag.rs workout_tags.rs // Junction table entity
conversions/generated/ mod.rs // pub mod workout; pub mod tag; ... workout.rs // impl Workout { from_model(), to_active_model() } workout_set.rs exercise.rs tag.rsBoth mod.rs files are regenerated on every build. Entity and junction modules are regenerated too. Stale files from deleted entities are cleaned up automatically.