Skip to main content

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