rustango 0.7.0

A Django-inspired ORM + admin + multi-tenancy for Rust. One crate, opt in via features.
Documentation
//! Schema snapshots — serializable mirror of the inventory registry.
//!
//! v0.2 captures table + column metadata in JSON so two snapshots can be
//! diffed to produce DDL. Only the fields the writer cares about are
//! tracked: type, nullability, primary key, `max_length`, min/max,
//! relations. Per-field bounds become `CHECK` constraints; relations
//! become `FOREIGN KEY` ALTER statements.

use crate::core::{inventory, FieldType, ModelEntry, ModelSchema, Relation};
use serde::{Deserialize, Serialize};

/// A snapshot of every registered model, ordered by table name.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SchemaSnapshot {
    pub tables: Vec<TableSnapshot>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TableSnapshot {
    pub name: String,
    pub model: String,
    pub fields: Vec<FieldSnapshot>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FieldSnapshot {
    pub name: String,
    pub column: String,
    pub ty: String,
    pub nullable: bool,
    pub primary_key: bool,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub max_length: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub min: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub max: Option<i64>,
    /// Raw SQL fragment for `DEFAULT` if the model declared one.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub default: Option<String>,
    /// `true` for fields whose Rust type is `Auto<T>` — server-assigned
    /// PKs that translate to `BIGSERIAL` / `SERIAL` in DDL. Skipped on
    /// serialize when `false` so older snapshots stay diff-clean.
    #[serde(skip_serializing_if = "is_false", default)]
    pub auto: bool,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub fk: Option<RelationSnapshot>,
}

#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(v: &bool) -> bool {
    !*v
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RelationSnapshot {
    /// `"fk"` or `"o2o"`.
    pub kind: String,
    pub to: String,
    pub on: String,
}

impl SchemaSnapshot {
    /// Capture every model registered in the binary's `inventory`.
    #[must_use]
    pub fn from_registry() -> Self {
        let mut tables: Vec<TableSnapshot> = inventory::iter::<ModelEntry>
            .into_iter()
            .map(|e| TableSnapshot::from_schema(e.schema))
            .collect();
        tables.sort_by(|a, b| a.name.cmp(&b.name));
        Self { tables }
    }

    /// Capture an explicit list of model schemas — the inventory-
    /// agnostic counterpart of [`from_registry`]. Used by callers that
    /// want a curated snapshot rather than every linked model (e.g.
    /// `rustango-tenancy`'s bootstrap migrations, which pin themselves
    /// to `rustango_orgs` + `rustango_operators` + `rustango_users`).
    #[must_use]
    pub fn from_models(models: &[&ModelSchema]) -> Self {
        let mut tables: Vec<TableSnapshot> =
            models.iter().map(|s| TableSnapshot::from_schema(s)).collect();
        tables.sort_by(|a, b| a.name.cmp(&b.name));
        Self { tables }
    }

    /// Look up a table by SQL name.
    #[must_use]
    pub fn table(&self, name: &str) -> Option<&TableSnapshot> {
        self.tables.iter().find(|t| t.name == name)
    }
}

impl TableSnapshot {
    /// Build a snapshot row from a registered [`ModelSchema`]. Public
    /// so external callers (e.g. tenancy bootstrap migrations) can
    /// assemble their own snapshots without going through the global
    /// inventory.
    #[must_use]
    pub fn from_schema(s: &ModelSchema) -> Self {
        let mut fields: Vec<FieldSnapshot> =
            s.scalar_fields().map(FieldSnapshot::from_schema).collect();
        fields.sort_by(|a, b| a.column.cmp(&b.column));
        Self {
            name: s.table.to_owned(),
            model: s.name.to_owned(),
            fields,
        }
    }

    /// Look up a field by SQL column name.
    #[must_use]
    pub fn field(&self, column: &str) -> Option<&FieldSnapshot> {
        self.fields.iter().find(|f| f.column == column)
    }
}

impl FieldSnapshot {
    fn from_schema(f: &crate::core::FieldSchema) -> Self {
        let fk = f.relation.and_then(|r| match r {
            Relation::Fk { to, on } => Some(RelationSnapshot {
                kind: "fk".into(),
                to: to.to_owned(),
                on: on.to_owned(),
            }),
            Relation::O2O { to, on } => Some(RelationSnapshot {
                kind: "o2o".into(),
                to: to.to_owned(),
                on: on.to_owned(),
            }),
            Relation::M2M { .. } => None,
        });
        Self {
            name: f.name.to_owned(),
            column: f.column.to_owned(),
            ty: field_type_name(f.ty).to_owned(),
            nullable: f.nullable,
            primary_key: f.primary_key,
            max_length: f.max_length,
            min: f.min,
            max: f.max,
            default: f.default.map(str::to_owned),
            auto: f.auto,
            fk,
        }
    }
}

fn field_type_name(ty: FieldType) -> &'static str {
    // Reuse the `FieldType::as_str` mapping but with stable JSON names.
    match ty {
        FieldType::I32 => "i32",
        FieldType::I64 => "i64",
        FieldType::F32 => "f32",
        FieldType::F64 => "f64",
        FieldType::Bool => "bool",
        FieldType::String => "string",
        FieldType::DateTime => "datetime",
        FieldType::Date => "date",
        FieldType::Uuid => "uuid",
        FieldType::Json => "json",
    }
}