Skip to content

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.

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()],
})?;
FieldTypeDescription
entity_outputPathBufDirectory for generated SeaORM entity modules
conversion_outputPathBufDirectory for generated from_model/to_active_model code
skip_conversionsVec<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.

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

Each entity gets its own module. Junction tables get their own modules too. A mod.rs re-exports everything.

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

Notice what happened:

  • The tags field (many_to_many) has no column in the Model struct
  • A TagsLink variant in the Relation enum points to the junction table
  • A Related<tag::Entity> impl enables SeaORM’s relation traversal through the junction

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,
FieldTypeDB Column TypeNotes
StringString
OptionStringOption<String>
I32i32
OptionI32Option<i32>
I64i64Also used for u64
OptionI64Option<i64>
Boolbool
OptionBoolOption<bool>
VecStringStringJSON-encoded
VecStruct(T)StringJSON-encoded
OptionEnum(T)Option<String>Stored as string; Option<i32> if T is a primitive
Other(T)Stringi32 if T is a numeric primitive

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.

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 i32 primary key (id)
  • Two FK columns named {source}_id and {target}_id
  • belongs_to relations to both side entities
  • Related impls for both side entities
SourceFieldJunction Table Name
Nodefulfillsnode_fulfills
Specificationcapability_idsspecification_capability_ids
Specificationdepends_onspecification_depends_on
Constraintscope_idsconstraint_scope_ids

Formula: {source_snake}_{field_name}. Override with the junction attribute:

#[ontology(relation(many_to_many, target = "Requirement", junction = "custom_name"))]

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_many and many_to_many fields are initialized as Vec::new() in from_model(). The store layer populates them later.
  • Those same fields are absent from to_active_model(). Junction table management is handled by the store.
  • VecString plain fields use decode_json_vec() in from_model() and serde_json::to_string() in to_active_model().
  • Enum fields use a serde roundtrip through JSON string representation.
  • Skip fields are treated as plain fields in conversions (they have DB columns).

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.

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.

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

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

Both mod.rs files are regenerated on every build. Entity and junction modules are regenerated too. Stale files from deleted entities are cleaned up automatically.