Skip to main content

drizzle_cli/
snapshot.rs

1//! Snapshot conversion helpers reused from `drizzle-migrations`.
2
3use crate::error::CliError;
4use crate::output;
5use drizzle_migrations::parser::ParseResult;
6
7/// Print parser warnings and fail on parser errors.
8///
9/// The parser emits entities best-effort even for source it could not fully
10/// interpret; generating migrations from a half-understood schema turns
11/// parser confusion into destructive DDL, so hard errors must stop the
12/// command.
13pub fn surface_parse_diagnostics(result: &ParseResult) -> Result<(), CliError> {
14    for warning in &result.warnings {
15        println!("{}", output::warning(&format!("schema parse: {warning}")));
16    }
17
18    if result.errors.is_empty() {
19        Ok(())
20    } else {
21        Err(CliError::SchemaParse(result.errors.join("\n  ")))
22    }
23}
24
25#[cfg(test)]
26mod tests {
27    use super::*;
28    use drizzle_migrations::schema::Snapshot;
29    use drizzle_types::{Casing, Dialect};
30
31    #[test]
32    fn surface_parse_diagnostics_errors_on_broken_source() {
33        use drizzle_migrations::parser::SchemaParser;
34
35        let result = SchemaParser::parse("#[SQLiteTable]\npub struct Broken {");
36        let error = surface_parse_diagnostics(&result)
37            .expect_err("hard parse failures must stop the command");
38        assert!(matches!(error, CliError::SchemaParse(_)));
39    }
40
41    /// Test that changing a column from Option<String> to String generates table recreation
42    #[test]
43    fn test_nullable_to_not_null_generates_migration() {
44        use drizzle_migrations::parser::SchemaParser;
45        use drizzle_migrations::sqlite::collection::SQLiteDDL;
46        use drizzle_migrations::sqlite::diff::compute_migration;
47
48        let prev_code = r#"
49#[SQLiteTable]
50pub struct User {
51    #[column(primary)]
52    pub id: i64,
53    pub name: String,
54    pub email: Option<String>,
55}
56"#;
57
58        let cur_code = r#"
59#[SQLiteTable]
60pub struct User {
61    #[column(primary)]
62    pub id: i64,
63    pub name: String,
64    pub email: String,
65}
66"#;
67
68        let prev_result = SchemaParser::parse(prev_code);
69        let cur_result = SchemaParser::parse(cur_code);
70
71        let prev_snapshot = Snapshot::from_parse_result(&prev_result, Dialect::SQLite, None);
72        let cur_snapshot = Snapshot::from_parse_result(&cur_result, Dialect::SQLite, None);
73
74        let (prev_ddl, cur_ddl) = match (&prev_snapshot, &cur_snapshot) {
75            (Snapshot::Sqlite(p), Snapshot::Sqlite(c)) => (
76                SQLiteDDL::from_entities(p.ddl.clone()),
77                SQLiteDDL::from_entities(c.ddl.clone()),
78            ),
79            _ => panic!("Expected SQLite snapshots"),
80        };
81
82        let prev_email = prev_ddl
83            .columns
84            .one("user", "email")
85            .expect("email column in prev");
86        let cur_email = cur_ddl
87            .columns
88            .one("user", "email")
89            .expect("email column in cur");
90        assert!(!prev_email.not_null, "Previous email should be nullable");
91        assert!(cur_email.not_null, "Current email should be NOT NULL");
92
93        let migration = compute_migration(&prev_ddl, &cur_ddl);
94
95        assert!(
96            !migration.sql_statements.is_empty(),
97            "Should generate migration SQL for nullable change"
98        );
99
100        assert_eq!(migration.sql_statements[0], "PRAGMA foreign_keys=OFF;");
101        assert!(
102            migration.sql_statements[1].starts_with("CREATE TABLE `__new_user`"),
103            "Expected CREATE TABLE `__new_user`, got: {}",
104            migration.sql_statements[1]
105        );
106        assert!(
107            migration.sql_statements[1].contains("`email` TEXT NOT NULL"),
108            "New table should have NOT NULL on email: {}",
109            migration.sql_statements[1]
110        );
111        assert_eq!(
112            migration.sql_statements[2],
113            "INSERT INTO `__new_user`(`id`, `name`, `email`) SELECT `id`, `name`, `email` FROM `user`;"
114        );
115        assert_eq!(migration.sql_statements[3], "DROP TABLE `user`;");
116        assert_eq!(
117            migration.sql_statements[4],
118            "ALTER TABLE `__new_user` RENAME TO `user`;"
119        );
120        assert_eq!(migration.sql_statements[5], "PRAGMA foreign_keys=ON;");
121    }
122
123    /// Test that changing a column from String to Option<String> generates table recreation
124    #[test]
125    fn test_not_null_to_nullable_generates_migration() {
126        use drizzle_migrations::parser::SchemaParser;
127        use drizzle_migrations::sqlite::collection::SQLiteDDL;
128        use drizzle_migrations::sqlite::diff::compute_migration;
129
130        let prev_code = r#"
131#[SQLiteTable]
132pub struct User {
133    #[column(primary)]
134    pub id: i64,
135    pub email: String,
136}
137"#;
138
139        let cur_code = r#"
140#[SQLiteTable]
141pub struct User {
142    #[column(primary)]
143    pub id: i64,
144    pub email: Option<String>,
145}
146"#;
147
148        let prev_result = SchemaParser::parse(prev_code);
149        let cur_result = SchemaParser::parse(cur_code);
150
151        let prev_snapshot = Snapshot::from_parse_result(&prev_result, Dialect::SQLite, None);
152        let cur_snapshot = Snapshot::from_parse_result(&cur_result, Dialect::SQLite, None);
153
154        let (prev_ddl, cur_ddl) = match (&prev_snapshot, &cur_snapshot) {
155            (Snapshot::Sqlite(p), Snapshot::Sqlite(c)) => (
156                SQLiteDDL::from_entities(p.ddl.clone()),
157                SQLiteDDL::from_entities(c.ddl.clone()),
158            ),
159            _ => panic!("Expected SQLite snapshots"),
160        };
161
162        let migration = compute_migration(&prev_ddl, &cur_ddl);
163
164        assert!(
165            !migration.sql_statements.is_empty(),
166            "Should generate migration SQL for nullable change"
167        );
168
169        assert_eq!(migration.sql_statements[0], "PRAGMA foreign_keys=OFF;");
170        assert!(
171            migration.sql_statements[1].starts_with("CREATE TABLE `__new_user`"),
172            "Expected CREATE TABLE `__new_user`, got: {}",
173            migration.sql_statements[1]
174        );
175        assert_eq!(migration.sql_statements[3], "DROP TABLE `user`;");
176        assert_eq!(
177            migration.sql_statements[4],
178            "ALTER TABLE `__new_user` RENAME TO `user`;"
179        );
180        assert_eq!(migration.sql_statements[5], "PRAGMA foreign_keys=ON;");
181    }
182
183    #[test]
184    fn test_postgres_schema_and_index_options_are_preserved() {
185        use drizzle_migrations::parser::SchemaParser;
186        use drizzle_migrations::postgres::ddl::PostgresEntity;
187
188        let code = r#"
189#[PostgresTable(schema = "auth")]
190pub struct Users {
191    #[column(primary)]
192    pub id: i32,
193}
194
195#[PostgresTable(schema = "app")]
196pub struct Sessions {
197    #[column(primary)]
198    pub id: i32,
199    #[column(references = Users::id)]
200    pub user_id: i32,
201}
202
203#[PostgresIndex(concurrent, method = "gin", where = "user_id > 0")]
204pub struct SessionsUserIdx(Sessions::user_id);
205"#;
206
207        let result = SchemaParser::parse(code);
208        let snapshot = Snapshot::from_parse_result(&result, Dialect::PostgreSQL, None);
209
210        let snap = match snapshot {
211            Snapshot::Postgres(s) => s,
212            _ => panic!("Expected Postgres snapshot"),
213        };
214
215        let has_auth_schema = snap
216            .ddl
217            .iter()
218            .any(|e| matches!(e, PostgresEntity::Schema(s) if s.name.as_ref() == "auth"));
219        let has_app_schema = snap
220            .ddl
221            .iter()
222            .any(|e| matches!(e, PostgresEntity::Schema(s) if s.name.as_ref() == "app"));
223        assert!(has_auth_schema, "missing auth schema entity");
224        assert!(has_app_schema, "missing app schema entity");
225
226        let fk = snap.ddl.iter().find_map(|e| {
227            if let PostgresEntity::ForeignKey(fk) = e {
228                Some(fk)
229            } else {
230                None
231            }
232        });
233        let fk = fk.expect("expected foreign key");
234        assert_eq!(fk.schema.as_ref(), "app");
235        assert_eq!(fk.schema_to.as_ref(), "auth");
236
237        let idx = snap.ddl.iter().find_map(|e| {
238            if let PostgresEntity::Index(i) = e {
239                Some(i)
240            } else {
241                None
242            }
243        });
244        let idx = idx.expect("expected index");
245        assert!(idx.concurrently);
246        assert_eq!(idx.method.as_deref(), Some("gin"));
247        assert_eq!(idx.where_clause.as_deref(), Some("user_id > 0"));
248        assert_eq!(idx.schema.as_ref(), "app");
249    }
250
251    #[test]
252    fn test_sqlite_table_options_and_pk_name_are_preserved() {
253        use drizzle_migrations::parser::SchemaParser;
254        use drizzle_migrations::sqlite::SqliteEntity;
255
256        let code = r#"
257#[SQLiteTable(strict, without_rowid)]
258pub struct Accounts {
259    #[column(primary)]
260    pub id: i64,
261}
262"#;
263
264        let result = SchemaParser::parse(code);
265        let snapshot = Snapshot::from_parse_result(&result, Dialect::SQLite, None);
266        let snap = match snapshot {
267            Snapshot::Sqlite(s) => s,
268            _ => panic!("Expected SQLite snapshot"),
269        };
270
271        let table = snap.ddl.iter().find_map(|e| {
272            if let SqliteEntity::Table(t) = e {
273                Some(t)
274            } else {
275                None
276            }
277        });
278        let table = table.expect("expected sqlite table");
279        assert!(table.strict, "strict should be preserved");
280        assert!(table.without_rowid, "without_rowid should be preserved");
281
282        let pk = snap.ddl.iter().find_map(|e| {
283            if let SqliteEntity::PrimaryKey(pk) = e {
284                Some(pk)
285            } else {
286                None
287            }
288        });
289        let pk = pk.expect("expected sqlite primary key");
290        // Canonical macro/introspection name (`types::sqlite::ddl::name_for_pk`).
291        assert_eq!(pk.name.as_ref(), "accounts_pk");
292    }
293
294    #[test]
295    fn test_sqlite_casing_preserves_explicit_names() {
296        use drizzle_migrations::parser::SchemaParser;
297        use drizzle_migrations::sqlite::SqliteEntity;
298
299        let code = r#"
300#[SQLiteTable(name = "users_tbl")]
301pub struct UsersTable {
302    #[column(name = "user_id", primary)]
303    pub userId: i64,
304    pub emailAddress: String,
305}
306
307#[SQLiteIndex(name = "users_tbl_email_idx")]
308pub struct UsersEmailIdx(UsersTable::emailAddress);
309"#;
310
311        let result = SchemaParser::parse(code);
312        let snapshot =
313            Snapshot::from_parse_result(&result, Dialect::SQLite, Some(Casing::SnakeCase));
314        let snap = match snapshot {
315            Snapshot::Sqlite(s) => s,
316            _ => panic!("Expected SQLite snapshot"),
317        };
318
319        let table = snap.ddl.iter().find_map(|e| {
320            if let SqliteEntity::Table(t) = e {
321                Some(t)
322            } else {
323                None
324            }
325        });
326        let table = table.expect("expected sqlite table");
327        assert_eq!(table.name.as_ref(), "users_tbl");
328
329        let user_id = snap.ddl.iter().find_map(|e| {
330            if let SqliteEntity::Column(c) = e
331                && c.name.as_ref() == "user_id"
332            {
333                Some(c)
334            } else {
335                None
336            }
337        });
338        assert!(user_id.is_some(), "expected explicit column name user_id");
339
340        let email_col = snap.ddl.iter().find_map(|e| {
341            if let SqliteEntity::Column(c) = e
342                && c.name.as_ref() == "email_address"
343            {
344                Some(c)
345            } else {
346                None
347            }
348        });
349        assert!(
350            email_col.is_some(),
351            "expected inferred snake_case column name"
352        );
353
354        let index = snap.ddl.iter().find_map(|e| {
355            if let SqliteEntity::Index(i) = e {
356                Some(i)
357            } else {
358                None
359            }
360        });
361        let index = index.expect("expected sqlite index");
362        assert_eq!(index.name.as_ref(), "users_tbl_email_idx");
363    }
364
365    #[test]
366    fn test_postgres_casing_preserves_explicit_names() {
367        use drizzle_migrations::parser::SchemaParser;
368        use drizzle_migrations::postgres::ddl::PostgresEntity;
369
370        let code = r#"
371#[PostgresTable(schema = "auth", name = "users_tbl")]
372pub struct UsersTable {
373    #[column(name = "user_id", primary)]
374    pub userId: i32,
375    pub createdAt: String,
376}
377
378#[PostgresIndex(name = "users_tbl_created_idx")]
379pub struct UsersCreatedIdx(UsersTable::createdAt);
380"#;
381
382        let result = SchemaParser::parse(code);
383        let snapshot =
384            Snapshot::from_parse_result(&result, Dialect::PostgreSQL, Some(Casing::SnakeCase));
385        let snap = match snapshot {
386            Snapshot::Postgres(s) => s,
387            _ => panic!("Expected Postgres snapshot"),
388        };
389
390        let table = snap.ddl.iter().find_map(|e| {
391            if let PostgresEntity::Table(t) = e {
392                Some(t)
393            } else {
394                None
395            }
396        });
397        let table = table.expect("expected postgres table");
398        assert_eq!(table.schema.as_ref(), "auth");
399        assert_eq!(table.name.as_ref(), "users_tbl");
400
401        let user_id = snap.ddl.iter().find_map(|e| {
402            if let PostgresEntity::Column(c) = e
403                && c.name.as_ref() == "user_id"
404            {
405                Some(c)
406            } else {
407                None
408            }
409        });
410        assert!(user_id.is_some(), "expected explicit column name user_id");
411
412        let created_at = snap.ddl.iter().find_map(|e| {
413            if let PostgresEntity::Column(c) = e
414                && c.name.as_ref() == "created_at"
415            {
416                Some(c)
417            } else {
418                None
419            }
420        });
421        assert!(
422            created_at.is_some(),
423            "expected inferred snake_case column name created_at"
424        );
425
426        let index = snap.ddl.iter().find_map(|e| {
427            if let PostgresEntity::Index(i) = e {
428                Some(i)
429            } else {
430                None
431            }
432        });
433        let index = index.expect("expected postgres index");
434        assert_eq!(index.name.as_ref(), "users_tbl_created_idx");
435        assert_eq!(index.schema.as_ref(), "auth");
436    }
437}