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.

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_ids reads many_to_many ids back ORDER BY rowid, a column only SQLite has, so a list would not keep its written order.
  • Lists and has_many child 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’s order, which order_{plural}_query sorts in SQL. SQLite’s default BINARY collation 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 rejects OFFSET alone.
  • Integer primitives moved from i32 to i64 columns. SQLite reads an existing column as i64 in 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.

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>
I64i64
OptionI64Option<i64>
Boolbool
OptionBoolOption<bool>
VecStringStringJSON-encoded
VecStruct(T)StringJSON-encoded
OptionEnum(T)Option<String>Stored as string; Option<i64> if T is an integer primitive such as u32 or u64
Other(T)Stringi64 if T is an integer primitive such as u32 or u64

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::{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 as u32 are stored as i64 and converted with From/TryFrom; a value that does not fit fails with AppError::DbError instead of wrapping. Hand-written callers use ?.
  • 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, 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.

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.