Skip to main content

vibesql_executor/
persistence.rs

1//! Database Persistence - Loading SQL Dumps
2//!
3//! Provides functionality for loading databases from SQL dump files.
4//! This mirrors the save functionality in vibesql-storage but requires
5//! the executor layer since it needs to parse and execute SQL statements.
6
7use std::path::Path;
8
9use vibesql_storage::Database;
10
11use crate::{
12    CreateIndexExecutor, CreateTableExecutor, ExecutorError, InsertExecutor, RoleExecutor,
13    SchemaExecutor, TriggerExecutor, UpdateExecutor, ViewExecutor,
14};
15
16/// Load database from SQL dump file
17///
18/// Reads SQL dump, parses statements, and executes them to recreate database state.
19/// This is the shared implementation used by CLI, Python bindings, and other consumers.
20///
21/// # Arguments
22/// * `path` - Path to the SQL dump file
23///
24/// # Returns
25/// A new Database instance with the loaded state
26///
27/// # Errors
28/// Returns error if:
29/// - File cannot be read
30/// - File is not valid SQL dump format (e.g., binary SQLite file)
31/// - SQL parsing fails
32/// - Statement execution fails
33///
34/// # Example
35/// ```no_run
36/// # use vibesql_executor::load_sql_dump;
37/// let db = load_sql_dump("database.sql").unwrap();
38/// ```
39pub fn load_sql_dump<P: AsRef<Path>>(path: P) -> Result<Database, ExecutorError> {
40    // Read the SQL dump file using storage utility
41    let sql_content = vibesql_storage::read_sql_dump(&path).map_err(|e| {
42        ExecutorError::Other(format!("Failed to read database file {:?}: {}", path.as_ref(), e))
43    })?;
44
45    // Split into individual statements using storage utility
46    let statements = vibesql_storage::parse_sql_statements(&sql_content)
47        .map_err(|e| ExecutorError::Other(format!("Failed to parse SQL dump: {}", e)))?;
48
49    // Create a new database to populate
50    let mut db = Database::new();
51
52    // Execute each statement
53    for (idx, stmt_sql) in statements.iter().enumerate() {
54        // Skip empty statements and comments
55        let trimmed = stmt_sql.trim();
56        if trimmed.is_empty() || trimmed.starts_with("--") {
57            continue;
58        }
59
60        // Parse the statement
61        let statement = vibesql_parser::Parser::parse_sql(trimmed).map_err(|e| {
62            ExecutorError::Other(format!(
63                "Failed to parse statement {} in {:?}: {}\nStatement: {}",
64                idx + 1,
65                path.as_ref(),
66                e,
67                truncate_for_error(trimmed, 100)
68            ))
69        })?;
70
71        // Execute the statement
72        execute_statement_for_load(&mut db, statement, trimmed).map_err(|e| {
73            ExecutorError::Other(format!(
74                "Failed to execute statement {} in {:?}: {}\nStatement: {}",
75                idx + 1,
76                path.as_ref(),
77                e,
78                truncate_for_error(trimmed, 100)
79            ))
80        })?;
81    }
82
83    Ok(db)
84}
85
86/// Execute a single statement during database load
87///
88/// Only DDL and INSERT statements are supported during load.
89/// Other statement types will return an error.
90fn execute_statement_for_load(
91    db: &mut Database,
92    statement: vibesql_ast::Statement,
93    original_sql: &str,
94) -> Result<(), ExecutorError> {
95    match statement {
96        vibesql_ast::Statement::CreateSchema(schema_stmt) => {
97            // Skip built-in schemas (main and all temp schemas) - they already exist
98            // This handles backward compatibility with old SQL dumps
99            let schema_name = schema_stmt.schema_name.to_lowercase();
100            if schema_name != vibesql_catalog::DEFAULT_SCHEMA
101                && !vibesql_catalog::Catalog::is_temp_schema(&schema_name)
102            {
103                SchemaExecutor::execute_create_schema(&schema_stmt, db)?;
104            }
105        }
106        vibesql_ast::Statement::CreateTable(create_stmt) => {
107            // Trusted replay of the engine's own persisted dump. A dump must
108            // always round-trip, so we bypass the user-facing reserved-name and
109            // duplicate-column guards: a `sqlite_`-prefixed table that
110            // legitimately reached the catalog before #5614 (via the then-open
111            // ALTER TABLE RENAME gap) must still reload rather than brick the
112            // database. User-issued CREATE TABLE still goes through
113            // `CreateTableExecutor::execute`, which keeps the guards (issue #5614).
114            //
115            // We use the trusted+verbatim variant so the reload ALSO preserves
116            // the byte-for-byte original CREATE TABLE text for sqlite_master.sql
117            // (issue #5619). The bypassed guards (#5614/#5553) and the verbatim
118            // source capture are complementary: a persisted dump must reload AND
119            // keep the user's original formatting after a save/reload cycle.
120            CreateTableExecutor::execute_for_load_with_source(
121                &create_stmt,
122                db,
123                Some(original_sql),
124            )?;
125        }
126        vibesql_ast::Statement::CreateIndex(index_stmt) => {
127            CreateIndexExecutor::execute(&index_stmt, db)?;
128        }
129        vibesql_ast::Statement::CreateView(mut view_stmt) => {
130            // Store original SQL for sqlite_master compatibility
131            view_stmt.sql_definition = Some(original_sql.to_string());
132            ViewExecutor::execute_create_view(&view_stmt, db)?;
133        }
134        vibesql_ast::Statement::CreateTrigger(trigger_stmt) => {
135            // Preserve the original SQL on the catalog TriggerDefinition so that
136            // a subsequent save_sql_dump can re-emit the trigger verbatim. Without
137            // this, triggers would survive one round-trip (because we just executed
138            // CREATE TRIGGER) but would be lost on the *next* save because the
139            // catalog entry would have no sql_definition.
140            TriggerExecutor::create_trigger_with_sql(db, &trigger_stmt, Some(original_sql))?;
141        }
142        vibesql_ast::Statement::CreateRole(role_stmt) => {
143            RoleExecutor::execute_create_role(&role_stmt, db)?;
144        }
145        vibesql_ast::Statement::Insert(insert_stmt) => {
146            InsertExecutor::execute(db, &insert_stmt)?;
147        }
148        vibesql_ast::Statement::Update(update_stmt) => {
149            UpdateExecutor::execute(&update_stmt, db)?;
150        }
151        _ => {
152            return Err(ExecutorError::Other(format!(
153                "Statement type not supported in database load: {:?}",
154                statement
155            )));
156        }
157    }
158    Ok(())
159}
160
161/// Truncate a string for error messages
162fn truncate_for_error(s: &str, max_len: usize) -> String {
163    if s.len() <= max_len {
164        s.to_string()
165    } else {
166        format!("{}...", &s[..max_len])
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use std::{fs, io::Write};
173    use tempfile::NamedTempFile;
174
175    use super::*;
176
177    #[test]
178    fn test_load_simple_database() {
179        // Create a temporary SQL dump file
180        let temp_file = NamedTempFile::new().unwrap();
181        let sql_dump = r#"
182-- Test database
183CREATE TABLE users (id INTEGER, name VARCHAR(50));
184INSERT INTO users VALUES (1, 'Alice');
185INSERT INTO users VALUES (2, 'Bob');
186"#;
187
188        fs::write(temp_file.path(), sql_dump).unwrap();
189
190        // Load the database
191        let db = load_sql_dump(temp_file.path().to_str().unwrap()).unwrap();
192
193        // Verify the table exists (note: identifiers are uppercased)
194        assert!(db.get_table("USERS").is_some());
195
196        // Verify data was loaded
197        let table = db.get_table("USERS").unwrap();
198        assert_eq!(table.row_count(), 2);
199        // temp_file is automatically cleaned up on drop
200    }
201
202    #[test]
203    fn test_load_with_schema() {
204        let temp_file = NamedTempFile::new().unwrap();
205        // Note: Schema-qualified INSERT statements not yet supported by parser
206        // So we create the table in a schema but insert without schema qualification
207        let sql_dump = r#"
208CREATE SCHEMA test_schema;
209CREATE TABLE test_schema.products (id INTEGER, price REAL);
210"#;
211
212        fs::write(temp_file.path(), sql_dump).unwrap();
213
214        let db = load_sql_dump(temp_file.path().to_str().unwrap()).unwrap();
215
216        // Verify table exists in schema (case-insensitive lookup)
217        assert!(db.get_table("test_schema.products").is_some());
218    }
219
220    #[test]
221    fn test_load_nonexistent_file() {
222        let result = load_sql_dump("/tmp/nonexistent_file.sql");
223        assert!(result.is_err());
224        assert!(result.unwrap_err().to_string().contains("does not exist"));
225    }
226
227    #[test]
228    fn test_load_invalid_sql() {
229        let temp_file = NamedTempFile::new().unwrap();
230        fs::write(temp_file.path(), "THIS IS NOT VALID SQL;").unwrap();
231
232        let result = load_sql_dump(temp_file.path().to_str().unwrap());
233        assert!(result.is_err());
234        assert!(result.unwrap_err().to_string().contains("Failed to parse"));
235    }
236
237    #[test]
238    fn test_load_binary_file_error() {
239        let temp_file = NamedTempFile::new().unwrap();
240        let mut file = fs::File::create(temp_file.path()).unwrap();
241        file.write_all(b"SQLite format 3\0").unwrap();
242        file.write_all(&[0xFF, 0xFE, 0xFD]).unwrap();
243
244        let result = load_sql_dump(temp_file.path().to_str().unwrap());
245        assert!(result.is_err());
246        assert!(result.unwrap_err().to_string().contains("binary SQLite database"));
247    }
248
249    #[test]
250    fn test_load_with_indexes() {
251        let temp_file = NamedTempFile::new().unwrap();
252        let sql_dump = r#"
253CREATE TABLE employees (id INTEGER, name VARCHAR(100), dept VARCHAR(50));
254INSERT INTO employees VALUES (1, 'Alice', 'Engineering');
255INSERT INTO employees VALUES (2, 'Bob', 'Sales');
256CREATE INDEX idx_dept ON employees (dept Asc);
257"#;
258
259        fs::write(temp_file.path(), sql_dump).unwrap();
260
261        let db = load_sql_dump(temp_file.path().to_str().unwrap()).unwrap();
262
263        // Verify table and data (note: identifiers are uppercased)
264        assert!(db.get_table("EMPLOYEES").is_some());
265        let table = db.get_table("EMPLOYEES").unwrap();
266        assert_eq!(table.row_count(), 2);
267
268        // Verify index exists (note: identifiers are uppercased)
269        assert!(db.get_index("IDX_DEPT").is_some());
270    }
271
272    // Helper used by the issue #5624 regression tests: run one statement through
273    // the appropriate executor so a save_sql_dump round-trip can be exercised.
274    #[cfg(test)]
275    fn exec_one(db: &mut Database, sql: &str) {
276        use crate::{
277            index_ddl::IndexExecutor, BeginTransactionExecutor, CommitExecutor, DropTableExecutor,
278        };
279        let st = vibesql_parser::Parser::parse_sql(sql).unwrap();
280        match st {
281            vibesql_ast::Statement::CreateTable(s) => {
282                CreateTableExecutor::execute(&s, db).unwrap();
283            }
284            vibesql_ast::Statement::CreateIndex(s) => {
285                CreateIndexExecutor::execute(&s, db).unwrap();
286            }
287            vibesql_ast::Statement::DropIndex(s) => {
288                IndexExecutor::execute_drop(&s, db).unwrap();
289            }
290            vibesql_ast::Statement::DropTable(s) => {
291                DropTableExecutor::execute(&s, db).unwrap();
292            }
293            vibesql_ast::Statement::BeginTransaction(s) => {
294                BeginTransactionExecutor::execute(&s, db).unwrap();
295            }
296            vibesql_ast::Statement::Commit(s) => {
297                CommitExecutor::execute(&s, db).unwrap();
298            }
299            other => panic!("unexpected statement in test helper: {:?}", other),
300        }
301    }
302
303    #[test]
304    fn test_transactional_bulk_create_drop_round_trips_cleanly() {
305        // Regression for issue #5624. table.test table-15.1/15.2 create and then
306        // drop ~2000 tables inside single BEGIN/COMMIT transactions. After such a
307        // transaction commits, the persisted dump must NOT contain any of the
308        // created-then-dropped tables — earlier engine states emitted a stale or
309        // duplicate CREATE for a table that had been dropped within the
310        // transaction, so the next reload aborted with
311        // `there is already a table named <name>` (or similar). The dropped
312        // tables must simply be absent, exactly like sqlite3.
313        let mut db = Database::new();
314
315        // BEGIN; CREATE TABLE tbl0..tblN; COMMIT  (mirrors table-15.1)
316        exec_one(&mut db, "BEGIN");
317        for i in 0..200 {
318            exec_one(&mut db, &format!("CREATE TABLE tbl{} (a, b, c)", i));
319        }
320        exec_one(&mut db, "COMMIT");
321
322        // BEGIN; DROP TABLE tbl0..tblN; COMMIT  (mirrors table-15.2)
323        exec_one(&mut db, "BEGIN");
324        for i in 0..200 {
325            exec_one(&mut db, &format!("DROP TABLE tbl{}", i));
326        }
327        exec_one(&mut db, "COMMIT");
328
329        // After the drop transaction commits, no tbl* tables remain.
330        assert!(db.list_tables().is_empty(), "all tables should be dropped: {:?}", db.list_tables());
331
332        // Save and reload via the engine's own replay path. The dump must reload
333        // cleanly — no duplicate / stale CREATE TABLE for a dropped table.
334        let temp_file = NamedTempFile::new().unwrap();
335        db.save_sql_dump(temp_file.path()).unwrap();
336        let reloaded = load_sql_dump(temp_file.path().to_str().unwrap())
337            .expect("dump of a committed bulk create+drop transaction must reload (issue #5624)");
338        assert!(
339            reloaded.list_tables().is_empty(),
340            "reloaded DB should have no tables, got: {:?}",
341            reloaded.list_tables()
342        );
343    }
344
345    #[test]
346    fn test_transactional_partial_drop_round_trips_cleanly() {
347        // A committed transaction that creates several tables but drops only some
348        // of them must persist exactly the survivors — neither a stale CREATE for
349        // a dropped table nor a missing CREATE for a kept one. Round-trips
350        // through the replay path. (Issue #5624.)
351        let mut db = Database::new();
352
353        exec_one(&mut db, "BEGIN");
354        for i in 0..10 {
355            exec_one(&mut db, &format!("CREATE TABLE t{} (a, b)", i));
356        }
357        // Drop the even-numbered tables within the same transaction.
358        for i in (0..10).step_by(2) {
359            exec_one(&mut db, &format!("DROP TABLE t{}", i));
360        }
361        exec_one(&mut db, "COMMIT");
362
363        let temp_file = NamedTempFile::new().unwrap();
364        db.save_sql_dump(temp_file.path()).unwrap();
365        let reloaded = load_sql_dump(temp_file.path().to_str().unwrap())
366            .expect("partial-drop transaction dump must reload (issue #5624)");
367
368        for i in 0..10 {
369            let exists = reloaded.get_table(&format!("t{}", i)).is_some();
370            if i % 2 == 0 {
371                assert!(!exists, "dropped table t{} must NOT survive reload", i);
372            } else {
373                assert!(exists, "kept table t{} must survive reload", i);
374            }
375        }
376    }
377
378    #[test]
379    fn test_table_replacing_dropped_index_round_trips_cleanly() {
380        // Regression for issue #5624, modeled on table.test lines ~120-142:
381        //   CREATE TABLE test2(one);
382        //   CREATE INDEX test3 ON test2(one);   -- `test3` is an INDEX
383        //   DROP INDEX test3;                    -- `test3` freed
384        //   CREATE TABLE test3(two);             -- `test3` is now a TABLE
385        // After DROP INDEX, the catalog must NOT retain a stale `test3` index.
386        // If it did, the dump would emit BOTH `CREATE TABLE test3` and
387        // `CREATE INDEX test3 ...`, and the reload would abort with
388        // `there is already a table named test3` when the CREATE INDEX replay
389        // hit the namespace-collision guard. Both objects (and only them) must
390        // round-trip.
391        let mut db = Database::new();
392        exec_one(&mut db, "CREATE TABLE test2(one text)");
393        exec_one(&mut db, "CREATE INDEX test3 ON test2(one)");
394        exec_one(&mut db, "DROP INDEX test3");
395        exec_one(&mut db, "CREATE TABLE test3(two text)");
396
397        // No residual `test3` index should remain after DROP INDEX + the
398        // same-named CREATE TABLE.
399        assert!(
400            db.catalog.list_all_indexes().iter().all(|idx| idx.name.to_lowercase() != "test3"),
401            "stale `test3` index must not survive DROP INDEX: {:?}",
402            db.catalog.list_all_indexes().iter().map(|i| i.name.clone()).collect::<Vec<_>>()
403        );
404
405        let temp_file = NamedTempFile::new().unwrap();
406        db.save_sql_dump(temp_file.path()).unwrap();
407        let dump = fs::read_to_string(temp_file.path()).unwrap();
408        // Defense-in-depth: the dump must not emit a CREATE INDEX for the freed
409        // name (which would collide with table test3 on reload).
410        assert!(
411            !dump.to_uppercase().contains("CREATE INDEX TEST3"),
412            "dump must not re-emit the dropped `test3` index:\n{}",
413            dump
414        );
415
416        let reloaded = load_sql_dump(temp_file.path().to_str().unwrap()).expect(
417            "table replacing a dropped same-named index must reload without collision (issue #5624)",
418        );
419        assert!(reloaded.get_table("test2").is_some(), "test2 must survive reload");
420        assert!(reloaded.get_table("test3").is_some(), "test3 (table) must survive reload");
421    }
422
423    #[test]
424    fn test_table_index_namespace_collision_round_trips_cleanly() {
425        // Regression for issue #5613. Before the fix, CREATE TABLE accepted a
426        // name already used by an index. The resulting saved schema then failed
427        // to reload because the DDL-replay (this very `load_sql_dump`) hit its
428        // own collision and aborted, bricking the database. With the namespace
429        // check in place, the collision is rejected up-front, so a database can
430        // never persist a state it cannot itself reload.
431        //
432        // Here we build a *valid* schema (table + index with distinct names plus
433        // a colliding-but-rejected attempt), save it, and confirm the dump
434        // reloads cleanly via the same replay path.
435        let mut db = Database::new();
436
437        let create_t2 = vibesql_parser::Parser::parse_sql("CREATE TABLE test2(one text)").unwrap();
438        if let vibesql_ast::Statement::CreateTable(s) = create_t2 {
439            CreateTableExecutor::execute(&s, &mut db).unwrap();
440        }
441        let create_ix =
442            vibesql_parser::Parser::parse_sql("CREATE INDEX test3 ON test2(one)").unwrap();
443        if let vibesql_ast::Statement::CreateIndex(s) = create_ix {
444            CreateIndexExecutor::execute(&s, &mut db).unwrap();
445        }
446
447        // The colliding CREATE TABLE is rejected (never persisted).
448        let collide = vibesql_parser::Parser::parse_sql("CREATE TABLE test3(two text)").unwrap();
449        if let vibesql_ast::Statement::CreateTable(s) = collide {
450            let err = CreateTableExecutor::execute(&s, &mut db).unwrap_err();
451            assert_eq!(err.to_string(), "there is already an index named test3");
452        }
453
454        // Save the (clean) schema and reload it through the replay path.
455        let temp_file = NamedTempFile::new().unwrap();
456        db.save_sql_dump(temp_file.path()).unwrap();
457
458        let reloaded = load_sql_dump(temp_file.path().to_str().unwrap())
459            .expect("schema must reload without bricking (issue #5613)");
460
461        // Both objects survived the round-trip; the bogus table never appeared.
462        assert!(reloaded.get_table("test2").is_some());
463        assert!(reloaded.get_index("test3").is_some());
464    }
465
466    #[test]
467    fn test_dump_with_table_index_collision_is_rejected_on_load() {
468        // Defense in depth: even a hand-authored dump that puts a CREATE INDEX
469        // and a same-named CREATE TABLE in one file must be rejected on load
470        // with the SQLite-compatible wording — not silently accepted into an
471        // unloadable state.
472        let temp_file = NamedTempFile::new().unwrap();
473        let sql_dump = r#"
474CREATE TABLE test2 (one TEXT);
475CREATE INDEX test3 ON test2 (one);
476CREATE TABLE test3 (two TEXT);
477"#;
478        fs::write(temp_file.path(), sql_dump).unwrap();
479
480        let err = load_sql_dump(temp_file.path().to_str().unwrap())
481            .expect_err("colliding dump must be rejected, not bricked")
482            .to_string();
483        assert!(
484            err.contains("there is already an index named test3"),
485            "expected index-collision wording, got: {}",
486            err
487        );
488    }
489
490    #[test]
491    fn test_load_dump_with_sqlite_prefixed_table_reloads_cleanly() {
492        // Regression for issue #5614. Before the fix, a database that contained a
493        // `sqlite_`-prefixed *user* table (reachable via the then-unguarded
494        // ALTER TABLE RENAME TO) would dump as `CREATE TABLE sqlite_t3 (...)`,
495        // and on reload the new user-facing reserved-name guard would reject the
496        // engine's own dump — permanently bricking the database. The trusted
497        // load path (`execute_for_load`) must reconstruct whatever was
498        // legitimately persisted, so such a dump reloads cleanly.
499        let temp_file = NamedTempFile::new().unwrap();
500        let sql_dump = r#"
501CREATE TABLE sqlite_t3 (a BLOB, b BLOB, c BLOB);
502INSERT INTO sqlite_t3 VALUES (1, 2, 3);
503"#;
504        fs::write(temp_file.path(), sql_dump).unwrap();
505
506        let db = load_sql_dump(temp_file.path().to_str().unwrap())
507            .expect("a persisted sqlite_-prefixed table must reload, not brick (issue #5614)");
508
509        // The table and its data survived the trusted reload.
510        let table = db.get_table("sqlite_t3").expect("sqlite_t3 must exist after reload");
511        assert_eq!(table.row_count(), 1);
512    }
513
514    #[test]
515    fn test_user_create_sqlite_prefixed_table_still_rejected() {
516        // The #5614 feature must remain intact on the USER path: a user-issued
517        // CREATE TABLE with a reserved `sqlite_` name is still rejected. Only the
518        // trusted load/replay path bypasses the guard.
519        let mut db = Database::new();
520        let stmt = vibesql_parser::Parser::parse_sql("CREATE TABLE sqlite_foo (x INTEGER)").unwrap();
521        if let vibesql_ast::Statement::CreateTable(s) = stmt {
522            let err = CreateTableExecutor::execute(&s, &mut db).unwrap_err();
523            assert_eq!(err.to_string(), "object name reserved for internal use: sqlite_foo");
524        } else {
525            panic!("expected CreateTable");
526        }
527    }
528
529    #[test]
530    fn test_alter_rename_to_reserved_name_rejected() {
531        // Regression for issue #5614 fix #2 (sqlite3 3.51.0 alter-2.5): renaming
532        // a table to a reserved `sqlite_`-prefixed name must error
533        // `object name reserved for internal use: <name>`, preventing a
534        // `sqlite_`-prefixed user table from ever being persisted (the root
535        // enabler of the reload-brick regression).
536        use crate::AlterTableExecutor;
537
538        let mut db = Database::new();
539        let create = vibesql_parser::Parser::parse_sql("CREATE TABLE t3(a, b, c)").unwrap();
540        if let vibesql_ast::Statement::CreateTable(s) = create {
541            CreateTableExecutor::execute(&s, &mut db).unwrap();
542        }
543
544        let alter =
545            vibesql_parser::Parser::parse_sql("ALTER TABLE t3 RENAME TO sqlite_t3").unwrap();
546        if let vibesql_ast::Statement::AlterTable(s) = alter {
547            let err = AlterTableExecutor::execute(&s, &mut db).unwrap_err();
548            assert_eq!(err.to_string(), "object name reserved for internal use: sqlite_t3");
549        } else {
550            panic!("expected AlterTable");
551        }
552
553        // The rename was rejected, so the original table is intact and no
554        // sqlite_-prefixed table leaked into the catalog.
555        assert!(db.get_table("t3").is_some());
556        assert!(db.get_table("sqlite_t3").is_none());
557    }
558
559    #[test]
560    fn test_load_with_roles() {
561        let temp_file = NamedTempFile::new().unwrap();
562        let sql_dump = r#"
563CREATE ROLE admin;
564CREATE ROLE user;
565CREATE TABLE data (id INTEGER);
566"#;
567
568        fs::write(temp_file.path(), sql_dump).unwrap();
569
570        let db = load_sql_dump(temp_file.path().to_str().unwrap()).unwrap();
571
572        // Verify table exists (note: identifiers are uppercased)
573        assert!(db.get_table("DATA").is_some());
574    }
575
576    // Issue #5618: a dump → reload round-trip must preserve the *original*
577    // declared spelling that `sqlite_master.name` / `PRAGMA table_info` echo,
578    // even though lookups remain case-insensitive (#5553). Before the fix, the
579    // dump emitted the lowercase canonical catalog key, and a keyword name like
580    // `"create"` was written unquoted and re-lexed back to the keyword `CREATE`.
581    #[test]
582    fn test_dump_reload_preserves_original_case_and_quoted_keywords() {
583        let mut db = Database::new();
584        for sql in [
585            "CREATE TABLE \"create\" (f1 INT)",
586            "CREATE TABLE big (a, b)",
587            "CREATE TABLE \"MixedCase\" (\"ColA\" INT, \"B\" INT)",
588        ] {
589            let stmt = vibesql_parser::Parser::parse_sql(sql).unwrap();
590            if let vibesql_ast::Statement::CreateTable(s) = stmt {
591                CreateTableExecutor::execute(&s, &mut db).unwrap();
592            } else {
593                panic!("expected CreateTable for: {sql}");
594            }
595        }
596
597        let temp_file = NamedTempFile::new().unwrap();
598        db.save_sql_dump(temp_file.path()).unwrap();
599        let reloaded = load_sql_dump(temp_file.path().to_str().unwrap()).unwrap();
600
601        // Table name echo preserves the original spelling. Lookup is
602        // case-insensitive, so resolve via the canonical key, then assert the
603        // stored display name.
604        let create_tbl = reloaded.get_table("create").expect("quoted-keyword table must survive");
605        assert_eq!(
606            create_tbl.schema.name, "create",
607            "quoted keyword table name must round-trip verbatim (not folded to CREATE)"
608        );
609
610        let mixed = reloaded.get_table("mixedcase").expect("mixed-case table must survive");
611        assert_eq!(mixed.schema.name, "MixedCase", "mixed-case table name must be preserved");
612        // Column-name echo preserves original case too.
613        assert_eq!(mixed.schema.columns[0].name, "ColA");
614        assert_eq!(mixed.schema.columns[1].name, "B");
615
616        let big = reloaded.get_table("big").expect("table must survive");
617        assert_eq!(big.schema.name, "big");
618    }
619}