Skip to main content

siderite_migrations/
state.rs

1//! Owned, serializable snapshots of model metadata.
2//!
3//! [`ModelState::from_meta`] copies a `&'static ModelMeta`. [`ProjectState::from_metas`]
4//! also synthesizes join-table models for auto many-to-many relations
5//! (`ManyToManyMeta::through == None`). Unmanaged models (`managed: false`)
6//! are skipped.
7//!
8//! [`SqlType`], [`OnDelete`] and [`DbDefault`] here are serde mirrors of the
9//! ORM types, which do not implement serde.
10
11use crate::error::MigrationError;
12use serde::{Deserialize, Serialize};
13use siderite_orm::{
14    ConstraintMeta, DbDefault as OrmDbDefault, FieldMeta, ModelMeta, OnDelete as OrmOnDelete,
15    SqlType as OrmSqlType,
16};
17use std::collections::BTreeMap;
18
19/// Portable column type family (serde mirror of [`siderite_orm::SqlType`]).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SqlType {
23    /// 16-bit integer.
24    SmallInt,
25    /// 32-bit integer.
26    Integer,
27    /// 64-bit integer.
28    BigInt,
29    /// 32-bit float.
30    Real,
31    /// 64-bit float.
32    Double,
33    /// Exact decimal.
34    Decimal,
35    /// Boolean.
36    Bool,
37    /// Text / `VARCHAR(n)`.
38    Text,
39    /// Binary data.
40    Binary,
41    /// Calendar date.
42    Date,
43    /// Time of day.
44    Time,
45    /// UTC timestamp.
46    Timestamp,
47    /// Duration stored as 64-bit microseconds.
48    Duration,
49    /// UUID.
50    Uuid,
51    /// JSON document.
52    Json,
53    /// IP address stored as text.
54    IpAddr,
55}
56
57impl From<OrmSqlType> for SqlType {
58    fn from(value: OrmSqlType) -> Self {
59        match value {
60            OrmSqlType::SmallInt => Self::SmallInt,
61            OrmSqlType::Integer => Self::Integer,
62            OrmSqlType::BigInt => Self::BigInt,
63            OrmSqlType::Real => Self::Real,
64            OrmSqlType::Double => Self::Double,
65            OrmSqlType::Decimal => Self::Decimal,
66            OrmSqlType::Bool => Self::Bool,
67            OrmSqlType::Text => Self::Text,
68            OrmSqlType::Binary => Self::Binary,
69            OrmSqlType::Date => Self::Date,
70            OrmSqlType::Time => Self::Time,
71            OrmSqlType::Timestamp => Self::Timestamp,
72            OrmSqlType::Duration => Self::Duration,
73            OrmSqlType::Uuid => Self::Uuid,
74            OrmSqlType::Json => Self::Json,
75            OrmSqlType::IpAddr => Self::IpAddr,
76            other => unknown_sql_type(other),
77        }
78    }
79}
80
81impl From<SqlType> for OrmSqlType {
82    fn from(value: SqlType) -> Self {
83        match value {
84            SqlType::SmallInt => Self::SmallInt,
85            SqlType::Integer => Self::Integer,
86            SqlType::BigInt => Self::BigInt,
87            SqlType::Real => Self::Real,
88            SqlType::Double => Self::Double,
89            SqlType::Decimal => Self::Decimal,
90            SqlType::Bool => Self::Bool,
91            SqlType::Text => Self::Text,
92            SqlType::Binary => Self::Binary,
93            SqlType::Date => Self::Date,
94            SqlType::Time => Self::Time,
95            SqlType::Timestamp => Self::Timestamp,
96            SqlType::Duration => Self::Duration,
97            SqlType::Uuid => Self::Uuid,
98            SqlType::Json => Self::Json,
99            SqlType::IpAddr => Self::IpAddr,
100        }
101    }
102}
103
104fn unknown_sql_type(other: OrmSqlType) -> SqlType {
105    // `SqlType` is `#[non_exhaustive]` on the ORM side; new variants fall
106    // back to Text so an older migrations crate still serializes.
107    let _ = other;
108    SqlType::Text
109}
110
111/// Deletion policy (serde mirror of [`siderite_orm::OnDelete`]).
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
113#[serde(rename_all = "snake_case")]
114pub enum OnDelete {
115    /// Delete referencing rows too.
116    #[default]
117    Cascade,
118    /// Refuse the delete while references exist (`RESTRICT` in DDL).
119    Protect,
120    /// Set the reference to `NULL`.
121    SetNull,
122    /// Set the reference to the column default.
123    SetDefault,
124    /// Leave referencing rows untouched (`NO ACTION` in DDL).
125    DoNothing,
126}
127
128impl From<OrmOnDelete> for OnDelete {
129    fn from(value: OrmOnDelete) -> Self {
130        match value {
131            OrmOnDelete::Cascade => Self::Cascade,
132            OrmOnDelete::Protect => Self::Protect,
133            OrmOnDelete::SetNull => Self::SetNull,
134            OrmOnDelete::SetDefault => Self::SetDefault,
135            OrmOnDelete::DoNothing => Self::DoNothing,
136        }
137    }
138}
139
140impl From<OnDelete> for OrmOnDelete {
141    fn from(value: OnDelete) -> Self {
142        match value {
143            OnDelete::Cascade => Self::Cascade,
144            OnDelete::Protect => Self::Protect,
145            OnDelete::SetNull => Self::SetNull,
146            OnDelete::SetDefault => Self::SetDefault,
147            OnDelete::DoNothing => Self::DoNothing,
148        }
149    }
150}
151
152/// Server-side column default (serde mirror of [`siderite_orm::DbDefault`]).
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum DbDefault {
156    /// Current timestamp at insert time (`auto_now_add`).
157    Now,
158    /// A literal integer.
159    Int(i64),
160    /// A literal boolean.
161    Bool(bool),
162    /// A literal string.
163    Text(String),
164}
165
166impl From<OrmDbDefault> for DbDefault {
167    fn from(value: OrmDbDefault) -> Self {
168        match value {
169            OrmDbDefault::Now => Self::Now,
170            OrmDbDefault::Int(i) => Self::Int(i),
171            OrmDbDefault::Bool(b) => Self::Bool(b),
172            OrmDbDefault::Text(s) => Self::Text(s.to_owned()),
173        }
174    }
175}
176
177/// Foreign-key target stored on a field snapshot.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ForeignKeyState {
180    /// Referenced table.
181    pub target_table: String,
182    /// Referenced column (the target's primary key).
183    pub target_column: String,
184    /// `ON DELETE` policy.
185    pub on_delete: OnDelete,
186}
187
188/// One concrete column.
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct FieldState {
191    /// Rust field name.
192    pub name: String,
193    /// Column name.
194    pub column: String,
195    /// Column type family.
196    pub sql_type: SqlType,
197    /// Accepts `NULL`.
198    pub nullable: bool,
199    /// Part of the primary key.
200    pub primary_key: bool,
201    /// Value generated by the database (autoincrement / identity).
202    pub auto: bool,
203    /// `UNIQUE`.
204    pub unique: bool,
205    /// Single-column index.
206    pub index: bool,
207    /// `VARCHAR(n)`.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub max_length: Option<u32>,
210    /// Decimal precision.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub max_digits: Option<u32>,
213    /// Decimal scale.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub decimal_places: Option<u32>,
216    /// Database default.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub default: Option<DbDefault>,
219    /// Foreign-key relation.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub fk: Option<ForeignKeyState>,
222}
223
224impl FieldState {
225    /// A plain non-null column of `sql_type`.
226    pub fn new(name: impl Into<String>, column: impl Into<String>, sql_type: SqlType) -> Self {
227        Self {
228            name: name.into(),
229            column: column.into(),
230            sql_type,
231            nullable: false,
232            primary_key: false,
233            auto: false,
234            unique: false,
235            index: false,
236            max_length: None,
237            max_digits: None,
238            decimal_places: None,
239            default: None,
240            fk: None,
241        }
242    }
243
244    /// Copy a [`FieldMeta`], resolving the FK target table and primary-key column.
245    pub fn from_meta(field: &FieldMeta) -> Self {
246        let fk = field.relation.map(|rel| {
247            let target = (rel.target)();
248            let target_column = target
249                .pk()
250                .map(|pk| pk.column.to_owned())
251                .unwrap_or_else(|| "id".to_owned());
252            ForeignKeyState {
253                target_table: target.table.to_owned(),
254                target_column,
255                on_delete: rel.on_delete.into(),
256            }
257        });
258        Self {
259            name: field.name.to_owned(),
260            column: field.column.to_owned(),
261            sql_type: field.sql_type.into(),
262            nullable: field.nullable,
263            primary_key: field.primary_key,
264            auto: field.auto,
265            unique: field.unique,
266            index: field.index,
267            max_length: field.max_length,
268            max_digits: field.max_digits,
269            decimal_places: field.decimal_places,
270            default: field.default.map(Into::into),
271            fk,
272        }
273    }
274}
275
276/// A named multi-column index.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct IndexState {
279    /// Index name.
280    pub name: String,
281    /// Columns in order.
282    pub columns: Vec<String>,
283    /// `UNIQUE` index.
284    pub unique: bool,
285}
286
287impl IndexState {
288    /// Copy a [`siderite_orm::IndexMeta`].
289    pub fn from_meta(meta: &siderite_orm::IndexMeta) -> Self {
290        Self {
291            name: meta.name.to_owned(),
292            columns: meta.columns.iter().map(|c| (*c).to_owned()).collect(),
293            unique: meta.unique,
294        }
295    }
296}
297
298/// A table-level constraint.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(tag = "kind", rename_all = "snake_case")]
301pub enum ConstraintState {
302    /// `UNIQUE (a, b)`.
303    Unique {
304        /// Constraint name.
305        name: String,
306        /// Columns.
307        columns: Vec<String>,
308    },
309    /// `CHECK (<sql>)`. The SQL is written by the model author.
310    Check {
311        /// Constraint name.
312        name: String,
313        /// Boolean SQL expression.
314        sql: String,
315    },
316}
317
318impl ConstraintState {
319    /// Copy a [`ConstraintMeta`].
320    pub fn from_meta(meta: &ConstraintMeta) -> Self {
321        match *meta {
322            ConstraintMeta::Unique { name, columns } => Self::Unique {
323                name: name.to_owned(),
324                columns: columns.iter().map(|c| (*c).to_owned()).collect(),
325            },
326            ConstraintMeta::Check { name, sql } => Self::Check {
327                name: name.to_owned(),
328                sql: sql.to_owned(),
329            },
330        }
331    }
332
333    /// Constraint name.
334    pub fn name(&self) -> &str {
335        match self {
336            Self::Unique { name, .. } | Self::Check { name, .. } => name,
337        }
338    }
339}
340
341/// Snapshot of one model (or a synthesized M2M join table).
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct ModelState {
344    /// Rust type name (`User`); also the migration model name.
345    pub name: String,
346    /// Table name.
347    pub table: String,
348    /// Concrete columns in declaration order.
349    pub fields: Vec<FieldState>,
350    /// Named indexes.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub indexes: Vec<IndexState>,
353    /// Table constraints.
354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
355    pub constraints: Vec<ConstraintState>,
356}
357
358impl ModelState {
359    /// Copy a managed [`ModelMeta`]. Returns `None` when `managed` is false.
360    pub fn from_meta(meta: &ModelMeta) -> Option<Self> {
361        if !meta.managed {
362            return None;
363        }
364        Some(Self {
365            name: meta.name.to_owned(),
366            table: meta.table.to_owned(),
367            fields: meta.fields.iter().map(FieldState::from_meta).collect(),
368            indexes: meta.indexes.iter().map(IndexState::from_meta).collect(),
369            constraints: meta
370                .constraints
371                .iter()
372                .map(ConstraintState::from_meta)
373                .collect(),
374        })
375    }
376
377    /// Field by Rust name.
378    pub fn field(&self, name: &str) -> Option<&FieldState> {
379        self.fields.iter().find(|f| f.name == name)
380    }
381
382    /// Mutable field by Rust name.
383    pub fn field_mut(&mut self, name: &str) -> Option<&mut FieldState> {
384        self.fields.iter_mut().find(|f| f.name == name)
385    }
386
387    /// Field by column name.
388    pub fn column(&self, column: &str) -> Option<&FieldState> {
389        self.fields.iter().find(|f| f.column == column)
390    }
391
392    /// Primary-key field, if declared.
393    pub fn pk(&self) -> Option<&FieldState> {
394        self.fields.iter().find(|f| f.primary_key)
395    }
396
397    /// Named index.
398    pub fn index(&self, name: &str) -> Option<&IndexState> {
399        self.indexes.iter().find(|i| i.name == name)
400    }
401
402    /// Named constraint.
403    pub fn constraint(&self, name: &str) -> Option<&ConstraintState> {
404        self.constraints.iter().find(|c| c.name() == name)
405    }
406}
407
408/// The whole project's managed schema.
409#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
410pub struct ProjectState {
411    /// Models keyed by [`ModelState::name`].
412    pub models: BTreeMap<String, ModelState>,
413}
414
415impl ProjectState {
416    /// Empty project.
417    pub fn new() -> Self {
418        Self::default()
419    }
420
421    /// Build from compiled metadata, synthesizing auto M2M join tables.
422    pub fn from_metas(metas: &[&ModelMeta]) -> Self {
423        let mut models = BTreeMap::new();
424        for meta in metas {
425            if let Some(state) = ModelState::from_meta(meta) {
426                models.insert(state.name.clone(), state);
427            }
428        }
429        for meta in metas {
430            if !meta.managed {
431                continue;
432            }
433            for m2m in meta.many_to_many {
434                if m2m.through.is_some() {
435                    continue;
436                }
437                let join = synthesize_m2m(meta, m2m);
438                models.insert(join.name.clone(), join);
439            }
440        }
441        Self { models }
442    }
443
444    /// Model by name.
445    pub fn model(&self, name: &str) -> Option<&ModelState> {
446        self.models.get(name)
447    }
448
449    /// Mutable model by name.
450    pub fn model_mut(&mut self, name: &str) -> Option<&mut ModelState> {
451        self.models.get_mut(name)
452    }
453
454    /// Model whose table is `table`.
455    pub fn model_for_table(&self, table: &str) -> Option<&ModelState> {
456        self.models.values().find(|m| m.table == table)
457    }
458
459    /// Model named `name`, or a [`MigrationError::State`].
460    pub fn require(&self, name: &str) -> Result<&ModelState, MigrationError> {
461        self.model(name).ok_or_else(|| {
462            MigrationError::state(format!("model `{name}` is not in the project state"))
463        })
464    }
465
466    /// Mutable model named `name`, or a [`MigrationError::State`].
467    pub fn require_mut(&mut self, name: &str) -> Result<&mut ModelState, MigrationError> {
468        self.model_mut(name).ok_or_else(|| {
469            MigrationError::state(format!("model `{name}` is not in the project state"))
470        })
471    }
472}
473
474fn synthesize_m2m(source: &ModelMeta, m2m: &siderite_orm::ManyToManyMeta) -> ModelState {
475    let target = (m2m.target)();
476    let source_pk = source.pk();
477    let target_pk = target.pk();
478    let source_type = source_pk
479        .map(|f| SqlType::from(f.sql_type))
480        .unwrap_or(SqlType::BigInt);
481    let target_type = target_pk
482        .map(|f| SqlType::from(f.sql_type))
483        .unwrap_or(SqlType::BigInt);
484    let source_column_pk = source_pk.map(|f| f.column).unwrap_or("id");
485    let target_column_pk = target_pk.map(|f| f.column).unwrap_or("id");
486
487    let source_field = FieldState {
488        index: true,
489        fk: Some(ForeignKeyState {
490            target_table: source.table.to_owned(),
491            target_column: source_column_pk.to_owned(),
492            on_delete: OnDelete::Cascade,
493        }),
494        ..FieldState::new(m2m.source_column, m2m.source_column, source_type)
495    };
496    let target_field = FieldState {
497        index: true,
498        fk: Some(ForeignKeyState {
499            target_table: target.table.to_owned(),
500            target_column: target_column_pk.to_owned(),
501            on_delete: OnDelete::Cascade,
502        }),
503        ..FieldState::new(m2m.target_column, m2m.target_column, target_type)
504    };
505
506    ModelState {
507        name: m2m.through_table.to_owned(),
508        table: m2m.through_table.to_owned(),
509        fields: vec![source_field, target_field],
510        indexes: Vec::new(),
511        constraints: vec![ConstraintState::Unique {
512            name: format!("{}_uniq", m2m.through_table),
513            columns: vec![m2m.source_column.to_owned(), m2m.target_column.to_owned()],
514        }],
515    }
516}
517
518/// Deterministic name for the single-column index implied by `field.index`.
519pub fn auto_index_name(table: &str, column: &str) -> String {
520    format!("{table}_{column}_idx")
521}