use std::path::Path;
use autumn_schema_core::{Backend, Schema, Table};
use serde::{Deserialize, Serialize};
pub const SNAPSHOT_VERSION: u32 = 1;
pub const SNAPSHOT_DEFAULT_PATH: &str = ".autumn/schema-snapshot.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaSnapshot {
pub snapshot_version: u32,
pub backend: Backend,
pub tables: Vec<Table>,
}
impl SchemaSnapshot {
#[must_use]
pub const fn new(backend: Backend, tables: Vec<Table>) -> Self {
Self {
snapshot_version: SNAPSHOT_VERSION,
backend,
tables,
}
}
#[allow(dead_code)]
#[must_use]
pub fn from_schema(schema: Schema) -> Self {
Self::new(schema.backend, schema.tables)
}
#[allow(dead_code)]
pub fn ensure_backend_matches(&self, expected: Backend) -> Result<(), SnapshotError> {
if self.backend == expected {
Ok(())
} else {
Err(SnapshotError::BackendMismatch {
expected,
found: self.backend,
})
}
}
}
#[allow(dead_code)]
#[derive(Debug, thiserror::Error)]
pub enum SnapshotError {
#[error("failed to access snapshot at {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to parse snapshot at {path}: {source}")]
Json {
path: String,
#[source]
source: serde_json::Error,
},
#[error(
"unsupported snapshot_version {found} (this build understands version {SNAPSHOT_VERSION}); \
upgrade the Autumn CLI to read this snapshot"
)]
UnsupportedVersion {
found: u32,
},
#[error("snapshot backend {found:?} does not match the expected backend {expected:?}")]
BackendMismatch {
expected: Backend,
found: Backend,
},
}
#[must_use]
pub fn to_canonical_json(snapshot: &SchemaSnapshot) -> String {
let canonical = SchemaSnapshot {
snapshot_version: snapshot.snapshot_version,
backend: snapshot.backend,
tables: canonical_tables(&snapshot.tables),
};
let mut json = serde_json::to_string_pretty(&canonical)
.expect("SchemaSnapshot is always serializable to JSON");
json.push('\n');
json
}
fn canonical_tables(tables: &[Table]) -> Vec<Table> {
let mut tables = tables.to_vec();
tables.sort_by(|a, b| a.name.cmp(&b.name));
for table in &mut tables {
table.indexes.sort_by(|a, b| a.name.cmp(&b.name));
table.checks.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| a.expression.cmp(&b.expression))
});
}
tables
}
pub fn write_snapshot(path: &Path, snapshot: &SchemaSnapshot) -> Result<(), SnapshotError> {
use std::io::Write;
let io_error = |source: std::io::Error| SnapshotError::Io {
path: path.display().to_string(),
source,
};
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent).map_err(|source| SnapshotError::Io {
path: parent.display().to_string(),
source,
})?;
}
let dir = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(io_error)?;
temp.write_all(to_canonical_json(snapshot).as_bytes())
.and_then(|()| temp.as_file().sync_all())
.map_err(io_error)?;
temp.persist(path).map_err(|e| io_error(e.error))?;
Ok(())
}
#[allow(dead_code)]
pub fn load_snapshot(path: &Path) -> Result<SchemaSnapshot, SnapshotError> {
let text = std::fs::read_to_string(path).map_err(|source| SnapshotError::Io {
path: path.display().to_string(),
source,
})?;
parse_snapshot_str(&text, &path.display().to_string())
}
#[allow(dead_code)]
fn parse_snapshot_str(text: &str, label: &str) -> Result<SchemaSnapshot, SnapshotError> {
let value: serde_json::Value =
serde_json::from_str(text).map_err(|source| SnapshotError::Json {
path: label.to_owned(),
source,
})?;
if let Some(version) = value
.get("snapshot_version")
.and_then(serde_json::Value::as_u64)
&& version != u64::from(SNAPSHOT_VERSION)
{
return Err(SnapshotError::UnsupportedVersion {
found: u32::try_from(version).unwrap_or(u32::MAX),
});
}
serde_json::from_value(value).map_err(|source| SnapshotError::Json {
path: label.to_owned(),
source,
})
}
#[cfg(test)]
#[allow(clippy::needless_raw_string_hashes)]
mod tests {
use super::*;
use autumn_schema_core::{
CheckConstraint, Column, ColumnDefault, ColumnType, ForeignKey, Index,
};
use crate::schema::parse::parse_model_source;
fn fixture_tables() -> Vec<Table> {
let mut posts = Table::new("posts", Backend::Postgres);
let mut id = Column::new("id", ColumnType::Int64);
id.primary_key = true;
posts.primary_key.push("id".to_owned());
let mut author = Column::new("author_id", ColumnType::Int64);
author.references = Some(ForeignKey::new("users", "id"));
let mut created = Column::new("created_at", ColumnType::Timestamp);
created.default = Some(ColumnDefault::Now);
posts.columns.push(id);
posts.columns.push(author);
posts.columns.push(Column::new("body", ColumnType::Text));
posts.columns.push(created);
posts.indexes.push(Index {
name: "idx_posts_created_at".to_owned(),
columns: vec!["created_at".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
posts.indexes.push(Index {
name: "idx_posts_author_id".to_owned(),
columns: vec!["author_id".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
posts.checks.push(CheckConstraint {
name: Some("posts_body_len".to_owned()),
expression: "length(body) > 0".to_owned(),
});
let mut accounts = Table::new("accounts", Backend::Postgres);
let mut aid = Column::new("id", ColumnType::Int64);
aid.primary_key = true;
accounts.primary_key.push("id".to_owned());
let mut email = Column::new("email", ColumnType::Text);
email.unique = true;
accounts.columns.push(aid);
accounts.columns.push(email);
vec![posts, accounts]
}
#[test]
fn round_trips_through_canonical_json() {
let snapshot = SchemaSnapshot::new(Backend::Postgres, fixture_tables());
let json = to_canonical_json(&snapshot);
let back = parse_snapshot_str(&json, "<test>").expect("parse back");
let expected = SchemaSnapshot {
snapshot_version: SNAPSHOT_VERSION,
backend: Backend::Postgres,
tables: canonical_tables(&snapshot.tables),
};
assert_eq!(back, expected);
}
#[test]
fn write_then_load_round_trips_on_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".autumn").join("schema-snapshot.json");
let snapshot = SchemaSnapshot::new(Backend::Sqlite, fixture_tables());
write_snapshot(&path, &snapshot).expect("write");
let loaded = load_snapshot(&path).expect("load");
assert_eq!(loaded.backend, Backend::Sqlite);
assert_eq!(loaded.snapshot_version, SNAPSHOT_VERSION);
assert_eq!(loaded.tables, canonical_tables(&snapshot.tables));
}
#[test]
#[allow(clippy::too_many_lines)] fn canonical_json_matches_golden_and_is_byte_stable() {
let snapshot = SchemaSnapshot::new(Backend::Postgres, fixture_tables());
let json = to_canonical_json(&snapshot);
let expected = r#"{
"snapshot_version": 1,
"backend": "Postgres",
"tables": [
{
"name": "accounts",
"columns": [
{
"name": "id",
"ty": "Int64",
"nullable": false,
"primary_key": true,
"unique": false,
"default": null,
"references": null
},
{
"name": "email",
"ty": "Text",
"nullable": false,
"primary_key": false,
"unique": true,
"default": null,
"references": null
}
],
"primary_key": [
"id"
],
"indexes": [],
"checks": [],
"backend": "Postgres",
"managed": true
},
{
"name": "posts",
"columns": [
{
"name": "id",
"ty": "Int64",
"nullable": false,
"primary_key": true,
"unique": false,
"default": null,
"references": null
},
{
"name": "author_id",
"ty": "Int64",
"nullable": false,
"primary_key": false,
"unique": false,
"default": null,
"references": {
"table": "users",
"column": "id"
}
},
{
"name": "body",
"ty": "Text",
"nullable": false,
"primary_key": false,
"unique": false,
"default": null,
"references": null
},
{
"name": "created_at",
"ty": "Timestamp",
"nullable": false,
"primary_key": false,
"unique": false,
"default": "Now",
"references": null
}
],
"primary_key": [
"id"
],
"indexes": [
{
"name": "idx_posts_author_id",
"columns": [
"author_id"
],
"unique": false
},
{
"name": "idx_posts_created_at",
"columns": [
"created_at"
],
"unique": false
}
],
"checks": [
{
"name": "posts_body_len",
"expression": "length(body) > 0"
}
],
"backend": "Postgres",
"managed": true
}
]
}
"#;
assert_eq!(
json, expected,
"canonical JSON drifted from the golden fixture"
);
assert_eq!(to_canonical_json(&snapshot), json);
let reparsed = parse_snapshot_str(&json, "<test>").expect("parse");
assert_eq!(to_canonical_json(&reparsed), json);
}
#[test]
fn write_snapshot_atomically_replaces_existing_file_without_leftover_temp() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("schema-snapshot.json");
std::fs::write(&path, "OLD STALE CONTENT").expect("seed old baseline");
let snapshot = SchemaSnapshot::new(Backend::Postgres, fixture_tables());
write_snapshot(&path, &snapshot).expect("write");
let written = std::fs::read_to_string(&path).expect("read back");
assert_eq!(written, to_canonical_json(&snapshot));
assert!(!written.contains("OLD STALE CONTENT"));
let entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("read_dir")
.map(|e| e.expect("entry").file_name())
.collect();
assert_eq!(
entries,
vec![std::ffi::OsString::from("schema-snapshot.json")],
"the atomic write must leave no leftover temp file: {entries:?}"
);
}
#[test]
fn unknown_future_version_is_rejected() {
let doc = r#"{ "snapshot_version": 999, "backend": "Postgres", "tables": [] }"#;
let err = parse_snapshot_str(doc, "<test>").unwrap_err();
match err {
SnapshotError::UnsupportedVersion { found } => assert_eq!(found, 999),
other => panic!("expected UnsupportedVersion, got {other:?}"),
}
}
#[test]
fn backend_tag_serializes_and_ensure_matches() {
let pg = SchemaSnapshot::new(Backend::Postgres, Vec::new());
assert!(to_canonical_json(&pg).contains("\"backend\": \"Postgres\""));
assert!(pg.ensure_backend_matches(Backend::Postgres).is_ok());
let mismatch = pg.ensure_backend_matches(Backend::Sqlite).unwrap_err();
assert!(matches!(
mismatch,
SnapshotError::BackendMismatch {
expected: Backend::Sqlite,
found: Backend::Postgres
}
));
let sqlite = SchemaSnapshot::new(Backend::Sqlite, Vec::new());
assert!(to_canonical_json(&sqlite).contains("\"backend\": \"Sqlite\""));
assert!(sqlite.ensure_backend_matches(Backend::Sqlite).is_ok());
}
#[test]
fn malformed_json_is_a_json_error() {
let err = parse_snapshot_str("{ not json", "<test>").unwrap_err();
assert!(matches!(err, SnapshotError::Json { .. }));
}
#[test]
fn end_to_end_from_parsed_model_source() {
let src = r#"
#[autumn_web::model]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let parsed = parse_model_source(src, Backend::Postgres).expect("parse");
let snapshot = SchemaSnapshot::new(Backend::Postgres, parsed.tables);
let json = to_canonical_json(&snapshot);
assert!(json.contains("\"name\": \"posts\""));
assert!(json.contains("\"name\": \"title\""));
assert!(json.contains("\"snapshot_version\": 1"));
assert!(json.contains("\"default\": \"Now\""));
let loaded = parse_snapshot_str(&json, "<test>").expect("reload");
assert_eq!(loaded.snapshot_version, SNAPSHOT_VERSION);
assert_eq!(loaded.tables.len(), 1);
assert_eq!(loaded.tables[0].name, "posts");
}
}