Skip to main content

drizzle_cli/
snapshot.rs

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