use crate::core::{inventory, FieldType, ModelEntry, ModelSchema, Relation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct SchemaSnapshot {
pub tables: Vec<TableSnapshot>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub m2m_tables: Vec<M2MTableSnapshot>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub indexes: Vec<IndexSnapshot>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub checks: Vec<CheckSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CheckSnapshot {
pub name: String,
pub table: String,
pub expr: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IndexSnapshot {
pub name: String,
pub table: String,
pub columns: Vec<String>,
pub unique: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct M2MTableSnapshot {
pub through: String,
pub src_table: String,
pub src_col: String,
pub dst_table: String,
pub dst_col: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TableSnapshot {
pub name: String,
pub model: String,
pub fields: Vec<FieldSnapshot>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub composite_fks: Vec<CompositeFkSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CompositeFkSnapshot {
pub name: String,
pub to: String,
pub from: Vec<String>,
pub on: Vec<String>,
}
#[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>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub default: Option<String>,
#[serde(skip_serializing_if = "is_false", default)]
pub auto: bool,
#[serde(skip_serializing_if = "is_false", default)]
pub unique: 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 {
pub kind: String,
pub to: String,
pub on: String,
}
impl SchemaSnapshot {
#[must_use]
pub fn from_registry() -> Self {
let entries: Vec<&ModelEntry> = inventory::iter::<ModelEntry>.into_iter().collect();
let mut tables: Vec<TableSnapshot> =
entries.iter().map(|e| TableSnapshot::from_schema(e.schema)).collect();
tables.sort_by(|a, b| a.name.cmp(&b.name));
let m2m_tables = collect_m2m_tables(entries.iter().map(|e| e.schema));
let indexes = collect_indexes(entries.iter().map(|e| e.schema));
let checks = collect_checks(entries.iter().map(|e| e.schema));
Self { tables, m2m_tables, indexes, checks }
}
#[must_use]
pub fn from_registry_for_app(app: &str) -> Self {
let entries: Vec<&ModelEntry> = inventory::iter::<ModelEntry>
.into_iter()
.filter(|e| e.resolved_app_label() == Some(app))
.collect();
let mut tables: Vec<TableSnapshot> =
entries.iter().map(|e| TableSnapshot::from_schema(e.schema)).collect();
tables.sort_by(|a, b| a.name.cmp(&b.name));
let m2m_tables = collect_m2m_tables(entries.iter().map(|e| e.schema));
let indexes = collect_indexes(entries.iter().map(|e| e.schema));
let checks = collect_checks(entries.iter().map(|e| e.schema));
Self { tables, m2m_tables, indexes, checks }
}
#[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));
let m2m_tables = collect_m2m_tables(models.iter().copied());
let indexes = collect_indexes(models.iter().copied());
let checks = collect_checks(models.iter().copied());
Self { tables, m2m_tables, indexes, checks }
}
#[must_use]
pub fn m2m_table(&self, through: &str) -> Option<&M2MTableSnapshot> {
self.m2m_tables.iter().find(|t| t.through == through)
}
#[must_use]
pub fn index(&self, name: &str) -> Option<&IndexSnapshot> {
self.indexes.iter().find(|i| i.name == name)
}
#[must_use]
pub fn check(&self, name: &str) -> Option<&CheckSnapshot> {
self.checks.iter().find(|c| c.name == name)
}
#[must_use]
pub fn table(&self, name: &str) -> Option<&TableSnapshot> {
self.tables.iter().find(|t| t.name == name)
}
}
impl TableSnapshot {
#[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));
let composite_fks: Vec<CompositeFkSnapshot> = s
.composite_relations
.iter()
.map(|rel| CompositeFkSnapshot {
name: rel.name.to_owned(),
to: rel.to.to_owned(),
from: rel.from.iter().map(|c| (*c).to_owned()).collect(),
on: rel.on.iter().map(|c| (*c).to_owned()).collect(),
})
.collect();
Self {
name: s.table.to_owned(),
model: s.name.to_owned(),
fields,
composite_fks,
}
}
#[must_use]
pub fn field(&self, column: &str) -> Option<&FieldSnapshot> {
self.fields.iter().find(|f| f.column == column)
}
#[must_use]
pub fn composite_fk(&self, name: &str) -> Option<&CompositeFkSnapshot> {
self.composite_fks.iter().find(|c| c.name == name)
}
}
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(),
}),
});
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,
unique: f.unique,
fk,
}
}
}
fn field_type_name(ty: FieldType) -> &'static str {
match ty {
FieldType::I16 => "i16",
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",
}
}
fn collect_checks<'a>(schemas: impl Iterator<Item = &'a ModelSchema>) -> Vec<CheckSnapshot> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<CheckSnapshot> = Vec::new();
for schema in schemas {
for c in schema.check_constraints {
if seen.insert(c.name) {
out.push(CheckSnapshot {
name: c.name.to_owned(),
table: schema.table.to_owned(),
expr: c.expr.to_owned(),
});
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn collect_indexes<'a>(schemas: impl Iterator<Item = &'a ModelSchema>) -> Vec<IndexSnapshot> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<IndexSnapshot> = Vec::new();
for schema in schemas {
for idx in schema.indexes {
if seen.insert(idx.name) {
out.push(IndexSnapshot {
name: idx.name.to_owned(),
table: schema.table.to_owned(),
columns: idx.columns.iter().map(|&c| c.to_owned()).collect(),
unique: idx.unique,
});
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn collect_m2m_tables<'a>(
schemas: impl Iterator<Item = &'a ModelSchema>,
) -> Vec<M2MTableSnapshot> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<M2MTableSnapshot> = Vec::new();
for schema in schemas {
for rel in schema.m2m {
if seen.insert(rel.through) {
out.push(M2MTableSnapshot {
through: rel.through.to_owned(),
src_table: schema.table.to_owned(),
src_col: rel.src_col.to_owned(),
dst_table: rel.to.to_owned(),
dst_col: rel.dst_col.to_owned(),
});
}
}
}
out.sort_by(|a, b| a.through.cmp(&b.through));
out
}
#[cfg(test)]
mod composite_fk_snapshot_tests {
use super::*;
use crate::core::{CompositeFkRelation, FieldSchema, FieldType};
fn schema_with_composite_fk() -> &'static ModelSchema {
static FIELDS: [FieldSchema; 1] = [FieldSchema {
name: "id",
column: "id",
ty: FieldType::I64,
nullable: false,
primary_key: true,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
unique: false,
}];
static COMPS: [CompositeFkRelation; 1] = [CompositeFkRelation {
name: "target",
to: "other_table",
from: &["a", "b"],
on: &["x", "y"],
}];
static MS: ModelSchema = ModelSchema {
name: "Demo",
table: "demo",
fields: &FIELDS,
display: None,
app_label: None,
admin: None,
soft_delete_column: None,
audit_track: None,
permissions: false,
indexes: &[],
check_constraints: &[],
m2m: &[],
composite_relations: &COMPS,
generic_relations: &[],
};
&MS
}
#[test]
fn from_schema_captures_composite_fks_in_declaration_order() {
let snap = TableSnapshot::from_schema(schema_with_composite_fk());
assert_eq!(snap.composite_fks.len(), 1);
let c = &snap.composite_fks[0];
assert_eq!(c.name, "target");
assert_eq!(c.to, "other_table");
assert_eq!(c.from, vec!["a", "b"]);
assert_eq!(c.on, vec!["x", "y"]);
}
#[test]
fn empty_composite_fks_skipped_on_serialize_for_back_compat() {
static FIELDS: [FieldSchema; 1] = [FieldSchema {
name: "id",
column: "id",
ty: FieldType::I64,
nullable: false,
primary_key: true,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
unique: false,
}];
static MS: ModelSchema = ModelSchema {
name: "Plain",
table: "plain",
fields: &FIELDS,
display: None,
app_label: None,
admin: None,
soft_delete_column: None,
audit_track: None,
permissions: false,
indexes: &[],
check_constraints: &[],
m2m: &[],
composite_relations: &[],
generic_relations: &[],
};
let snap = TableSnapshot::from_schema(&MS);
let json = serde_json::to_string(&snap).expect("serialize");
assert!(
!json.contains("composite_fks"),
"empty composite_fks should not appear in JSON; got: {json}"
);
}
}