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.
Database support
Section titled “Database support”The SeaORM backend supports SQLite only, for now. The generated store and the consumer helpers the docs and examples ship (sync_junction, load_junction_ids) are written and tested against SQLite, and the cross-backend guarantees (byte-order ids, written order for many_to_many, the same results as the markdown backend) are checked on SQLite alone. Supporting another engine is a separate piece of work, not a configuration switch.
On Postgres or MySQL, today’s code would break in these places:
load_junction_idsreadsmany_to_manyids backORDER BY rowid, a column only SQLite has, so a list would not keep its written order.- Lists and
has_manychild ids are ordered by id and must come back in byte order, as on markdown. The same holds for every string key of a list’sorder, whichorder_{plural}_querysorts in SQL. SQLite’s defaultBINARYcollation does that; a Postgres locale collation or MySQL’s case-insensitive default does not. - A list with an offset and no limit sends
LIMIT i64::MAX, because SQLite rejectsOFFSETalone. - Integer primitives moved from
i32toi64columns. SQLite reads an existing column asi64in place; other engines need the column widened (see Upgrading).
Each such place carries a sqlite-only comment that says why, in the generator, in the generated store, and in the in-tree helpers, so a search for sqlite-only finds them.
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 | |
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<i64> if T is an integer primitive such as u32 or u64 |
Other(T) | String | i64 if T is an integer primitive such as u32 or u64 |
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::{AppError, Workout};
impl Workout { pub fn from_model(model: &entity::Model) -> Result<Self, AppError> { Ok(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) -> Result<entity::ActiveModel, AppError> { use sea_orm::Set;
Ok(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:
- Both functions return
Result<_, AppError>. Integer primitives such asu32are stored asi64and converted withFrom/TryFrom; a value that does not fit fails withAppError::DbErrorinstead of wrapping. Hand-written callers use?. 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, inside StoreConfig::backend, as an optional enrichment:
// Enriches the store with exact table/column names.store_config.backend = ontogen::Backend::Seaorm(Some(seaorm_output));let store_output = ontogen::gen_store(&schema, &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.