oxide_sql_core/migrations/
snapshot.rs

1//! Schema snapshot types for diff-based migration generation.
2//!
3//! Dialect-resolved representations of database schemas used for
4//! comparison. Unlike [`ColumnSchema`](crate::schema::ColumnSchema)
5//! (which stores Rust type strings), snapshots store resolved
6//! [`DataType`](crate::ast::DataType) values.
7
8use std::collections::BTreeMap;
9
10use crate::ast::DataType;
11use crate::schema::{RustTypeMapping, TableSchema};
12
13use super::column_builder::DefaultValue;
14use super::operation::strip_option;
15
16/// A snapshot of a single column's resolved schema.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ColumnSnapshot {
19    /// Column name.
20    pub name: String,
21    /// Resolved SQL data type.
22    pub data_type: DataType,
23    /// Whether the column is nullable.
24    pub nullable: bool,
25    /// Whether this column is a primary key.
26    pub primary_key: bool,
27    /// Whether this column has a UNIQUE constraint.
28    pub unique: bool,
29    /// Whether this column auto-increments.
30    pub autoincrement: bool,
31    /// Default value, if any.
32    pub default: Option<DefaultValue>,
33}
34
35/// A snapshot of a single table's resolved schema.
36#[derive(Debug, Clone, PartialEq)]
37pub struct TableSnapshot {
38    /// Table name.
39    pub name: String,
40    /// Columns in declaration order.
41    pub columns: Vec<ColumnSnapshot>,
42}
43
44impl TableSnapshot {
45    /// Builds a snapshot from a `#[derive(Table)]` struct, resolving
46    /// Rust types to SQL `DataType` via the dialect's
47    /// `RustTypeMapping`.
48    pub fn from_table_schema<T: TableSchema>(dialect: &impl RustTypeMapping) -> Self {
49        let columns = T::SCHEMA
50            .iter()
51            .map(|col| {
52                let inner = strip_option(col.rust_type);
53                let data_type = dialect.map_type(inner);
54                let default = col
55                    .default_expr
56                    .map(|expr| DefaultValue::Expression(expr.to_string()));
57                ColumnSnapshot {
58                    name: col.name.to_string(),
59                    data_type,
60                    nullable: col.nullable,
61                    primary_key: col.primary_key,
62                    unique: col.unique,
63                    autoincrement: col.autoincrement,
64                    default,
65                }
66            })
67            .collect();
68        Self {
69            name: T::NAME.to_string(),
70            columns,
71        }
72    }
73
74    /// Looks up a column by name.
75    #[must_use]
76    pub fn column(&self, name: &str) -> Option<&ColumnSnapshot> {
77        self.columns.iter().find(|c| c.name == name)
78    }
79}
80
81/// A snapshot of an entire database schema (multiple tables).
82#[derive(Debug, Clone, PartialEq)]
83pub struct SchemaSnapshot {
84    /// Tables keyed by name, sorted for deterministic iteration.
85    pub tables: BTreeMap<String, TableSnapshot>,
86}
87
88impl SchemaSnapshot {
89    /// Creates an empty schema snapshot.
90    #[must_use]
91    pub fn new() -> Self {
92        Self {
93            tables: BTreeMap::new(),
94        }
95    }
96
97    /// Adds a table snapshot.
98    pub fn add_table(&mut self, table: TableSnapshot) {
99        self.tables.insert(table.name.clone(), table);
100    }
101
102    /// Adds a table snapshot built from a `#[derive(Table)]` struct.
103    pub fn add_from_table_schema<T: TableSchema>(&mut self, dialect: &impl RustTypeMapping) {
104        let snapshot = TableSnapshot::from_table_schema::<T>(dialect);
105        self.add_table(snapshot);
106    }
107}
108
109impl Default for SchemaSnapshot {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::migrations::{DuckDbDialect, PostgresDialect, SqliteDialect};
119    use crate::schema::{ColumnSchema, Table};
120
121    // Minimal test table
122    struct TestTable;
123    struct TestRow;
124
125    impl Table for TestTable {
126        type Row = TestRow;
127        const NAME: &'static str = "test_items";
128        const COLUMNS: &'static [&'static str] = &["id", "name", "score", "active"];
129        const PRIMARY_KEY: Option<&'static str> = Some("id");
130    }
131
132    impl TableSchema for TestTable {
133        const SCHEMA: &'static [ColumnSchema] = &[
134            ColumnSchema {
135                name: "id",
136                rust_type: "i64",
137                nullable: false,
138                primary_key: true,
139                unique: false,
140                autoincrement: true,
141                default_expr: None,
142            },
143            ColumnSchema {
144                name: "name",
145                rust_type: "String",
146                nullable: false,
147                primary_key: false,
148                unique: true,
149                autoincrement: false,
150                default_expr: None,
151            },
152            ColumnSchema {
153                name: "score",
154                rust_type: "Option<f64>",
155                nullable: true,
156                primary_key: false,
157                unique: false,
158                autoincrement: false,
159                default_expr: None,
160            },
161            ColumnSchema {
162                name: "active",
163                rust_type: "bool",
164                nullable: false,
165                primary_key: false,
166                unique: false,
167                autoincrement: false,
168                default_expr: Some("TRUE"),
169            },
170        ];
171    }
172
173    #[test]
174    fn from_table_schema_sqlite() {
175        let dialect = SqliteDialect::new();
176        let snap = TableSnapshot::from_table_schema::<TestTable>(&dialect);
177
178        assert_eq!(snap.name, "test_items");
179        assert_eq!(snap.columns.len(), 4);
180
181        let id = snap.column("id").unwrap();
182        assert_eq!(id.data_type, DataType::Bigint);
183        assert!(id.primary_key);
184        assert!(id.autoincrement);
185        assert!(!id.nullable);
186
187        let name_col = snap.column("name").unwrap();
188        assert_eq!(name_col.data_type, DataType::Text);
189        assert!(name_col.unique);
190
191        // Option<f64> -> f64 -> Double (SQLite maps f64 to Double)
192        let score = snap.column("score").unwrap();
193        assert_eq!(score.data_type, DataType::Double);
194        assert!(score.nullable);
195
196        let active = snap.column("active").unwrap();
197        assert_eq!(active.data_type, DataType::Integer);
198        assert_eq!(
199            active.default,
200            Some(DefaultValue::Expression("TRUE".into()))
201        );
202    }
203
204    #[test]
205    fn from_table_schema_postgres() {
206        let dialect = PostgresDialect::new();
207        let snap = TableSnapshot::from_table_schema::<TestTable>(&dialect);
208
209        let name_col = snap.column("name").unwrap();
210        assert_eq!(name_col.data_type, DataType::Varchar(Some(255)));
211
212        let active = snap.column("active").unwrap();
213        assert_eq!(active.data_type, DataType::Boolean);
214    }
215
216    #[test]
217    fn from_table_schema_duckdb() {
218        let dialect = DuckDbDialect::new();
219        let snap = TableSnapshot::from_table_schema::<TestTable>(&dialect);
220
221        let name_col = snap.column("name").unwrap();
222        assert_eq!(name_col.data_type, DataType::Varchar(None));
223
224        let active = snap.column("active").unwrap();
225        assert_eq!(active.data_type, DataType::Boolean);
226    }
227
228    #[test]
229    fn column_lookup_by_name() {
230        let dialect = SqliteDialect::new();
231        let snap = TableSnapshot::from_table_schema::<TestTable>(&dialect);
232
233        assert!(snap.column("id").is_some());
234        assert!(snap.column("name").is_some());
235        assert!(snap.column("nonexistent").is_none());
236    }
237
238    #[test]
239    fn schema_snapshot_add_tables() {
240        let dialect = SqliteDialect::new();
241        let mut schema = SchemaSnapshot::new();
242        schema.add_from_table_schema::<TestTable>(&dialect);
243
244        assert_eq!(schema.tables.len(), 1);
245        assert!(schema.tables.contains_key("test_items"));
246    }
247}