Skip to content

Field Types & Roles

Every field on your entity struct has two properties that drive code generation: its type (what Rust type it is) and its role (what #[ontology(...)] annotation it has). Together these determine what column type goes in the database, how the field appears in DTOs, how it’s converted between layers, and whether it gets special handling in the store.

When build.rs parses a field, it classifies the Rust type into a FieldType variant. This is a simplified representation — Ontogen doesn’t need the full complexity of Rust’s type system, just enough to make correct code generation decisions.

pub enum FieldType {
String,
I32,
I64,
Bool,
OptionString,
OptionI32,
OptionI64,
OptionBool,
OptionEnum(String),
VecString,
VecStruct(String),
Other(String),
}

The classification uses pattern matching on the AST. It recognizes the common types directly and falls back to Other(String) for anything else.

The parser inspects the last path segment of each type:

Rust TypeFieldTypeNotes
StringStringPlain string
i32I3232-bit integer
i64I6464-bit integer
u64I64Mapped to i64 (SQLite has no unsigned integers)
boolBoolBoolean
Option<String>OptionStringNullable string
Option<i32>OptionI32Nullable integer
Option<i64>OptionI64Nullable 64-bit integer
Option<u64>OptionI64Mapped to nullable i64
Option<bool>OptionBoolNullable boolean
Option<TaskStatus>OptionEnum("TaskStatus")Any non-primitive Option inner type
Vec<String>VecStringString list — stored as JSON in DB
Vec<AcceptanceCriterion>VecStruct("AcceptanceCriterion")Struct list — stored as JSON in DB
RequirementStatusOther("RequirementStatus")Anything else (enums, custom types)

Each FieldType variant maps through every layer of the pipeline. Here’s what happens to each type as it flows from your schema struct through SeaORM, then through the conversion layer:

FieldTypeSeaORM Columnfrom_model()to_active_model()
StringStringmodel.name.clone()Set(self.name.clone())
I32i32model.countSet(self.count)
I64i64model.timestampSet(self.timestamp)
Boolboolmodel.activeSet(self.active)

Copy types (i32, i64, bool) don’t need .clone(). Strings do. The generator knows the difference.

FieldTypeSeaORM Columnfrom_model()to_active_model()
OptionStringOption<String>model.notes.clone()Set(self.notes.clone())
OptionI32Option<i32>model.rpeSet(self.rpe)
OptionI64Option<i64>model.max_valueSet(self.max_value)
OptionBoolOption<bool>model.verifiedSet(self.verified)

Nullable types map directly. Option<String> in your struct becomes Option<String> in the database column. No transformation needed.

FieldTypeSeaORM Columnfrom_model()to_active_model()
VecStringStringdecode_json_vec(&model.tags)Set(serde_json::to_string(&self.tags).unwrap_or_default())
VecStruct("T")Stringserde_json::from_str(&model.items).unwrap_or_default()Set(serde_json::to_string(&self.items).unwrap_or_default())

Both Vec<String> and Vec<T> are stored as JSON strings in the database. The conversion layer serializes on write and deserializes on read.

FieldTypeSeaORM Columnfrom_model()to_active_model()
OptionEnum("TaskStatus")Option<String>serde parse from "variant" stringSet(self.status.as_ref().map(enum_to_string))
Other("RequirementStatus")Stringserde parse with fallbackSet(enum_to_string(&self.status))

Enums go through a serde JSON roundtrip. The database stores the string representation (e.g., "active"), and from_model() parses it back:

// Optional enum
status: model.status.as_deref()
.and_then(|s| serde_json::from_str::<crate::schema::TaskStatus>(
&format!("\"{s}\"")
).ok()),
// Required enum with fallback
status: serde_json::from_str::<crate::schema::RequirementStatus>(
&format!("\"{}\"", &model.status)
).unwrap_or_else(|_|
serde_json::from_str::<crate::schema::RequirementStatus>("\"\"")
.expect("enum should have a fallback variant")
),

The required enum path expects your enum to have a fallback variant that deserializes from an empty string.

When the parser encounters a type it doesn’t specifically recognize, it captures the raw type string:

pub priority: TaskPriority, // FieldType::Other("TaskPriority")

If it’s a known primitive (u8, u16, u32, etc.), the column type is i32 with a cast:

// from_model
#[allow(clippy::cast_sign_loss)] priority: model.priority as u32,
// to_active_model
#[allow(clippy::cast_possible_wrap)] priority: Set(self.priority as i32),

For non-primitive Other types, it’s stored as String in the database and cloned:

priority: model.priority.clone(),
priority: Set(self.priority.clone()),

While FieldType captures the Rust type, FieldRole captures the semantic meaning. It’s derived from the #[ontology(...)] annotation on the field (or defaults to Plain when there’s no annotation).

pub enum FieldRole {
Id, // #[ontology(id)]
Body, // #[ontology(body)]
EnumField, // #[ontology(enum_field)]
Relation(RelationInfo), // #[ontology(relation(...))]
Plain, // no annotation
Skip, // #[ontology(skip)]
}

The same FieldType can produce very different generated code depending on the role:

// Schema struct
#[ontology(id)]
pub id: String,
// Generated SeaORM column
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
// Not in Update struct (can't change an ID)
// Not in DTO update input

A Vec<String> field can mean two completely different things depending on its role:

Plain Vec<String> — data stored as JSON:

#[serde(default)]
pub tags: Vec<String>,
// FieldRole::Plain
// Stored as JSON string in a regular DB column
// from_model: decode_json_vec(&model.tags)
// to_active_model: Set(serde_json::to_string(&self.tags).unwrap())

Relation Vec<String> — IDs resolved from a junction table:

#[serde(default)]
#[ontology(relation(many_to_many, target = "Tag"))]
pub tags: Vec<String>,
// FieldRole::Relation(ManyToMany)
// NO database column
// from_model: Vec::new()
// Populated by store's populate_relations()
// Synced by store's sync_junction() on create/update

The Rust type is identical. The role determines everything about how it’s handled.

The presence or absence of Option directly maps to database nullability:

Schema TypeDB ColumnNullable?
StringStringNo (NOT NULL)
Option<String>Option<String>Yes
i32i32No
Option<i32>Option<i32>Yes
Vec<String>String (JSON)No (empty vec = "[]")

There’s one exception: Option<String> as an ID field. The SeaORM column becomes a non-nullable String, because primary keys can’t be NULL:

#[ontology(id)]
pub id: Option<String>,
// SeaORM: pub id: String (NOT nullable)

In the generated {Entity}Update struct, every field gets wrapped in Option to represent “was this field included in the update?”:

Schema TypeUpdate Struct TypeMeaning of None
StringOption<String>Field not being updated
Option<String>Option<Option<String>>Outer None = not updating; inner None = clearing the value
i32Option<i32>Field not being updated
Vec<String>Option<Vec<String>>Field not being updated
Option<TaskStatus>Option<Option<TaskStatus>>Outer None = not updating; inner None = clearing

The double-Option pattern for nullable fields lets you distinguish between “don’t change this field” (None) and “set this field to null” (Some(None)).

Here’s what the generated Update struct looks like for a Workout:

#[derive(Debug, Clone, Default)]
pub struct WorkoutUpdate {
pub name: Option<Option<String>>, // nullable field
pub date: Option<String>, // required field
pub duration_minutes: Option<Option<i32>>, // nullable field
pub notes: Option<Option<String>>, // nullable field
pub tags: Option<Vec<String>>, // vec field
pub created_at: Option<String>, // required field
}

And the apply() method uses clone_from for each field that’s Some:

impl WorkoutUpdate {
fn apply(&self, workout: &mut Workout) {
if let Some(name) = &self.name {
workout.name.clone_from(name);
}
if let Some(date) = &self.date {
workout.date.clone_from(date);
}
// ... same pattern for all fields
}
}