Skip to main content

sqlrite/sql/
mod.rs

1pub mod db;
2pub mod executor;
3pub mod fts;
4pub mod hnsw;
5pub mod pager;
6pub mod parser;
7// pub mod tokenizer;
8
9use parser::create::CreateQuery;
10use parser::insert::InsertQuery;
11use parser::select::SelectQuery;
12
13use sqlparser::ast::{AlterTableOperation, ObjectType, Statement};
14use sqlparser::dialect::SQLiteDialect;
15use sqlparser::parser::{Parser, ParserError};
16
17use crate::error::{Result, SQLRiteError};
18use crate::sql::db::database::Database;
19use crate::sql::db::table::Table;
20
21#[derive(Debug, PartialEq)]
22pub enum SQLCommand {
23    Insert(String),
24    Delete(String),
25    Update(String),
26    CreateTable(String),
27    Select(String),
28    Unknown(String),
29}
30
31impl SQLCommand {
32    pub fn new(command: String) -> SQLCommand {
33        let v = command.split(" ").collect::<Vec<&str>>();
34        match v[0] {
35            "insert" => SQLCommand::Insert(command),
36            "update" => SQLCommand::Update(command),
37            "delete" => SQLCommand::Delete(command),
38            "create" => SQLCommand::CreateTable(command),
39            "select" => SQLCommand::Select(command),
40            _ => SQLCommand::Unknown(command),
41        }
42    }
43}
44
45/// Output of running one SQL statement through the engine.
46///
47/// Two fields:
48///
49/// - `status` is the short human-readable confirmation line every caller
50///   wants ("INSERT Statement executed.", "3 rows updated.", "BEGIN", etc.).
51/// - `rendered` is the pre-formatted prettytable rendering of a SELECT's
52///   result rows. Populated only for `SELECT` statements; `None` for every
53///   other statement type. The REPL prints this above the status line so
54///   users see both the rows and the confirmation; SDK / FFI / MCP callers
55///   ignore it and reach for the typed-row APIs (`Connection::prepare` →
56///   `Statement::query` → `Rows`) when they want row data instead.
57///
58/// Splitting the two means [`process_command_with_render`] can return
59/// everything the REPL needs without writing to stdout itself —
60/// historically `process_command` would `print!()` the rendered table
61/// directly, which corrupted any non-REPL stdout channel (the MCP server's
62/// JSON-RPC wire, structured loggers piping engine output, …).
63#[derive(Debug, Clone)]
64pub struct CommandOutput {
65    pub status: String,
66    pub rendered: Option<String>,
67}
68
69/// Backwards-compatible wrapper around [`process_command_with_render`] that
70/// returns just the status string. Every existing call site (the public
71/// `Connection::execute`, the SDK FFI shims, the .ask meta-command's
72/// inline runner, the engine's own tests) keeps working unchanged.
73///
74/// Callers that want the rendered SELECT table (the REPL, future
75/// terminal-style consumers) should call [`process_command_with_render`]
76/// directly and inspect [`CommandOutput::rendered`].
77pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
78    process_command_with_render(query, db).map(|o| o.status)
79}
80
81/// Performs initial parsing of SQL Statement using sqlparser-rs.
82///
83/// Returns a [`CommandOutput`] carrying both the status string and (for
84/// SELECT statements) the pre-rendered prettytable output. **Never writes
85/// to stdout.** The REPL is responsible for printing whatever it wants
86/// from the returned struct.
87pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<CommandOutput> {
88    let dialect = SQLiteDialect {};
89    let message: String;
90    let mut rendered: Option<String> = None;
91    let mut ast = Parser::parse_sql(&dialect, query).map_err(SQLRiteError::from)?;
92
93    if ast.len() > 1 {
94        return Err(SQLRiteError::SqlError(ParserError::ParserError(format!(
95            "Expected a single query statement, but there are {}",
96            ast.len()
97        ))));
98    }
99
100    // Comment-only or whitespace-only input parses to an empty Vec<Statement>.
101    // Return a benign status rather than panicking on `pop().unwrap()`. Callers
102    // (REPL, Tauri app) treat this as a no-op with no disk write triggered.
103    let Some(query) = ast.pop() else {
104        return Ok(CommandOutput {
105            status: "No statement to execute.".to_string(),
106            rendered: None,
107        });
108    };
109
110    // Transaction boundary statements are routed to Database-level
111    // handlers before we even inspect the rest of the AST. They don't
112    // mutate table data directly, so they short-circuit the
113    // is_write_statement / auto-save path.
114    match &query {
115        Statement::StartTransaction { .. } => {
116            db.begin_transaction()?;
117            return Ok(CommandOutput {
118                status: String::from("BEGIN"),
119                rendered: None,
120            });
121        }
122        Statement::Commit { .. } => {
123            if !db.in_transaction() {
124                return Err(SQLRiteError::General(
125                    "cannot COMMIT: no transaction is open".to_string(),
126                ));
127            }
128            // Flush accumulated in-memory changes to disk. If the save
129            // fails we auto-rollback the in-memory state to the
130            // pre-BEGIN snapshot and surface a combined error. Leaving
131            // the transaction open after a failed COMMIT would be
132            // unsafe: auto-save on any subsequent non-transactional
133            // statement would silently publish partial mid-transaction
134            // work. Auto-rollback keeps the disk-plus-memory pair
135            // coherent — the user loses their in-flight work on a disk
136            // error, but that's the only safe outcome.
137            if let Some(path) = db.source_path.clone() {
138                if let Err(save_err) = pager::save_database(db, &path) {
139                    let _ = db.rollback_transaction();
140                    return Err(SQLRiteError::General(format!(
141                        "COMMIT failed — transaction rolled back: {save_err}"
142                    )));
143                }
144            }
145            db.commit_transaction()?;
146            return Ok(CommandOutput {
147                status: String::from("COMMIT"),
148                rendered: None,
149            });
150        }
151        Statement::Rollback { .. } => {
152            db.rollback_transaction()?;
153            return Ok(CommandOutput {
154                status: String::from("ROLLBACK"),
155                rendered: None,
156            });
157        }
158        _ => {}
159    }
160
161    // Statements that mutate state — trigger auto-save on success. Read-only
162    // SELECTs skip the save entirely to avoid pointless file writes.
163    // VACUUM is a write statement (rewrites the entire file) but it does
164    // its own save internally, so it's also explicitly excluded from the
165    // post-dispatch auto-save block at the bottom.
166    let is_write_statement = matches!(
167        &query,
168        Statement::CreateTable(_)
169            | Statement::CreateIndex(_)
170            | Statement::Insert(_)
171            | Statement::Update(_)
172            | Statement::Delete(_)
173            | Statement::Drop { .. }
174            | Statement::AlterTable(_)
175            | Statement::Vacuum(_)
176    );
177    let is_vacuum = matches!(&query, Statement::Vacuum(_));
178
179    // SQLR-10: statements that release pages onto the freelist.
180    // After the auto-save flushes them, we'll consult
181    // `db.auto_vacuum_threshold` and possibly compact in place.
182    // ALTER TABLE here matches only DROP COLUMN — RENAME / ADD COLUMN
183    // don't grow the freelist, so they shouldn't pay the trigger cost.
184    let releases_pages = match &query {
185        Statement::Drop { object_type, .. } => {
186            matches!(object_type, ObjectType::Table | ObjectType::Index)
187        }
188        Statement::AlterTable(alter) => alter
189            .operations
190            .iter()
191            .any(|op| matches!(op, AlterTableOperation::DropColumn { .. })),
192        _ => false,
193    };
194
195    // Early-reject mutations on a read-only database before they touch
196    // in-memory state. Phase 4e: without this, a user running INSERT
197    // on a `--readonly` REPL would see the row appear in the printed
198    // table, and then the auto-save would fail — leaving the in-memory
199    // Database visibly diverged from disk.
200    if is_write_statement && db.is_read_only() {
201        return Err(SQLRiteError::General(
202            "cannot execute: database is opened read-only".to_string(),
203        ));
204    }
205
206    // Initialy only implementing some basic SQL Statements
207    match query {
208        Statement::CreateTable(_) => {
209            let create_query = CreateQuery::new(&query);
210            match create_query {
211                Ok(payload) => {
212                    let table_name = payload.table_name.clone();
213                    if table_name == pager::MASTER_TABLE_NAME {
214                        return Err(SQLRiteError::General(format!(
215                            "'{}' is a reserved name used by the internal schema catalog",
216                            pager::MASTER_TABLE_NAME
217                        )));
218                    }
219                    // Checking if table already exists, after parsing CREATE TABLE query
220                    match db.contains_table(table_name.to_string()) {
221                        true => {
222                            return Err(SQLRiteError::Internal(
223                                "Cannot create, table already exists.".to_string(),
224                            ));
225                        }
226                        false => {
227                            let table = Table::new(payload);
228                            // Note: we used to call `table.print_table_schema()` here
229                            // for REPL convenience. Removed because it wrote
230                            // directly to stdout, which corrupted any non-REPL
231                            // protocol channel (most painfully the MCP server's
232                            // JSON-RPC wire). The status line below is enough for
233                            // the REPL; users who want to inspect the schema can
234                            // run a follow-up describe / `.tables`-style command.
235                            db.tables.insert(table_name.to_string(), table);
236                            message = String::from("CREATE TABLE Statement executed.");
237                        }
238                    }
239                }
240                Err(err) => return Err(err),
241            }
242        }
243        Statement::Insert(_) => {
244            let insert_query = InsertQuery::new(&query);
245            match insert_query {
246                Ok(payload) => {
247                    let table_name = payload.table_name;
248                    let columns = payload.columns;
249                    let values = payload.rows;
250
251                    // println!("table_name = {:?}\n cols = {:?}\n vals = {:?}", table_name, columns, values);
252                    // Checking if Table exists in Database
253                    match db.contains_table(table_name.to_string()) {
254                        true => {
255                            let db_table = db.get_table_mut(table_name.to_string()).unwrap();
256                            // Checking if columns on INSERT query exist on Table
257                            match columns
258                                .iter()
259                                .all(|column| db_table.contains_column(column.to_string()))
260                            {
261                                true => {
262                                    for value in &values {
263                                        // Checking if number of columns in query are the same as number of values
264                                        if columns.len() != value.len() {
265                                            return Err(SQLRiteError::Internal(format!(
266                                                "{} values for {} columns",
267                                                value.len(),
268                                                columns.len()
269                                            )));
270                                        }
271                                        db_table
272                                            .validate_unique_constraint(&columns, value)
273                                            .map_err(|err| {
274                                                SQLRiteError::Internal(format!(
275                                                    "Unique key constraint violation: {err}"
276                                                ))
277                                            })?;
278                                        db_table.insert_row(&columns, value)?;
279                                    }
280                                }
281                                false => {
282                                    return Err(SQLRiteError::Internal(
283                                        "Cannot insert, some of the columns do not exist"
284                                            .to_string(),
285                                    ));
286                                }
287                            }
288                            // Note: we used to call `db_table.print_table_data()`
289                            // here, which dumped the *entire* table to stdout
290                            // after every INSERT. Beyond corrupting non-REPL
291                            // stdout channels, that's actively bad UX on any
292                            // table with more than a few rows. Removed in the
293                            // engine-stdout-pollution cleanup.
294                        }
295                        false => {
296                            return Err(SQLRiteError::Internal("Table doesn't exist".to_string()));
297                        }
298                    }
299                }
300                Err(err) => return Err(err),
301            }
302
303            message = String::from("INSERT Statement executed.")
304        }
305        Statement::Query(_) => {
306            let select_query = SelectQuery::new(&query)?;
307            let (rendered_table, rows) = executor::execute_select(select_query, db)?;
308            // Stash the rendered prettytable in the output so the REPL
309            // (or any terminal-style consumer) can print it above the
310            // status line. SDK / FFI / MCP callers ignore this field.
311            // The previous implementation `print!("{rendered}")`-ed
312            // directly to stdout, which broke every non-REPL embedder.
313            rendered = Some(rendered_table);
314            message = format!(
315                "SELECT Statement executed. {rows} row{s} returned.",
316                s = if rows == 1 { "" } else { "s" }
317            );
318        }
319        Statement::Delete(_) => {
320            let rows = executor::execute_delete(&query, db)?;
321            message = format!(
322                "DELETE Statement executed. {rows} row{s} deleted.",
323                s = if rows == 1 { "" } else { "s" }
324            );
325        }
326        Statement::Update(_) => {
327            let rows = executor::execute_update(&query, db)?;
328            message = format!(
329                "UPDATE Statement executed. {rows} row{s} updated.",
330                s = if rows == 1 { "" } else { "s" }
331            );
332        }
333        Statement::CreateIndex(_) => {
334            let name = executor::execute_create_index(&query, db)?;
335            message = format!("CREATE INDEX '{name}' executed.");
336        }
337        Statement::Drop {
338            object_type,
339            if_exists,
340            names,
341            ..
342        } => match object_type {
343            ObjectType::Table => {
344                let count = executor::execute_drop_table(&names, if_exists, db)?;
345                let plural = if count == 1 { "table" } else { "tables" };
346                message = format!("DROP TABLE Statement executed. {count} {plural} dropped.");
347            }
348            ObjectType::Index => {
349                let count = executor::execute_drop_index(&names, if_exists, db)?;
350                let plural = if count == 1 { "index" } else { "indexes" };
351                message = format!("DROP INDEX Statement executed. {count} {plural} dropped.");
352            }
353            other => {
354                return Err(SQLRiteError::NotImplemented(format!(
355                    "DROP {other:?} is not supported (only TABLE and INDEX)"
356                )));
357            }
358        },
359        Statement::AlterTable(alter) => {
360            message = executor::execute_alter_table(alter, db)?;
361        }
362        Statement::Vacuum(vac) => {
363            // SQLR-6 — only bare `VACUUM;` is supported. The crate-level
364            // `VacuumStatement` carries Redshift-style modifiers we don't
365            // implement; reject any non-default flag rather than silently
366            // ignoring it.
367            if vac.full
368                || vac.sort_only
369                || vac.delete_only
370                || vac.reindex
371                || vac.recluster
372                || vac.boost
373                || vac.table_name.is_some()
374                || vac.threshold.is_some()
375            {
376                return Err(SQLRiteError::NotImplemented(
377                    "VACUUM modifiers (FULL, REINDEX, table targets, etc.) are not supported; use bare VACUUM;"
378                        .to_string(),
379                ));
380            }
381            message = executor::execute_vacuum(db)?;
382        }
383        _ => {
384            return Err(SQLRiteError::NotImplemented(
385                "SQL Statement not supported yet.".to_string(),
386            ));
387        }
388    };
389
390    // Auto-save: if the database is backed by a file AND no explicit
391    // transaction is open AND the statement changed state, flush to
392    // disk before returning. Inside a `BEGIN … COMMIT` block the
393    // mutations accumulate in memory (protected by the ROLLBACK
394    // snapshot) and land on disk in one shot when COMMIT runs.
395    //
396    // A failed save surfaces as an error — the in-memory state already
397    // mutated, so the caller should know disk is out of sync. The
398    // Pager held on `db` diffs against its last-committed snapshot,
399    // so only pages whose bytes actually changed are written.
400    //
401    // VACUUM is a write-shaped statement but already wrote the file
402    // internally — skip the second save to avoid undoing the compact.
403    if is_write_statement && !is_vacuum && db.source_path.is_some() && !db.in_transaction() {
404        let path = db.source_path.clone().unwrap();
405        pager::save_database(db, &path)?;
406    }
407
408    // SQLR-10 auto-VACUUM trigger. Runs *after* the auto-save above so
409    // the orphaned pages from the just-executed DROP/ALTER have actually
410    // landed on the freelist (the bottom-up rebuild populates it during
411    // save). Skipped mid-transaction (no commit yet → no save → freelist
412    // is stale), on in-memory DBs (nothing to compact), and when the
413    // user has explicitly disabled the trigger via
414    // `set_auto_vacuum_threshold(None)`. We deliberately bypass
415    // `executor::execute_vacuum` and call `pager::vacuum_database`
416    // directly: the executor wrapper builds a user-facing status string
417    // and rejects in-transaction calls — both wrong for this silent
418    // maintenance path.
419    if releases_pages && !db.in_transaction() {
420        if let (Some(threshold), Some(path)) = (db.auto_vacuum_threshold(), db.source_path.clone())
421        {
422            let should = match db.pager.as_ref() {
423                Some(p) => pager::freelist::should_auto_vacuum(p, threshold)?,
424                None => false,
425            };
426            if should {
427                pager::vacuum_database(db, &path)?;
428            }
429        }
430    }
431
432    Ok(CommandOutput {
433        status: message,
434        rendered,
435    })
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::sql::db::table::Value;
442
443    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
444    /// with three rows, for use in executor-level tests.
445    fn seed_users_table() -> Database {
446        let mut db = Database::new("tempdb".to_string());
447        process_command(
448            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
449            &mut db,
450        )
451        .expect("create table");
452        process_command(
453            "INSERT INTO users (name, age) VALUES ('alice', 30);",
454            &mut db,
455        )
456        .expect("insert alice");
457        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
458            .expect("insert bob");
459        process_command(
460            "INSERT INTO users (name, age) VALUES ('carol', 40);",
461            &mut db,
462        )
463        .expect("insert carol");
464        db
465    }
466
467    #[test]
468    fn process_command_select_all_test() {
469        let mut db = seed_users_table();
470        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
471        assert!(response.contains("3 rows returned"));
472    }
473
474    #[test]
475    fn process_command_select_where_test() {
476        let mut db = seed_users_table();
477        let response =
478            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
479        assert!(response.contains("2 rows returned"));
480    }
481
482    #[test]
483    fn process_command_select_eq_string_test() {
484        let mut db = seed_users_table();
485        let response =
486            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
487        assert!(response.contains("1 row returned"));
488    }
489
490    #[test]
491    fn process_command_select_limit_test() {
492        let mut db = seed_users_table();
493        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
494            .expect("select");
495        assert!(response.contains("2 rows returned"));
496    }
497
498    #[test]
499    fn process_command_select_unknown_table_test() {
500        let mut db = Database::new("tempdb".to_string());
501        let result = process_command("SELECT * FROM nope;", &mut db);
502        assert!(result.is_err());
503    }
504
505    #[test]
506    fn process_command_select_unknown_column_test() {
507        let mut db = seed_users_table();
508        let result = process_command("SELECT height FROM users;", &mut db);
509        assert!(result.is_err());
510    }
511
512    #[test]
513    fn process_command_insert_test() {
514        // Creating temporary database
515        let mut db = Database::new("tempdb".to_string());
516
517        // Creating temporary table for testing purposes
518        let query_statement = "CREATE TABLE users (
519            id INTEGER PRIMARY KEY,
520            name TEXT
521        );";
522        let dialect = SQLiteDialect {};
523        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
524        if ast.len() > 1 {
525            panic!("Expected a single query statement, but there are more then 1.")
526        }
527        let query = ast.pop().unwrap();
528        let create_query = CreateQuery::new(&query).unwrap();
529
530        // Inserting table into database
531        db.tables.insert(
532            create_query.table_name.to_string(),
533            Table::new(create_query),
534        );
535
536        // Inserting data into table
537        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
538        match process_command(&insert_query, &mut db) {
539            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
540            Err(err) => {
541                eprintln!("Error: {}", err);
542                assert!(false)
543            }
544        };
545    }
546
547    #[test]
548    fn process_command_insert_no_pk_test() {
549        // Creating temporary database
550        let mut db = Database::new("tempdb".to_string());
551
552        // Creating temporary table for testing purposes
553        let query_statement = "CREATE TABLE users (
554            name TEXT
555        );";
556        let dialect = SQLiteDialect {};
557        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
558        if ast.len() > 1 {
559            panic!("Expected a single query statement, but there are more then 1.")
560        }
561        let query = ast.pop().unwrap();
562        let create_query = CreateQuery::new(&query).unwrap();
563
564        // Inserting table into database
565        db.tables.insert(
566            create_query.table_name.to_string(),
567            Table::new(create_query),
568        );
569
570        // Inserting data into table
571        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
572        match process_command(&insert_query, &mut db) {
573            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
574            Err(err) => {
575                eprintln!("Error: {}", err);
576                assert!(false)
577            }
578        };
579    }
580
581    #[test]
582    fn process_command_delete_where_test() {
583        let mut db = seed_users_table();
584        let response =
585            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
586        assert!(response.contains("1 row deleted"));
587
588        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
589        assert!(remaining.contains("2 rows returned"));
590    }
591
592    #[test]
593    fn process_command_delete_all_test() {
594        let mut db = seed_users_table();
595        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
596        assert!(response.contains("3 rows deleted"));
597    }
598
599    #[test]
600    fn process_command_update_where_test() {
601        use crate::sql::db::table::Value;
602
603        let mut db = seed_users_table();
604        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
605            .expect("update");
606        assert!(response.contains("1 row updated"));
607
608        // Confirm the cell was actually rewritten.
609        let users = db.get_table("users".to_string()).unwrap();
610        let bob_rowid = users
611            .rowids()
612            .into_iter()
613            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
614            .expect("bob row must exist");
615        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
616    }
617
618    #[test]
619    fn process_command_update_unique_violation_test() {
620        let mut db = seed_users_table();
621        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
622        process_command(
623            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
624            &mut db,
625        )
626        .unwrap();
627        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
628        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
629
630        let result = process_command("UPDATE tags SET label = 'a' WHERE label = 'b';", &mut db);
631        assert!(result.is_err(), "expected UNIQUE violation, got {result:?}");
632    }
633
634    #[test]
635    fn process_command_insert_type_mismatch_returns_error_test() {
636        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
637        let mut db = Database::new("tempdb".to_string());
638        process_command(
639            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
640            &mut db,
641        )
642        .unwrap();
643        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
644        assert!(result.is_err(), "expected error, got {result:?}");
645    }
646
647    #[test]
648    fn insert_omitted_integer_column_is_stored_as_null() {
649        // SQLR-7 — pre-fix this errored because the omitted column was
650        // padded with the literal `"Null"` and then re-parsed as i32. The
651        // new INSERT pipeline carries `Option<Value>` from the parser
652        // through to `insert_row`, so a missing non-PK column is just
653        // SQL NULL (matches SQLite).
654        use crate::sql::db::table::Value;
655
656        let mut db = Database::new("tempdb".to_string());
657        process_command(
658            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
659            &mut db,
660        )
661        .unwrap();
662        process_command("INSERT INTO items (id) VALUES (1);", &mut db)
663            .expect("INSERT with omitted INTEGER column should succeed and store NULL");
664
665        let table = db.get_table("items".to_string()).unwrap();
666        let rowid = table.rowids().pop().expect("one row");
667        // BTreeMap entry was never written → get_value returns None,
668        // which the executor renders as Value::Null.
669        assert_eq!(table.get_value("qty", rowid), None);
670        // IS NULL via the executor sees the same NULL.
671        let response = process_command("SELECT id FROM items WHERE qty IS NULL;", &mut db)
672            .expect("select IS NULL");
673        assert!(
674            response.contains("1 row returned"),
675            "qty IS NULL should match the omitted-column row, got: {response}"
676        );
677        // Sanity: explicit literal stays Integer.
678        process_command("INSERT INTO items (id, qty) VALUES (2, 7);", &mut db).unwrap();
679        let table = db.get_table("items".to_string()).unwrap();
680        let row_two = table
681            .rowids()
682            .into_iter()
683            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
684            .unwrap();
685        assert_eq!(table.get_value("qty", row_two), Some(Value::Integer(7)));
686    }
687
688    #[test]
689    fn insert_explicit_null_into_integer_column() {
690        let mut db = Database::new("tempdb".to_string());
691        process_command(
692            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
693            &mut db,
694        )
695        .unwrap();
696        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db)
697            .expect("INSERT explicit NULL into INTEGER must not panic on parse::<i32>()");
698        let table = db.get_table("t".to_string()).unwrap();
699        let rowid = table.rowids().pop().unwrap();
700        assert_eq!(table.get_value("n", rowid), None);
701    }
702
703    #[test]
704    fn insert_explicit_null_into_text_column() {
705        // Pre-fix: the literal string "Null" was stored in the BTreeMap and
706        // a read-side workaround (`if v == "Null"`) re-mapped it back to
707        // Value::Null. Post-fix: nothing is stored at all, so a user-typed
708        // string `'Null'` no longer collides with SQL NULL.
709        use crate::sql::db::table::Value;
710
711        let mut db = Database::new("tempdb".to_string());
712        process_command("CREATE TABLE t (id INTEGER PRIMARY KEY, s TEXT);", &mut db).unwrap();
713        process_command("INSERT INTO t (id, s) VALUES (1, NULL);", &mut db).unwrap();
714        process_command("INSERT INTO t (id, s) VALUES (2, 'hi');", &mut db).unwrap();
715
716        let table = db.get_table("t".to_string()).unwrap();
717        let row_one = table
718            .rowids()
719            .into_iter()
720            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
721            .unwrap();
722        let row_two = table
723            .rowids()
724            .into_iter()
725            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
726            .unwrap();
727        assert_eq!(table.get_value("s", row_one), None);
728        assert_eq!(
729            table.get_value("s", row_two),
730            Some(Value::Text("hi".to_string()))
731        );
732    }
733
734    #[test]
735    fn insert_explicit_null_into_real_column() {
736        let mut db = Database::new("tempdb".to_string());
737        process_command(
738            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL);",
739            &mut db,
740        )
741        .unwrap();
742        process_command("INSERT INTO t (id, score) VALUES (1, NULL);", &mut db)
743            .expect("INSERT explicit NULL into REAL must not panic on parse::<f32>()");
744        let table = db.get_table("t".to_string()).unwrap();
745        let rowid = table.rowids().pop().unwrap();
746        assert_eq!(table.get_value("score", rowid), None);
747    }
748
749    #[test]
750    fn insert_explicit_null_into_bool_column() {
751        let mut db = Database::new("tempdb".to_string());
752        process_command(
753            "CREATE TABLE t (id INTEGER PRIMARY KEY, flag BOOLEAN);",
754            &mut db,
755        )
756        .unwrap();
757        process_command("INSERT INTO t (id, flag) VALUES (1, NULL);", &mut db)
758            .expect("INSERT explicit NULL into BOOL must not panic on parse::<bool>()");
759        let table = db.get_table("t".to_string()).unwrap();
760        let rowid = table.rowids().pop().unwrap();
761        assert_eq!(table.get_value("flag", rowid), None);
762    }
763
764    #[test]
765    fn insert_explicit_null_into_vector_column() {
766        let mut db = Database::new("tempdb".to_string());
767        process_command(
768            "CREATE TABLE t (id INTEGER PRIMARY KEY, v VECTOR(3));",
769            &mut db,
770        )
771        .unwrap();
772        process_command("INSERT INTO t (id, v) VALUES (1, NULL);", &mut db)
773            .expect("INSERT explicit NULL into VECTOR must not panic on parse_vector_literal");
774        let table = db.get_table("t".to_string()).unwrap();
775        let rowid = table.rowids().pop().unwrap();
776        assert_eq!(table.get_value("v", rowid), None);
777    }
778
779    #[test]
780    fn insert_explicit_null_into_json_column() {
781        let mut db = Database::new("tempdb".to_string());
782        process_command(
783            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON);",
784            &mut db,
785        )
786        .unwrap();
787        process_command("INSERT INTO t (id, doc) VALUES (1, NULL);", &mut db)
788            .expect("INSERT explicit NULL into JSON must skip serde_json validation");
789        let table = db.get_table("t".to_string()).unwrap();
790        let rowid = table.rowids().pop().unwrap();
791        assert_eq!(table.get_value("doc", rowid), None);
792    }
793
794    #[test]
795    fn default_does_not_override_explicit_null() {
796        // Restored from SQLR-2 (was dropped because it collided with the
797        // stringly-typed NULL handling SQLR-7 fixes). Column has DEFAULT 0;
798        // an explicit NULL in the INSERT must override the default and
799        // store NULL — the default only fires when the column is omitted.
800        use crate::sql::db::table::Value;
801
802        let mut db = Database::new("tempdb".to_string());
803        process_command(
804            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 0);",
805            &mut db,
806        )
807        .unwrap();
808        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db).unwrap();
809        process_command("INSERT INTO t (id) VALUES (2);", &mut db).unwrap();
810
811        let table = db.get_table("t".to_string()).unwrap();
812        let row_one = table
813            .rowids()
814            .into_iter()
815            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
816            .unwrap();
817        let row_two = table
818            .rowids()
819            .into_iter()
820            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
821            .unwrap();
822        // Explicit NULL: stored as NULL, not the default.
823        assert_eq!(table.get_value("n", row_one), None);
824        // Omitted: stored as the DEFAULT 0.
825        assert_eq!(table.get_value("n", row_two), Some(Value::Integer(0)));
826    }
827
828    #[test]
829    fn process_command_update_arith_test() {
830        use crate::sql::db::table::Value;
831
832        let mut db = seed_users_table();
833        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
834
835        let users = db.get_table("users".to_string()).unwrap();
836        let mut ages: Vec<i64> = users
837            .rowids()
838            .into_iter()
839            .filter_map(|r| match users.get_value("age", r) {
840                Some(Value::Integer(n)) => Some(n),
841                _ => None,
842            })
843            .collect();
844        ages.sort();
845        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
846    }
847
848    #[test]
849    fn process_command_select_arithmetic_where_test() {
850        let mut db = seed_users_table();
851        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
852        let response =
853            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
854        assert!(response.contains("2 rows returned"));
855    }
856
857    #[test]
858    fn process_command_divide_by_zero_test() {
859        let mut db = seed_users_table();
860        let result = process_command("SELECT age / 0 FROM users;", &mut db);
861        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
862        assert!(result.is_err());
863    }
864
865    #[test]
866    fn process_command_unsupported_statement_test() {
867        let mut db = Database::new("tempdb".to_string());
868        // CREATE VIEW is firmly in the "Not yet supported" list — used as
869        // the canary for the dispatcher's NotImplemented arm. (DROP TABLE
870        // moved out of unsupported in this branch.)
871        let result = process_command("CREATE VIEW v AS SELECT * FROM users;", &mut db);
872        assert!(result.is_err());
873    }
874
875    #[test]
876    fn empty_input_is_a_noop_not_a_panic() {
877        // Regression for: desktop app pre-fills the textarea with a
878        // comment-only placeholder, and hitting Run used to panic because
879        // sqlparser produced zero statements and pop().unwrap() exploded.
880        let mut db = Database::new("t".to_string());
881        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
882            let result = process_command(input, &mut db);
883            assert!(result.is_ok(), "input {input:?} should not error");
884            let msg = result.unwrap();
885            assert!(msg.contains("No statement"), "got: {msg:?}");
886        }
887    }
888
889    #[test]
890    fn create_index_adds_explicit_index() {
891        let mut db = seed_users_table();
892        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
893            .expect("create index");
894        assert!(response.contains("users_age_idx"));
895
896        // The index should now be attached to the users table.
897        let users = db.get_table("users".to_string()).unwrap();
898        let idx = users
899            .index_by_name("users_age_idx")
900            .expect("index should exist after CREATE INDEX");
901        assert_eq!(idx.column_name, "age");
902        assert!(!idx.is_unique);
903    }
904
905    #[test]
906    fn create_unique_index_rejects_duplicate_existing_values() {
907        let mut db = seed_users_table();
908        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
909        // first so CREATE UNIQUE INDEX on age catches the conflict.
910        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
911        let result = process_command(
912            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
913            &mut db,
914        );
915        assert!(
916            result.is_err(),
917            "expected unique-index failure, got {result:?}"
918        );
919    }
920
921    #[test]
922    fn where_eq_on_indexed_column_uses_index_probe() {
923        // Build a table big enough that a full scan would be expensive,
924        // then rely on the index-probe fast path. This test verifies
925        // correctness (right rows returned); the perf win is implicit.
926        let mut db = Database::new("t".to_string());
927        process_command(
928            "CREATE TABLE big (id INTEGER PRIMARY KEY, tag TEXT);",
929            &mut db,
930        )
931        .unwrap();
932        process_command("CREATE INDEX big_tag_idx ON big (tag);", &mut db).unwrap();
933        for i in 1..=100 {
934            let tag = if i % 3 == 0 { "hot" } else { "cold" };
935            process_command(&format!("INSERT INTO big (tag) VALUES ('{tag}');"), &mut db).unwrap();
936        }
937        let response =
938            process_command("SELECT id FROM big WHERE tag = 'hot';", &mut db).expect("select");
939        // 1..=100 has 33 multiples of 3.
940        assert!(
941            response.contains("33 rows returned"),
942            "response was {response:?}"
943        );
944    }
945
946    #[test]
947    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
948        let mut db = seed_users_table();
949        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
950            .expect("select");
951        assert!(response.contains("1 row returned"));
952    }
953
954    #[test]
955    fn where_eq_literal_first_side_uses_index_probe() {
956        let mut db = seed_users_table();
957        // `'bob' = name` should hit the same path as `name = 'bob'`.
958        let response =
959            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
960        assert!(response.contains("1 row returned"));
961    }
962
963    #[test]
964    fn non_equality_where_still_falls_back_to_full_scan() {
965        // Sanity: range predicates bypass the optimizer and the full-scan
966        // path still returns correct results.
967        let mut db = seed_users_table();
968        let response =
969            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
970        assert!(response.contains("2 rows returned"));
971    }
972
973    // -------------------------------------------------------------------
974    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
975    // -------------------------------------------------------------------
976
977    #[test]
978    fn rollback_restores_pre_begin_in_memory_state() {
979        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
980        // The row must disappear from the live tables HashMap.
981        let mut db = seed_users_table();
982        let before = db.get_table("users".to_string()).unwrap().rowids().len();
983        assert_eq!(before, 3);
984
985        process_command("BEGIN;", &mut db).expect("BEGIN");
986        assert!(db.in_transaction());
987        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
988            .expect("INSERT inside txn");
989        // Mid-transaction read sees the new row.
990        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
991        assert_eq!(mid, 4);
992
993        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
994        assert!(!db.in_transaction());
995        let after = db.get_table("users".to_string()).unwrap().rowids().len();
996        assert_eq!(
997            after, 3,
998            "ROLLBACK should have restored the pre-BEGIN state"
999        );
1000    }
1001
1002    #[test]
1003    fn commit_keeps_mutations_and_clears_txn_flag() {
1004        let mut db = seed_users_table();
1005        process_command("BEGIN;", &mut db).expect("BEGIN");
1006        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1007            .expect("INSERT inside txn");
1008        process_command("COMMIT;", &mut db).expect("COMMIT");
1009        assert!(!db.in_transaction());
1010        let after = db.get_table("users".to_string()).unwrap().rowids().len();
1011        assert_eq!(after, 4);
1012    }
1013
1014    #[test]
1015    fn rollback_undoes_update_and_delete_side_by_side() {
1016        use crate::sql::db::table::Value;
1017
1018        let mut db = seed_users_table();
1019        process_command("BEGIN;", &mut db).unwrap();
1020        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
1021        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
1022        // Mid-txn: one row gone, others have age=999.
1023        let users = db.get_table("users".to_string()).unwrap();
1024        assert_eq!(users.rowids().len(), 2);
1025        for r in users.rowids() {
1026            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
1027        }
1028
1029        process_command("ROLLBACK;", &mut db).unwrap();
1030        let users = db.get_table("users".to_string()).unwrap();
1031        assert_eq!(users.rowids().len(), 3);
1032        // Original ages {30, 25, 40} — none should be 999.
1033        for r in users.rowids() {
1034            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
1035        }
1036    }
1037
1038    #[test]
1039    fn nested_begin_is_rejected() {
1040        let mut db = seed_users_table();
1041        process_command("BEGIN;", &mut db).unwrap();
1042        let err = process_command("BEGIN;", &mut db).unwrap_err();
1043        assert!(
1044            format!("{err}").contains("already open"),
1045            "nested BEGIN should error; got: {err}"
1046        );
1047        // Still in the original transaction; a ROLLBACK clears it.
1048        assert!(db.in_transaction());
1049        process_command("ROLLBACK;", &mut db).unwrap();
1050    }
1051
1052    #[test]
1053    fn orphan_commit_and_rollback_are_rejected() {
1054        let mut db = seed_users_table();
1055        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
1056        assert!(format!("{commit_err}").contains("no transaction"));
1057        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
1058        assert!(format!("{rollback_err}").contains("no transaction"));
1059    }
1060
1061    #[test]
1062    fn error_inside_transaction_keeps_txn_open() {
1063        // A bad INSERT inside a txn doesn't commit or abort automatically —
1064        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
1065        // isn't modeled here.
1066        let mut db = seed_users_table();
1067        process_command("BEGIN;", &mut db).unwrap();
1068        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
1069        assert!(err.is_err());
1070        assert!(db.in_transaction(), "txn should stay open after error");
1071        process_command("ROLLBACK;", &mut db).unwrap();
1072    }
1073
1074    /// Builds a file-backed Database at a unique temp path, with the
1075    /// schema seeded and `source_path` set so subsequent process_command
1076    /// calls auto-save. Returns (path, db). Drop the db before deleting
1077    /// the files.
1078    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
1079        use crate::sql::pager::{open_database, save_database};
1080        let mut p = std::env::temp_dir();
1081        let pid = std::process::id();
1082        let nanos = std::time::SystemTime::now()
1083            .duration_since(std::time::UNIX_EPOCH)
1084            .map(|d| d.as_nanos())
1085            .unwrap_or(0);
1086        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
1087
1088        // Seed the file, then reopen to get a source_path-attached db
1089        // (save_database alone doesn't attach a fresh pager to a db
1090        // whose source_path was None before the call).
1091        {
1092            let mut seed = Database::new("t".to_string());
1093            process_command(schema, &mut seed).unwrap();
1094            save_database(&mut seed, &p).unwrap();
1095        }
1096        let db = open_database(&p, "t".to_string()).unwrap();
1097        (p, db)
1098    }
1099
1100    fn cleanup_file(path: &std::path::Path) {
1101        let _ = std::fs::remove_file(path);
1102        let mut wal = path.as_os_str().to_owned();
1103        wal.push("-wal");
1104        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1105    }
1106
1107    #[test]
1108    fn null_values_round_trip_through_disk() {
1109        // SQLR-7 — explicit NULLs and omitted columns must persist.
1110        // Pre-fix, restore_row rejected NULL for INTEGER/REAL/BOOL/VECTOR
1111        // columns ("Integer column 'n' cannot store NULL — corrupt
1112        // cell?"), so any DB containing a NULL in those types failed to
1113        // reopen.
1114        use crate::sql::db::table::Value;
1115        use crate::sql::pager::open_database;
1116
1117        let (path, mut db) = seed_file_backed(
1118            "nullrt",
1119            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER, s TEXT, score REAL, flag BOOLEAN);",
1120        );
1121        process_command(
1122            "INSERT INTO t (id, n, s, score, flag) VALUES (1, 10, 'hi', 1.5, true);",
1123            &mut db,
1124        )
1125        .unwrap();
1126        process_command(
1127            "INSERT INTO t (id, n, s, score, flag) VALUES (2, NULL, NULL, NULL, NULL);",
1128            &mut db,
1129        )
1130        .unwrap();
1131        // Row 3 omits every nullable column.
1132        process_command("INSERT INTO t (id) VALUES (3);", &mut db).unwrap();
1133
1134        drop(db); // release pager lock
1135
1136        let reopened = open_database(&path, "t".to_string()).unwrap();
1137        let t = reopened.get_table("t".to_string()).unwrap();
1138        let by_id = |id: i64| {
1139            t.rowids()
1140                .into_iter()
1141                .find(|r| t.get_value("id", *r) == Some(Value::Integer(id)))
1142                .unwrap_or_else(|| panic!("row id={id} not found"))
1143        };
1144
1145        let r1 = by_id(1);
1146        assert_eq!(t.get_value("n", r1), Some(Value::Integer(10)));
1147        assert_eq!(t.get_value("s", r1), Some(Value::Text("hi".to_string())));
1148        assert_eq!(t.get_value("score", r1), Some(Value::Real(1.5)));
1149        assert_eq!(t.get_value("flag", r1), Some(Value::Bool(true)));
1150
1151        for r in [by_id(2), by_id(3)] {
1152            assert_eq!(t.get_value("n", r), None, "INTEGER NULL must round-trip");
1153            assert_eq!(t.get_value("s", r), None, "TEXT NULL must round-trip");
1154            assert_eq!(t.get_value("score", r), None, "REAL NULL must round-trip");
1155            assert_eq!(t.get_value("flag", r), None, "BOOL NULL must round-trip");
1156        }
1157
1158        drop(reopened);
1159        cleanup_file(&path);
1160    }
1161
1162    #[test]
1163    fn begin_commit_rollback_round_trip_through_disk() {
1164        // File-backed DB: commit inside a transaction must actually
1165        // persist. ROLLBACK inside a *later* transaction must not
1166        // un-do the previously-committed changes.
1167        use crate::sql::pager::open_database;
1168
1169        let (path, mut db) = seed_file_backed(
1170            "roundtrip",
1171            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1172        );
1173
1174        // Transaction 1: insert two rows, commit.
1175        process_command("BEGIN;", &mut db).unwrap();
1176        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
1177        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
1178        process_command("COMMIT;", &mut db).unwrap();
1179
1180        // Transaction 2: insert another, roll back.
1181        process_command("BEGIN;", &mut db).unwrap();
1182        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
1183        process_command("ROLLBACK;", &mut db).unwrap();
1184
1185        drop(db); // release pager lock
1186
1187        let reopened = open_database(&path, "t".to_string()).unwrap();
1188        let notes = reopened.get_table("notes".to_string()).unwrap();
1189        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
1190
1191        drop(reopened);
1192        cleanup_file(&path);
1193    }
1194
1195    #[test]
1196    fn write_inside_transaction_does_not_autosave() {
1197        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
1198        // the WAL until COMMIT. We prove it by checking the WAL file
1199        // size before vs during the transaction.
1200        let (path, mut db) =
1201            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
1202
1203        let mut wal_path = path.as_os_str().to_owned();
1204        wal_path.push("-wal");
1205        let wal_path = std::path::PathBuf::from(wal_path);
1206        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
1207
1208        process_command("BEGIN;", &mut db).unwrap();
1209        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
1210        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
1211
1212        // Mid-transaction: WAL must be unchanged — no auto-save fired.
1213        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
1214        assert_eq!(
1215            frames_before, frames_mid,
1216            "WAL should not grow during an open transaction"
1217        );
1218
1219        process_command("COMMIT;", &mut db).unwrap();
1220
1221        drop(db); // release pager lock
1222        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1223        assert_eq!(
1224            fresh.get_table("t".to_string()).unwrap().rowids().len(),
1225            2,
1226            "COMMIT should have persisted both inserted rows"
1227        );
1228        drop(fresh);
1229        cleanup_file(&path);
1230    }
1231
1232    #[test]
1233    fn rollback_undoes_create_table() {
1234        // Schema DDL inside a txn: ROLLBACK must make the new table
1235        // disappear. The txn snapshot captures db.tables as of BEGIN,
1236        // and ROLLBACK reassigns tables from that snapshot, so a table
1237        // created mid-transaction has no entry in the snapshot.
1238        let mut db = seed_users_table();
1239        assert_eq!(db.tables.len(), 1);
1240
1241        process_command("BEGIN;", &mut db).unwrap();
1242        process_command(
1243            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
1244            &mut db,
1245        )
1246        .unwrap();
1247        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
1248        assert_eq!(db.tables.len(), 2);
1249
1250        process_command("ROLLBACK;", &mut db).unwrap();
1251        assert_eq!(
1252            db.tables.len(),
1253            1,
1254            "CREATE TABLE should have been rolled back"
1255        );
1256        assert!(db.get_table("dropme".to_string()).is_err());
1257    }
1258
1259    #[test]
1260    fn rollback_restores_secondary_index_state() {
1261        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
1262        // column must also clean up the index, otherwise a re-insert of
1263        // the same value would spuriously collide.
1264        let mut db = Database::new("t".to_string());
1265        process_command(
1266            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
1267            &mut db,
1268        )
1269        .unwrap();
1270        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1271
1272        process_command("BEGIN;", &mut db).unwrap();
1273        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1274        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
1275        process_command("ROLLBACK;", &mut db).unwrap();
1276
1277        // Re-inserting 'b@x' after rollback must succeed — if the index
1278        // wasn't properly restored, it would think 'b@x' is still a
1279        // collision and fail with a UNIQUE violation.
1280        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
1281        assert!(
1282            reinsert.is_ok(),
1283            "re-insert after rollback should succeed, got {reinsert:?}"
1284        );
1285    }
1286
1287    #[test]
1288    fn rollback_restores_last_rowid_counter() {
1289        // Rowids allocated inside a rolled-back transaction should be
1290        // reusable. The snapshot restores Table::last_rowid, so the
1291        // next insert picks up where the pre-BEGIN state left off.
1292        use crate::sql::db::table::Value;
1293
1294        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
1295        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
1296
1297        process_command("BEGIN;", &mut db).unwrap();
1298        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
1299        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
1300        process_command("ROLLBACK;", &mut db).unwrap();
1301
1302        let post = db.get_table("users".to_string()).unwrap().last_rowid;
1303        assert_eq!(pre, post, "last_rowid must roll back with the snapshot");
1304
1305        // Confirm: the next insert reuses rowid pre+1.
1306        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
1307        let users = db.get_table("users".to_string()).unwrap();
1308        let d_rowid = users
1309            .rowids()
1310            .into_iter()
1311            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
1312            .expect("d row must exist");
1313        assert_eq!(d_rowid, pre + 1);
1314    }
1315
1316    #[test]
1317    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
1318        // In-memory DB (no source_path): COMMIT must still work — just
1319        // no disk flush. Covers the `if let Some(path) = …` branch
1320        // where the guard falls through without calling save_database.
1321        let mut db = seed_users_table(); // no source_path
1322        assert!(db.source_path.is_none());
1323
1324        process_command("BEGIN;", &mut db).unwrap();
1325        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1326        process_command("COMMIT;", &mut db).unwrap();
1327
1328        assert!(!db.in_transaction());
1329        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
1330    }
1331
1332    #[test]
1333    fn failed_commit_auto_rolls_back_in_memory_state() {
1334        // Data-safety regression: on COMMIT save failure we must auto-
1335        // rollback the in-memory state. Otherwise, any subsequent
1336        // non-transactional statement would auto-save the partial
1337        // mid-transaction work, silently publishing uncommitted
1338        // changes to disk.
1339        //
1340        // We simulate a save failure by making the WAL sidecar path
1341        // unavailable mid-transaction: after BEGIN, we take an
1342        // exclusive OS lock on the WAL via a second File handle,
1343        // forcing the next save to fail when it tries to append.
1344        //
1345        // Simpler repro: point source_path at a directory (not a file).
1346        // `OpenOptions::open` will fail with EISDIR on save.
1347        use crate::sql::pager::save_database;
1348
1349        // Seed a file-backed db.
1350        let (path, mut db) = seed_file_backed(
1351            "failcommit",
1352            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1353        );
1354
1355        // Prime one committed row so we have a baseline.
1356        process_command("INSERT INTO notes (body) VALUES ('before');", &mut db).unwrap();
1357
1358        // Open a new txn and add a row.
1359        process_command("BEGIN;", &mut db).unwrap();
1360        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1361        assert_eq!(
1362            db.get_table("notes".to_string()).unwrap().rowids().len(),
1363            2,
1364            "inflight row visible mid-txn"
1365        );
1366
1367        // Swap source_path to a path that will fail on open. A
1368        // directory is a reliable failure mode — Pager::open on a
1369        // directory errors with an I/O error.
1370        let orig_source = db.source_path.clone();
1371        let orig_pager = db.pager.take();
1372        db.source_path = Some(std::env::temp_dir());
1373
1374        let commit_result = process_command("COMMIT;", &mut db);
1375        assert!(commit_result.is_err(), "commit must fail");
1376        let err_str = format!("{}", commit_result.unwrap_err());
1377        assert!(
1378            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
1379            "error must surface auto-rollback; got: {err_str}"
1380        );
1381
1382        // Auto-rollback fired: the inflight row is gone, the txn flag
1383        // is cleared, and a follow-up non-txn statement won't leak
1384        // stale state.
1385        assert!(
1386            !db.in_transaction(),
1387            "txn must be cleared after auto-rollback"
1388        );
1389        assert_eq!(
1390            db.get_table("notes".to_string()).unwrap().rowids().len(),
1391            1,
1392            "inflight row must be rolled back"
1393        );
1394
1395        // Restore the real source_path + pager and verify a clean
1396        // subsequent write goes through.
1397        db.source_path = orig_source;
1398        db.pager = orig_pager;
1399        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1400        drop(db);
1401
1402        // Reopen and assert only 'before' + 'after' landed on disk.
1403        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1404        let notes = reopened.get_table("notes".to_string()).unwrap();
1405        assert_eq!(notes.rowids().len(), 2);
1406        // Ensure no leaked save_database partial happened.
1407        let _ = save_database; // silence unused-import lint if any
1408        drop(reopened);
1409        cleanup_file(&path);
1410    }
1411
1412    #[test]
1413    fn begin_on_read_only_is_rejected() {
1414        use crate::sql::pager::{open_database_read_only, save_database};
1415
1416        let path = {
1417            let mut p = std::env::temp_dir();
1418            let pid = std::process::id();
1419            let nanos = std::time::SystemTime::now()
1420                .duration_since(std::time::UNIX_EPOCH)
1421                .map(|d| d.as_nanos())
1422                .unwrap_or(0);
1423            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1424            p
1425        };
1426        {
1427            let mut seed = Database::new("t".to_string());
1428            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
1429            save_database(&mut seed, &path).unwrap();
1430        }
1431
1432        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1433        let err = process_command("BEGIN;", &mut ro).unwrap_err();
1434        assert!(
1435            format!("{err}").contains("read-only"),
1436            "BEGIN on RO db should surface read-only; got: {err}"
1437        );
1438        assert!(!ro.in_transaction());
1439
1440        let _ = std::fs::remove_file(&path);
1441        let mut wal = path.as_os_str().to_owned();
1442        wal.push("-wal");
1443        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1444    }
1445
1446    #[test]
1447    fn read_only_database_rejects_mutations_before_touching_state() {
1448        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1449        // must error *before* the row is added to the in-memory table.
1450        // Otherwise the user sees a rendered result table with the
1451        // phantom row, followed by the auto-save error — UX rot and a
1452        // state-drift risk.
1453        use crate::sql::pager::open_database_read_only;
1454
1455        let mut seed = Database::new("t".to_string());
1456        process_command(
1457            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1458            &mut seed,
1459        )
1460        .unwrap();
1461        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1462
1463        let path = {
1464            let mut p = std::env::temp_dir();
1465            let pid = std::process::id();
1466            let nanos = std::time::SystemTime::now()
1467                .duration_since(std::time::UNIX_EPOCH)
1468                .map(|d| d.as_nanos())
1469                .unwrap_or(0);
1470            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1471            p
1472        };
1473        crate::sql::pager::save_database(&mut seed, &path).unwrap();
1474        drop(seed);
1475
1476        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1477        let notes_before = ro.get_table("notes".to_string()).unwrap().rowids().len();
1478
1479        for stmt in [
1480            "INSERT INTO notes (body) VALUES ('beta');",
1481            "UPDATE notes SET body = 'x';",
1482            "DELETE FROM notes;",
1483            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1484            "CREATE INDEX notes_body ON notes (body);",
1485        ] {
1486            let err = process_command(stmt, &mut ro).unwrap_err();
1487            assert!(
1488                format!("{err}").contains("read-only"),
1489                "stmt {stmt:?} should surface a read-only error; got: {err}"
1490            );
1491        }
1492
1493        // Nothing mutated: same row count as before, and SELECTs still work.
1494        let notes_after = ro.get_table("notes".to_string()).unwrap().rowids().len();
1495        assert_eq!(notes_before, notes_after);
1496        let sel = process_command("SELECT * FROM notes;", &mut ro).expect("select on RO must work");
1497        assert!(sel.contains("1 row returned"));
1498
1499        // Cleanup.
1500        drop(ro);
1501        let _ = std::fs::remove_file(&path);
1502        let mut wal = path.as_os_str().to_owned();
1503        wal.push("-wal");
1504        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1505    }
1506
1507    // -----------------------------------------------------------------
1508    // Phase 7a — VECTOR(N) end-to-end through process_command
1509    // -----------------------------------------------------------------
1510
1511    #[test]
1512    fn vector_create_table_and_insert_basic() {
1513        let mut db = Database::new("tempdb".to_string());
1514        process_command(
1515            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1516            &mut db,
1517        )
1518        .expect("create table with VECTOR(3)");
1519        process_command(
1520            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1521            &mut db,
1522        )
1523        .expect("insert vector");
1524
1525        // process_command returns a status string; the rendered table
1526        // goes to stdout via print_table. Verify state by inspecting
1527        // the database directly.
1528        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1529        assert!(sel.contains("1 row returned"));
1530
1531        let docs = db.get_table("docs".to_string()).expect("docs table");
1532        let rowids = docs.rowids();
1533        assert_eq!(rowids.len(), 1);
1534        match docs.get_value("embedding", rowids[0]) {
1535            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1536            other => panic!("expected Value::Vector(...), got {other:?}"),
1537        }
1538    }
1539
1540    #[test]
1541    fn vector_dim_mismatch_at_insert_is_clean_error() {
1542        let mut db = Database::new("tempdb".to_string());
1543        process_command(
1544            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1545            &mut db,
1546        )
1547        .expect("create table");
1548
1549        // Too few elements.
1550        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1551            .unwrap_err();
1552        let msg = format!("{err}");
1553        assert!(
1554            msg.to_lowercase().contains("dimension")
1555                && msg.contains("declared 3")
1556                && msg.contains("got 2"),
1557            "expected clear dim-mismatch error, got: {msg}"
1558        );
1559
1560        // Too many elements.
1561        let err = process_command(
1562            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1563            &mut db,
1564        )
1565        .unwrap_err();
1566        assert!(
1567            format!("{err}").contains("got 5"),
1568            "expected dim-mismatch error mentioning got 5, got: {err}"
1569        );
1570    }
1571
1572    #[test]
1573    fn vector_create_table_rejects_missing_dim() {
1574        let mut db = Database::new("tempdb".to_string());
1575        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1576        // empty args from sqlparser, OR may not parse as Custom at all
1577        // depending on dialect. Either way, the column shouldn't end up
1578        // as a usable Vector type. Accept any error here — the precise
1579        // message is parser-version-dependent.
1580        let result = process_command(
1581            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1582            &mut db,
1583        );
1584        assert!(
1585            result.is_err(),
1586            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1587        );
1588    }
1589
1590    #[test]
1591    fn vector_create_table_rejects_zero_dim() {
1592        let mut db = Database::new("tempdb".to_string());
1593        let err = process_command(
1594            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1595            &mut db,
1596        )
1597        .unwrap_err();
1598        let msg = format!("{err}");
1599        assert!(
1600            msg.to_lowercase().contains("vector"),
1601            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1602        );
1603    }
1604
1605    #[test]
1606    fn vector_high_dim_works() {
1607        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1608        // smoke test — if cell encoding mishandles the size, this fails.
1609        let mut db = Database::new("tempdb".to_string());
1610        process_command(
1611            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1612            &mut db,
1613        )
1614        .expect("create table VECTOR(384)");
1615
1616        let lit = format!(
1617            "[{}]",
1618            (0..384)
1619                .map(|i| format!("{}", i as f32 * 0.001))
1620                .collect::<Vec<_>>()
1621                .join(",")
1622        );
1623        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
1624        process_command(&sql, &mut db).expect("insert 384-dim vector");
1625
1626        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1627        assert!(sel.contains("1 row returned"));
1628    }
1629
1630    #[test]
1631    fn vector_multiple_rows() {
1632        // Three rows with different vectors — exercises the Row::Vector
1633        // BTreeMap path (not just single-row insertion).
1634        let mut db = Database::new("tempdb".to_string());
1635        process_command(
1636            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1637            &mut db,
1638        )
1639        .expect("create");
1640        for i in 0..3 {
1641            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
1642            process_command(&sql, &mut db).expect("insert");
1643        }
1644        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1645        assert!(sel.contains("3 rows returned"));
1646
1647        // Verify each vector round-tripped correctly via direct DB inspection.
1648        let docs = db.get_table("docs".to_string()).expect("docs table");
1649        let rowids = docs.rowids();
1650        assert_eq!(rowids.len(), 3);
1651        let mut vectors: Vec<Vec<f32>> = rowids
1652            .iter()
1653            .filter_map(|r| match docs.get_value("e", *r) {
1654                Some(Value::Vector(v)) => Some(v),
1655                _ => None,
1656            })
1657            .collect();
1658        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
1659        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1660        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1661        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1662    }
1663
1664    // -----------------------------------------------------------------
1665    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1666    // -----------------------------------------------------------------
1667
1668    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1669    /// at known positions for clear distance reasoning. Used by both
1670    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1671    fn seed_hnsw_table() -> Database {
1672        let mut db = Database::new("tempdb".to_string());
1673        process_command(
1674            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1675            &mut db,
1676        )
1677        .unwrap();
1678        for v in &[
1679            "[1.0, 0.0]",   // id=1
1680            "[2.0, 0.0]",   // id=2
1681            "[0.0, 3.0]",   // id=3
1682            "[1.0, 4.0]",   // id=4
1683            "[10.0, 10.0]", // id=5
1684        ] {
1685            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
1686        }
1687        db
1688    }
1689
1690    #[test]
1691    fn create_index_using_hnsw_succeeds() {
1692        let mut db = seed_hnsw_table();
1693        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1694        assert!(resp.to_lowercase().contains("create index"));
1695        // Index attached.
1696        let table = db.get_table("docs".to_string()).unwrap();
1697        assert_eq!(table.hnsw_indexes.len(), 1);
1698        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1699        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1700        // Existing rows landed in the graph.
1701        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1702    }
1703
1704    #[test]
1705    fn create_index_using_hnsw_rejects_non_vector_column() {
1706        let mut db = Database::new("tempdb".to_string());
1707        process_command(
1708            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1709            &mut db,
1710        )
1711        .unwrap();
1712        let err =
1713            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1714        let msg = format!("{err}");
1715        assert!(
1716            msg.to_lowercase().contains("vector"),
1717            "expected error mentioning VECTOR; got: {msg}"
1718        );
1719    }
1720
1721    #[test]
1722    fn knn_query_uses_hnsw_after_create_index() {
1723        // The KNN-shaped query route through try_hnsw_probe rather than
1724        // the brute-force select_topk. The user-visible result should
1725        // be the same (HNSW recall is high on small graphs); we
1726        // primarily verify the index is being hit by checking that
1727        // the right rowids come back in the right order.
1728        let mut db = seed_hnsw_table();
1729        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1730
1731        // Top-3 closest to [1.0, 0.0]:
1732        //   id=1 [1.0, 0.0]   distance=0
1733        //   id=2 [2.0, 0.0]   distance=1
1734        //   id=3 [0.0, 3.0]   distance≈3.16
1735        let resp = process_command(
1736            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1737            &mut db,
1738        )
1739        .unwrap();
1740        assert!(resp.contains("3 rows returned"), "got: {resp}");
1741    }
1742
1743    #[test]
1744    fn knn_query_works_after_subsequent_inserts() {
1745        // Index built when 5 rows existed; insert 2 more after; the
1746        // HNSW gets maintained incrementally by insert_row, so the
1747        // KNN query should see the newly-inserted vectors.
1748        let mut db = seed_hnsw_table();
1749        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1750        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1751        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1752
1753        let table = db.get_table("docs".to_string()).unwrap();
1754        assert_eq!(
1755            table.hnsw_indexes[0].index.len(),
1756            7,
1757            "incremental insert should grow HNSW alongside row storage"
1758        );
1759
1760        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1761        // original 5 rows.
1762        let resp = process_command(
1763            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1764            &mut db,
1765        )
1766        .unwrap();
1767        assert!(resp.contains("1 row returned"), "got: {resp}");
1768    }
1769
1770    // Phase 7d.3 — DELETE / UPDATE on HNSW-indexed tables now works.
1771    // The 7d.2 versions of these tests asserted a refusal; replaced
1772    // with assertions that the operation succeeds + the index entry's
1773    // needs_rebuild flag flipped so the next save will rebuild.
1774
1775    #[test]
1776    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
1777        let mut db = seed_hnsw_table();
1778        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1779        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1780        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
1781
1782        let docs = db.get_table("docs".to_string()).unwrap();
1783        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
1784        assert!(
1785            entry.needs_rebuild,
1786            "DELETE should have marked HNSW index dirty for rebuild on next save"
1787        );
1788    }
1789
1790    #[test]
1791    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
1792        let mut db = seed_hnsw_table();
1793        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1794        let resp =
1795            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1796        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
1797
1798        let docs = db.get_table("docs".to_string()).unwrap();
1799        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
1800        assert!(
1801            entry.needs_rebuild,
1802            "UPDATE on the vector column should have marked HNSW index dirty"
1803        );
1804    }
1805
1806    #[test]
1807    fn duplicate_index_name_errors() {
1808        let mut db = seed_hnsw_table();
1809        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1810        let err =
1811            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1812        let msg = format!("{err}");
1813        assert!(
1814            msg.to_lowercase().contains("already exists"),
1815            "expected duplicate-index error; got: {msg}"
1816        );
1817    }
1818
1819    #[test]
1820    fn index_if_not_exists_is_idempotent() {
1821        let mut db = seed_hnsw_table();
1822        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1823        // Second time with IF NOT EXISTS should succeed (no-op).
1824        process_command(
1825            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1826            &mut db,
1827        )
1828        .unwrap();
1829        let table = db.get_table("docs".to_string()).unwrap();
1830        assert_eq!(table.hnsw_indexes.len(), 1);
1831    }
1832
1833    // -----------------------------------------------------------------
1834    // Phase 8b — CREATE INDEX … USING fts end-to-end
1835    // -----------------------------------------------------------------
1836
1837    /// 5-row docs(id INTEGER PK, body TEXT) populated with overlapping
1838    /// vocabulary so BM25 ranking has interesting structure.
1839    fn seed_fts_table() -> Database {
1840        let mut db = Database::new("tempdb".to_string());
1841        process_command(
1842            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1843            &mut db,
1844        )
1845        .unwrap();
1846        for body in &[
1847            "rust embedded database",        // id=1 — both 'rust' and 'embedded'
1848            "rust web framework",            // id=2 — 'rust' only
1849            "go embedded systems",           // id=3 — 'embedded' only
1850            "python web framework",          // id=4 — neither
1851            "rust rust rust embedded power", // id=5 — heavy on 'rust'
1852        ] {
1853            process_command(
1854                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
1855                &mut db,
1856            )
1857            .unwrap();
1858        }
1859        db
1860    }
1861
1862    #[test]
1863    fn create_index_using_fts_succeeds_and_indexes_existing_rows() {
1864        let mut db = seed_fts_table();
1865        let resp =
1866            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1867        assert!(resp.to_lowercase().contains("create index"), "got {resp}");
1868        let table = db.get_table("docs".to_string()).unwrap();
1869        assert_eq!(table.fts_indexes.len(), 1);
1870        assert_eq!(table.fts_indexes[0].name, "ix_body");
1871        assert_eq!(table.fts_indexes[0].column_name, "body");
1872        // All five rows should be in the in-memory PostingList.
1873        assert_eq!(table.fts_indexes[0].index.len(), 5);
1874    }
1875
1876    #[test]
1877    fn create_index_using_fts_rejects_non_text_column() {
1878        let mut db = Database::new("tempdb".to_string());
1879        process_command(
1880            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
1881            &mut db,
1882        )
1883        .unwrap();
1884        let err = process_command("CREATE INDEX ix_n ON t USING fts (n);", &mut db).unwrap_err();
1885        let msg = format!("{err}");
1886        assert!(
1887            msg.to_lowercase().contains("text"),
1888            "expected error mentioning TEXT; got: {msg}"
1889        );
1890    }
1891
1892    #[test]
1893    fn fts_match_returns_expected_rows() {
1894        let mut db = seed_fts_table();
1895        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1896        // Rows that contain 'rust': ids 1, 2, 5.
1897        let resp = process_command(
1898            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1899            &mut db,
1900        )
1901        .unwrap();
1902        assert!(resp.contains("3 rows returned"), "got: {resp}");
1903    }
1904
1905    #[test]
1906    fn fts_match_without_index_errors_clearly() {
1907        let mut db = seed_fts_table();
1908        // No CREATE INDEX — fts_match must surface a useful error.
1909        let err = process_command(
1910            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1911            &mut db,
1912        )
1913        .unwrap_err();
1914        let msg = format!("{err}");
1915        assert!(
1916            msg.contains("no FTS index"),
1917            "expected no-index error; got: {msg}"
1918        );
1919    }
1920
1921    #[test]
1922    fn bm25_score_orders_descending_by_relevance() {
1923        let mut db = seed_fts_table();
1924        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1925        // ORDER BY bm25_score DESC LIMIT 1: id=5 has 'rust' three times in
1926        // a 5-token doc — highest tf, modest length penalty → top score.
1927        let out = process_command_with_render(
1928            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1929             ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1930            &mut db,
1931        )
1932        .unwrap();
1933        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
1934        let rendered = out.rendered.expect("SELECT should produce rendered output");
1935        // The rendered prettytable contains the integer 5 in a cell.
1936        assert!(
1937            rendered.contains(" 5 "),
1938            "expected id=5 to be top-ranked; rendered:\n{rendered}"
1939        );
1940    }
1941
1942    #[test]
1943    fn bm25_score_without_index_errors_clearly() {
1944        let mut db = seed_fts_table();
1945        let err = process_command(
1946            "SELECT id FROM docs ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1947            &mut db,
1948        )
1949        .unwrap_err();
1950        let msg = format!("{err}");
1951        assert!(
1952            msg.contains("no FTS index"),
1953            "expected no-index error; got: {msg}"
1954        );
1955    }
1956
1957    #[test]
1958    fn fts_post_create_inserts_are_indexed_incrementally() {
1959        let mut db = seed_fts_table();
1960        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1961        process_command(
1962            "INSERT INTO docs (body) VALUES ('rust embedded analytics');",
1963            &mut db,
1964        )
1965        .unwrap();
1966        let table = db.get_table("docs".to_string()).unwrap();
1967        // PostingList::len() reports doc count; should be 6 now.
1968        assert_eq!(table.fts_indexes[0].index.len(), 6);
1969        // 'analytics' appears only in the new row → query returns 1 hit.
1970        let resp = process_command(
1971            "SELECT id FROM docs WHERE fts_match(body, 'analytics');",
1972            &mut db,
1973        )
1974        .unwrap();
1975        assert!(resp.contains("1 row returned"), "got: {resp}");
1976    }
1977
1978    #[test]
1979    fn delete_on_fts_indexed_table_marks_dirty() {
1980        let mut db = seed_fts_table();
1981        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1982        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1983        assert!(resp.contains("1 row"), "got: {resp}");
1984        let docs = db.get_table("docs".to_string()).unwrap();
1985        let entry = docs
1986            .fts_indexes
1987            .iter()
1988            .find(|e| e.name == "ix_body")
1989            .unwrap();
1990        assert!(
1991            entry.needs_rebuild,
1992            "DELETE should have flagged the FTS index dirty"
1993        );
1994    }
1995
1996    #[test]
1997    fn update_on_fts_indexed_text_col_marks_dirty() {
1998        let mut db = seed_fts_table();
1999        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2000        let resp = process_command(
2001            "UPDATE docs SET body = 'java spring framework' WHERE id = 1;",
2002            &mut db,
2003        )
2004        .unwrap();
2005        assert!(resp.contains("1 row"), "got: {resp}");
2006        let docs = db.get_table("docs".to_string()).unwrap();
2007        let entry = docs
2008            .fts_indexes
2009            .iter()
2010            .find(|e| e.name == "ix_body")
2011            .unwrap();
2012        assert!(
2013            entry.needs_rebuild,
2014            "UPDATE on the indexed TEXT column should have flagged dirty"
2015        );
2016    }
2017
2018    #[test]
2019    fn fts_index_name_collides_with_btree_and_hnsw_namespaces() {
2020        let mut db = seed_fts_table();
2021        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2022        let err = process_command("CREATE INDEX ix_body ON docs (body);", &mut db).unwrap_err();
2023        let msg = format!("{err}");
2024        assert!(
2025            msg.to_lowercase().contains("already exists"),
2026            "expected duplicate-index error; got: {msg}"
2027        );
2028    }
2029
2030    #[test]
2031    fn fts_index_rejects_unique() {
2032        let mut db = seed_fts_table();
2033        let err = process_command(
2034            "CREATE UNIQUE INDEX ix_body ON docs USING fts (body);",
2035            &mut db,
2036        )
2037        .unwrap_err();
2038        let msg = format!("{err}");
2039        assert!(
2040            msg.to_lowercase().contains("unique"),
2041            "expected UNIQUE-rejection error; got: {msg}"
2042        );
2043    }
2044
2045    #[test]
2046    fn try_fts_probe_falls_through_on_ascending() {
2047        // BM25 is "higher = better"; ASC is rejected so the slow path
2048        // applies. We verify by running the query and checking the
2049        // result is still correct (the slow path goes through scalar
2050        // bm25_score on every row).
2051        let mut db = seed_fts_table();
2052        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2053        // Same query as bm25_score_orders_descending but ASC → should
2054        // still succeed (slow path), and id=5 should now be LAST.
2055        let resp = process_command(
2056            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
2057             ORDER BY bm25_score(body, 'rust') ASC LIMIT 3;",
2058            &mut db,
2059        )
2060        .unwrap();
2061        assert!(resp.contains("3 rows returned"), "got: {resp}");
2062    }
2063
2064    // -----------------------------------------------------------------
2065    // Phase 7b — vector distance functions through process_command
2066    // -----------------------------------------------------------------
2067
2068    /// Builds a 3-row docs table with 2-dim vectors aligned along the
2069    /// axes so the expected distances are easy to reason about:
2070    ///   id=1: [1, 0]
2071    ///   id=2: [0, 1]
2072    ///   id=3: [1, 1]
2073    fn seed_vector_docs() -> Database {
2074        let mut db = Database::new("tempdb".to_string());
2075        process_command(
2076            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2077            &mut db,
2078        )
2079        .expect("create");
2080        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
2081        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
2082        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
2083        db
2084    }
2085
2086    #[test]
2087    fn vec_distance_l2_in_where_filters_correctly() {
2088        // Distance from [1,0]:
2089        //   id=1 [1,0]: 0
2090        //   id=2 [0,1]: √2 ≈ 1.414
2091        //   id=3 [1,1]: 1
2092        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
2093        let mut db = seed_vector_docs();
2094        let resp = process_command(
2095            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
2096            &mut db,
2097        )
2098        .expect("select");
2099        assert!(
2100            resp.contains("2 rows returned"),
2101            "expected 2 rows, got: {resp}"
2102        );
2103    }
2104
2105    #[test]
2106    fn vec_distance_cosine_in_where() {
2107        // [1,0] vs [1,0]: cosine distance = 0
2108        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
2109        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
2110        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
2111        let mut db = seed_vector_docs();
2112        let resp = process_command(
2113            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
2114            &mut db,
2115        )
2116        .expect("select");
2117        assert!(
2118            resp.contains("2 rows returned"),
2119            "expected 2 rows, got: {resp}"
2120        );
2121    }
2122
2123    #[test]
2124    fn vec_distance_dot_negated() {
2125        // [1,0]·[1,0] = 1 → -1
2126        // [1,0]·[0,1] = 0 → 0
2127        // [1,0]·[1,1] = 1 → -1
2128        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
2129        let mut db = seed_vector_docs();
2130        let resp = process_command(
2131            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
2132            &mut db,
2133        )
2134        .expect("select");
2135        assert!(
2136            resp.contains("2 rows returned"),
2137            "expected 2 rows, got: {resp}"
2138        );
2139    }
2140
2141    #[test]
2142    fn knn_via_order_by_distance_limit() {
2143        // Classic KNN shape: ORDER BY distance LIMIT k.
2144        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
2145        // LIMIT 2 should return id=1 then id=3 in that order.
2146        let mut db = seed_vector_docs();
2147        let resp = process_command(
2148            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
2149            &mut db,
2150        )
2151        .expect("select");
2152        assert!(
2153            resp.contains("2 rows returned"),
2154            "expected 2 rows, got: {resp}"
2155        );
2156    }
2157
2158    #[test]
2159    fn distance_function_dim_mismatch_errors() {
2160        // 2-dim column queried with a 3-dim probe → clean error.
2161        let mut db = seed_vector_docs();
2162        let err = process_command(
2163            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
2164            &mut db,
2165        )
2166        .unwrap_err();
2167        let msg = format!("{err}");
2168        assert!(
2169            msg.to_lowercase().contains("dimension")
2170                && msg.contains("lhs=2")
2171                && msg.contains("rhs=3"),
2172            "expected dim mismatch error, got: {msg}"
2173        );
2174    }
2175
2176    #[test]
2177    fn unknown_function_errors_with_name() {
2178        // Use the function in WHERE, not projection — the projection
2179        // parser still requires bare column references; function calls
2180        // there are a future enhancement (with `AS alias` support).
2181        let mut db = seed_vector_docs();
2182        let err = process_command(
2183            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
2184            &mut db,
2185        )
2186        .unwrap_err();
2187        let msg = format!("{err}");
2188        assert!(
2189            msg.contains("vec_does_not_exist"),
2190            "expected error mentioning function name, got: {msg}"
2191        );
2192    }
2193
2194    // -----------------------------------------------------------------
2195    // Phase 7e — JSON column type + path-extraction functions
2196    // -----------------------------------------------------------------
2197
2198    fn seed_json_table() -> Database {
2199        let mut db = Database::new("tempdb".to_string());
2200        process_command(
2201            "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
2202            &mut db,
2203        )
2204        .expect("create json table");
2205        db
2206    }
2207
2208    #[test]
2209    fn json_column_round_trip_primitive_values() {
2210        let mut db = seed_json_table();
2211        process_command(
2212            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2213            &mut db,
2214        )
2215        .expect("insert json");
2216        let docs = db.get_table("docs".to_string()).unwrap();
2217        let rowids = docs.rowids();
2218        assert_eq!(rowids.len(), 1);
2219        // Stored verbatim as Text underneath.
2220        match docs.get_value("payload", rowids[0]) {
2221            Some(Value::Text(s)) => {
2222                assert!(s.contains("alice"), "expected JSON text to round-trip: {s}");
2223            }
2224            other => panic!("expected Value::Text holding JSON, got {other:?}"),
2225        }
2226    }
2227
2228    #[test]
2229    fn json_insert_rejects_invalid_json() {
2230        let mut db = seed_json_table();
2231        let err = process_command(
2232            "INSERT INTO docs (payload) VALUES ('not-valid-json{');",
2233            &mut db,
2234        )
2235        .unwrap_err();
2236        let msg = format!("{err}").to_lowercase();
2237        assert!(
2238            msg.contains("json") && msg.contains("payload"),
2239            "expected JSON validation error mentioning column, got: {msg}"
2240        );
2241    }
2242
2243    #[test]
2244    fn json_extract_object_field() {
2245        let mut db = seed_json_table();
2246        process_command(
2247            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2248            &mut db,
2249        )
2250        .unwrap();
2251        // We don't have function calls in projection (yet), so test
2252        // the function via WHERE.
2253        let resp = process_command(
2254            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
2255            &mut db,
2256        )
2257        .expect("select via json_extract");
2258        assert!(resp.contains("1 row returned"), "got: {resp}");
2259
2260        let resp = process_command(
2261            r#"SELECT id FROM docs WHERE json_extract(payload, '$.age') = 30;"#,
2262            &mut db,
2263        )
2264        .expect("select via numeric json_extract");
2265        assert!(resp.contains("1 row returned"), "got: {resp}");
2266    }
2267
2268    #[test]
2269    fn json_extract_array_index_and_nested() {
2270        let mut db = seed_json_table();
2271        process_command(
2272            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["rust", "sql", "vectors"], "meta": {"author": "joao"}}');"#,
2273            &mut db,
2274        )
2275        .unwrap();
2276        let resp = process_command(
2277            r#"SELECT id FROM docs WHERE json_extract(payload, '$.tags[0]') = 'rust';"#,
2278            &mut db,
2279        )
2280        .expect("select via array index");
2281        assert!(resp.contains("1 row returned"), "got: {resp}");
2282
2283        let resp = process_command(
2284            r#"SELECT id FROM docs WHERE json_extract(payload, '$.meta.author') = 'joao';"#,
2285            &mut db,
2286        )
2287        .expect("select via nested object");
2288        assert!(resp.contains("1 row returned"), "got: {resp}");
2289    }
2290
2291    #[test]
2292    fn json_extract_missing_path_returns_null() {
2293        let mut db = seed_json_table();
2294        process_command(
2295            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice"}');"#,
2296            &mut db,
2297        )
2298        .unwrap();
2299        // Missing key under WHERE returns NULL → predicate is false →
2300        // 0 rows returned. (Standard SQL three-valued logic.)
2301        let resp = process_command(
2302            r#"SELECT id FROM docs WHERE json_extract(payload, '$.missing') = 'something';"#,
2303            &mut db,
2304        )
2305        .expect("select with missing path");
2306        assert!(resp.contains("0 rows returned"), "got: {resp}");
2307    }
2308
2309    #[test]
2310    fn json_extract_malformed_path_errors() {
2311        let mut db = seed_json_table();
2312        process_command(
2313            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2314            &mut db,
2315        )
2316        .unwrap();
2317        // Path doesn't start with '$' — syntax error.
2318        let err = process_command(
2319            r#"SELECT id FROM docs WHERE json_extract(payload, 'a.b') = 1;"#,
2320            &mut db,
2321        )
2322        .unwrap_err();
2323        assert!(format!("{err}").contains("'$'"));
2324    }
2325
2326    #[test]
2327    fn json_array_length_on_array() {
2328        // Note: json_array_length used in WHERE clause where it can be
2329        // compared; that exercises the function dispatch end-to-end.
2330        let mut db = seed_json_table();
2331        process_command(
2332            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["a", "b", "c"]}');"#,
2333            &mut db,
2334        )
2335        .unwrap();
2336        let resp = process_command(
2337            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 3;"#,
2338            &mut db,
2339        )
2340        .expect("select via array_length");
2341        assert!(resp.contains("1 row returned"), "got: {resp}");
2342    }
2343
2344    #[test]
2345    fn json_array_length_on_non_array_errors() {
2346        let mut db = seed_json_table();
2347        process_command(
2348            r#"INSERT INTO docs (payload) VALUES ('{"tags": "not-an-array"}');"#,
2349            &mut db,
2350        )
2351        .unwrap();
2352        let err = process_command(
2353            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 1;"#,
2354            &mut db,
2355        )
2356        .unwrap_err();
2357        let msg = format!("{err}").to_lowercase();
2358        assert!(
2359            msg.contains("non-array"),
2360            "expected non-array error, got: {msg}"
2361        );
2362    }
2363
2364    #[test]
2365    fn json_type_recognizes_each_kind() {
2366        let mut db = seed_json_table();
2367        process_command(
2368            r#"INSERT INTO docs (payload) VALUES ('{"o": {}, "a": [], "s": "x", "i": 1, "f": 1.5, "t": true, "n": null}');"#,
2369            &mut db,
2370        )
2371        .unwrap();
2372        let cases = &[
2373            ("$.o", "object"),
2374            ("$.a", "array"),
2375            ("$.s", "text"),
2376            ("$.i", "integer"),
2377            ("$.f", "real"),
2378            ("$.t", "true"),
2379            ("$.n", "null"),
2380        ];
2381        for (path, expected_type) in cases {
2382            let sql = format!(
2383                "SELECT id FROM docs WHERE json_type(payload, '{path}') = '{expected_type}';"
2384            );
2385            let resp =
2386                process_command(&sql, &mut db).unwrap_or_else(|e| panic!("path {path}: {e}"));
2387            assert!(
2388                resp.contains("1 row returned"),
2389                "path {path} expected type {expected_type}; got response: {resp}"
2390            );
2391        }
2392    }
2393
2394    #[test]
2395    fn update_on_json_column_revalidates() {
2396        let mut db = seed_json_table();
2397        process_command(
2398            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2399            &mut db,
2400        )
2401        .unwrap();
2402        // Valid JSON update succeeds.
2403        process_command(
2404            r#"UPDATE docs SET payload = '{"a": 2, "b": 3}' WHERE id = 1;"#,
2405            &mut db,
2406        )
2407        .expect("valid JSON UPDATE");
2408        // Invalid JSON in UPDATE is rejected with the same shape of
2409        // error as INSERT.
2410        let err = process_command(
2411            r#"UPDATE docs SET payload = 'not-json{' WHERE id = 1;"#,
2412            &mut db,
2413        )
2414        .unwrap_err();
2415        let msg = format!("{err}").to_lowercase();
2416        assert!(
2417            msg.contains("json") && msg.contains("payload"),
2418            "got: {msg}"
2419        );
2420    }
2421
2422    // -------------------------------------------------------------------
2423    // DEFAULT clause on CREATE TABLE columns
2424    // -------------------------------------------------------------------
2425
2426    #[test]
2427    fn default_literal_int_applies_when_column_omitted() {
2428        let mut db = Database::new("t".to_string());
2429        process_command(
2430            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 42);",
2431            &mut db,
2432        )
2433        .unwrap();
2434        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
2435
2436        let table = db.get_table("t".to_string()).unwrap();
2437        assert_eq!(table.get_value("n", 1), Some(Value::Integer(42)));
2438    }
2439
2440    #[test]
2441    fn default_literal_text_applies_when_column_omitted() {
2442        let mut db = Database::new("t".to_string());
2443        process_command(
2444            "CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active');",
2445            &mut db,
2446        )
2447        .unwrap();
2448        process_command("INSERT INTO users (id) VALUES (1);", &mut db).unwrap();
2449
2450        let table = db.get_table("users".to_string()).unwrap();
2451        assert_eq!(
2452            table.get_value("status", 1),
2453            Some(Value::Text("active".to_string()))
2454        );
2455    }
2456
2457    #[test]
2458    fn default_literal_real_negative_applies_when_column_omitted() {
2459        // `DEFAULT -1.5` arrives as a UnaryOp(Minus, Number) — exercise that path.
2460        let mut db = Database::new("t".to_string());
2461        process_command(
2462            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL DEFAULT -1.5);",
2463            &mut db,
2464        )
2465        .unwrap();
2466        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
2467
2468        let table = db.get_table("t".to_string()).unwrap();
2469        assert_eq!(table.get_value("score", 1), Some(Value::Real(-1.5)));
2470    }
2471
2472    #[test]
2473    fn default_with_type_mismatch_errors_at_create_time() {
2474        let mut db = Database::new("t".to_string());
2475        let result = process_command(
2476            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 'oops');",
2477            &mut db,
2478        );
2479        let err = result.expect_err("text default on INTEGER column should be rejected");
2480        let msg = format!("{err}").to_lowercase();
2481        assert!(msg.contains("default"), "got: {msg}");
2482    }
2483
2484    #[test]
2485    fn default_for_json_column_must_be_valid_json() {
2486        // ADD COLUMN ... JSON DEFAULT 'not-json' would otherwise backfill
2487        // every existing row with invalid JSON (insert_row's validation
2488        // is bypassed for the backfill path).
2489        let mut db = Database::new("t".to_string());
2490        let err = process_command(
2491            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON DEFAULT 'not-json{');",
2492            &mut db,
2493        )
2494        .unwrap_err();
2495        assert!(
2496            format!("{err}").to_lowercase().contains("json"),
2497            "got: {err}"
2498        );
2499
2500        // Valid JSON DEFAULT works.
2501        process_command(
2502            "CREATE TABLE t2 (id INTEGER PRIMARY KEY, doc JSON DEFAULT '{\"k\":1}');",
2503            &mut db,
2504        )
2505        .expect("valid JSON DEFAULT should be accepted");
2506    }
2507
2508    #[test]
2509    fn default_with_non_literal_expression_errors_at_create_time() {
2510        let mut db = Database::new("t".to_string());
2511        // Function-call DEFAULT (e.g. CURRENT_TIMESTAMP) → rejected; we only
2512        // accept literal expressions for now.
2513        let result = process_command(
2514            "CREATE TABLE t (id INTEGER PRIMARY KEY, ts TEXT DEFAULT CURRENT_TIMESTAMP);",
2515            &mut db,
2516        );
2517        let err = result.expect_err("non-literal DEFAULT should be rejected");
2518        let msg = format!("{err}").to_lowercase();
2519        assert!(
2520            msg.contains("default") && msg.contains("literal"),
2521            "got: {msg}"
2522        );
2523    }
2524
2525    #[test]
2526    fn default_null_is_accepted_at_create_time() {
2527        // `DEFAULT NULL` is a no-op equivalent to no DEFAULT clause; the
2528        // important thing is that CREATE TABLE accepts it without error
2529        // (some DDL exporters emit `DEFAULT NULL` redundantly).
2530        let mut db = Database::new("t".to_string());
2531        process_command(
2532            "CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT DEFAULT NULL);",
2533            &mut db,
2534        )
2535        .expect("CREATE TABLE with DEFAULT NULL should be accepted");
2536        let table = db.get_table("t".to_string()).unwrap();
2537        let note = table
2538            .columns
2539            .iter()
2540            .find(|c| c.column_name == "note")
2541            .unwrap();
2542        assert_eq!(note.default, Some(Value::Null));
2543    }
2544
2545    // -------------------------------------------------------------------
2546    // DROP TABLE / DROP INDEX
2547    // -------------------------------------------------------------------
2548
2549    #[test]
2550    fn drop_table_basic() {
2551        let mut db = seed_users_table();
2552        let response = process_command("DROP TABLE users;", &mut db).expect("drop table");
2553        assert!(response.contains("1 table dropped"));
2554        assert!(!db.contains_table("users".to_string()));
2555    }
2556
2557    #[test]
2558    fn drop_table_if_exists_noop_on_missing() {
2559        let mut db = Database::new("t".to_string());
2560        let response =
2561            process_command("DROP TABLE IF EXISTS missing;", &mut db).expect("drop if exists");
2562        assert!(response.contains("0 tables dropped"));
2563    }
2564
2565    #[test]
2566    fn drop_table_missing_errors_without_if_exists() {
2567        let mut db = Database::new("t".to_string());
2568        let err = process_command("DROP TABLE missing;", &mut db).unwrap_err();
2569        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2570    }
2571
2572    #[test]
2573    fn drop_table_reserved_name_errors() {
2574        let mut db = Database::new("t".to_string());
2575        let err = process_command("DROP TABLE sqlrite_master;", &mut db).unwrap_err();
2576        assert!(format!("{err}").contains("reserved"), "got: {err}");
2577    }
2578
2579    #[test]
2580    fn drop_table_multi_target_rejected() {
2581        let mut db = seed_users_table();
2582        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2583        // sqlparser accepts `DROP TABLE a, b` as one statement; we reject
2584        // to keep error semantics simple (no partial-failure rollback).
2585        let err = process_command("DROP TABLE users, other;", &mut db).unwrap_err();
2586        assert!(format!("{err}").contains("single table"), "got: {err}");
2587    }
2588
2589    #[test]
2590    fn drop_table_cascades_indexes_in_memory() {
2591        let mut db = seed_users_table();
2592        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2593        // PK auto-index + UNIQUE-on-name auto-index + the explicit one.
2594        let users = db.get_table("users".to_string()).unwrap();
2595        assert!(
2596            users
2597                .secondary_indexes
2598                .iter()
2599                .any(|i| i.name == "users_age_idx")
2600        );
2601
2602        process_command("DROP TABLE users;", &mut db).unwrap();
2603
2604        // After DROP TABLE, no other table should claim the dropped indexes.
2605        for table in db.tables.values() {
2606            assert!(
2607                !table
2608                    .secondary_indexes
2609                    .iter()
2610                    .any(|i| i.name.contains("users")),
2611                "dropped table's indexes should not survive on any other table"
2612            );
2613        }
2614    }
2615
2616    #[test]
2617    fn drop_index_explicit_basic() {
2618        let mut db = seed_users_table();
2619        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2620        let response = process_command("DROP INDEX users_age_idx;", &mut db).expect("drop index");
2621        assert!(response.contains("1 index dropped"));
2622
2623        let users = db.get_table("users".to_string()).unwrap();
2624        assert!(users.index_by_name("users_age_idx").is_none());
2625    }
2626
2627    #[test]
2628    fn drop_index_refuses_auto_index() {
2629        let mut db = seed_users_table();
2630        // `users` was created with `id INTEGER PRIMARY KEY` → auto-index
2631        // named `sqlrite_autoindex_users_id`.
2632        let err = process_command("DROP INDEX sqlrite_autoindex_users_id;", &mut db).unwrap_err();
2633        assert!(format!("{err}").contains("auto-created"), "got: {err}");
2634    }
2635
2636    #[test]
2637    fn drop_index_if_exists_noop_on_missing() {
2638        let mut db = Database::new("t".to_string());
2639        let response =
2640            process_command("DROP INDEX IF EXISTS nope;", &mut db).expect("drop index if exists");
2641        assert!(response.contains("0 indexes dropped"));
2642    }
2643
2644    #[test]
2645    fn drop_index_missing_errors_without_if_exists() {
2646        let mut db = Database::new("t".to_string());
2647        let err = process_command("DROP INDEX nope;", &mut db).unwrap_err();
2648        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2649    }
2650
2651    #[test]
2652    fn drop_statements_rejected_on_readonly_db() {
2653        use crate::sql::pager::{open_database_read_only, save_database};
2654
2655        let mut seed = Database::new("t".to_string());
2656        process_command(
2657            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2658            &mut seed,
2659        )
2660        .unwrap();
2661        process_command("CREATE INDEX notes_body ON notes (body);", &mut seed).unwrap();
2662        let path = {
2663            let mut p = std::env::temp_dir();
2664            let pid = std::process::id();
2665            let nanos = std::time::SystemTime::now()
2666                .duration_since(std::time::UNIX_EPOCH)
2667                .map(|d| d.as_nanos())
2668                .unwrap_or(0);
2669            p.push(format!("sqlrite-drop-ro-{pid}-{nanos}.sqlrite"));
2670            p
2671        };
2672        save_database(&mut seed, &path).unwrap();
2673        drop(seed);
2674
2675        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
2676        for stmt in ["DROP TABLE notes;", "DROP INDEX notes_body;"] {
2677            let err = process_command(stmt, &mut ro).unwrap_err();
2678            assert!(
2679                format!("{err}").contains("read-only"),
2680                "{stmt:?} should surface read-only error, got: {err}"
2681            );
2682        }
2683
2684        let _ = std::fs::remove_file(&path);
2685        let mut wal = path.as_os_str().to_owned();
2686        wal.push("-wal");
2687        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
2688    }
2689
2690    // -------------------------------------------------------------------
2691    // ALTER TABLE — RENAME TO / RENAME COLUMN / ADD COLUMN / DROP COLUMN
2692    // -------------------------------------------------------------------
2693
2694    #[test]
2695    fn alter_rename_table_basic() {
2696        let mut db = seed_users_table();
2697        process_command("ALTER TABLE users RENAME TO members;", &mut db).expect("rename table");
2698        assert!(!db.contains_table("users".to_string()));
2699        assert!(db.contains_table("members".to_string()));
2700        // Data still queryable under the new name.
2701        let response = process_command("SELECT * FROM members;", &mut db).expect("select");
2702        assert!(response.contains("3 rows returned"));
2703    }
2704
2705    #[test]
2706    fn alter_rename_table_renames_auto_indexes() {
2707        // Use a fresh table with both PK and a UNIQUE column so we
2708        // exercise both auto-index renames in one shot.
2709        let mut db = Database::new("t".to_string());
2710        process_command(
2711            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2712            &mut db,
2713        )
2714        .unwrap();
2715        {
2716            let accounts = db.get_table("accounts".to_string()).unwrap();
2717            assert!(
2718                accounts
2719                    .index_by_name("sqlrite_autoindex_accounts_id")
2720                    .is_some()
2721            );
2722            assert!(
2723                accounts
2724                    .index_by_name("sqlrite_autoindex_accounts_email")
2725                    .is_some()
2726            );
2727        }
2728        process_command("ALTER TABLE accounts RENAME TO members;", &mut db).expect("rename");
2729        let members = db.get_table("members".to_string()).unwrap();
2730        assert!(
2731            members
2732                .index_by_name("sqlrite_autoindex_members_id")
2733                .is_some(),
2734            "PK auto-index should be renamed to match new table"
2735        );
2736        assert!(
2737            members
2738                .index_by_name("sqlrite_autoindex_members_email")
2739                .is_some()
2740        );
2741        // The old-named auto-indexes should be gone.
2742        assert!(
2743            members
2744                .index_by_name("sqlrite_autoindex_accounts_id")
2745                .is_none()
2746        );
2747        // table_name field on each index should also reflect the rename.
2748        for idx in &members.secondary_indexes {
2749            assert_eq!(idx.table_name, "members");
2750        }
2751    }
2752
2753    #[test]
2754    fn alter_rename_table_to_existing_errors() {
2755        let mut db = seed_users_table();
2756        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2757        let err = process_command("ALTER TABLE users RENAME TO other;", &mut db).unwrap_err();
2758        assert!(format!("{err}").contains("already exists"), "got: {err}");
2759        // Both tables still present.
2760        assert!(db.contains_table("users".to_string()));
2761        assert!(db.contains_table("other".to_string()));
2762    }
2763
2764    #[test]
2765    fn alter_rename_table_to_reserved_name_errors() {
2766        let mut db = seed_users_table();
2767        let err =
2768            process_command("ALTER TABLE users RENAME TO sqlrite_master;", &mut db).unwrap_err();
2769        assert!(format!("{err}").contains("reserved"), "got: {err}");
2770    }
2771
2772    #[test]
2773    fn alter_rename_column_basic() {
2774        let mut db = seed_users_table();
2775        process_command(
2776            "ALTER TABLE users RENAME COLUMN name TO full_name;",
2777            &mut db,
2778        )
2779        .expect("rename column");
2780
2781        let users = db.get_table("users".to_string()).unwrap();
2782        assert!(users.contains_column("full_name".to_string()));
2783        assert!(!users.contains_column("name".to_string()));
2784
2785        // Existing data is queryable under the new column name and value
2786        // is preserved at the same rowid.
2787        let bob_rowid = users
2788            .rowids()
2789            .into_iter()
2790            .find(|r| users.get_value("full_name", *r) == Some(Value::Text("bob".to_string())))
2791            .expect("bob row should be findable under the new column name");
2792        assert_eq!(
2793            users.get_value("full_name", bob_rowid),
2794            Some(Value::Text("bob".to_string()))
2795        );
2796    }
2797
2798    #[test]
2799    fn alter_rename_column_collision_errors() {
2800        let mut db = seed_users_table();
2801        let err =
2802            process_command("ALTER TABLE users RENAME COLUMN name TO age;", &mut db).unwrap_err();
2803        assert!(format!("{err}").contains("already exists"), "got: {err}");
2804    }
2805
2806    #[test]
2807    fn alter_rename_column_updates_indexes() {
2808        // `accounts.email` is UNIQUE → has a renameable auto-index.
2809        let mut db = Database::new("t".to_string());
2810        process_command(
2811            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2812            &mut db,
2813        )
2814        .unwrap();
2815        process_command(
2816            "ALTER TABLE accounts RENAME COLUMN email TO contact;",
2817            &mut db,
2818        )
2819        .unwrap();
2820        let accounts = db.get_table("accounts".to_string()).unwrap();
2821        assert!(
2822            accounts
2823                .index_by_name("sqlrite_autoindex_accounts_contact")
2824                .is_some()
2825        );
2826        assert!(
2827            accounts
2828                .index_by_name("sqlrite_autoindex_accounts_email")
2829                .is_none()
2830        );
2831    }
2832
2833    #[test]
2834    fn alter_add_column_basic() {
2835        let mut db = seed_users_table();
2836        process_command("ALTER TABLE users ADD COLUMN nickname TEXT;", &mut db)
2837            .expect("add column");
2838        let users = db.get_table("users".to_string()).unwrap();
2839        assert!(users.contains_column("nickname".to_string()));
2840        // Existing rows read NULL for the new column (no default given).
2841        let any_rowid = *users.rowids().first().expect("seed has rows");
2842        assert_eq!(users.get_value("nickname", any_rowid), None);
2843
2844        // A new INSERT supplying the new column works.
2845        process_command(
2846            "INSERT INTO users (name, age, nickname) VALUES ('dan', 22, 'd');",
2847            &mut db,
2848        )
2849        .expect("insert with new col");
2850        let users = db.get_table("users".to_string()).unwrap();
2851        let dan_rowid = users
2852            .rowids()
2853            .into_iter()
2854            .find(|r| users.get_value("name", *r) == Some(Value::Text("dan".to_string())))
2855            .unwrap();
2856        assert_eq!(
2857            users.get_value("nickname", dan_rowid),
2858            Some(Value::Text("d".to_string()))
2859        );
2860    }
2861
2862    #[test]
2863    fn alter_add_column_with_default_backfills_existing_rows() {
2864        let mut db = seed_users_table();
2865        process_command(
2866            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
2867            &mut db,
2868        )
2869        .expect("add column with default");
2870        let users = db.get_table("users".to_string()).unwrap();
2871        for rowid in users.rowids() {
2872            assert_eq!(
2873                users.get_value("status", rowid),
2874                Some(Value::Text("active".to_string())),
2875                "rowid {rowid} should have been backfilled with the default"
2876            );
2877        }
2878    }
2879
2880    #[test]
2881    fn alter_add_column_not_null_with_default_works_on_nonempty_table() {
2882        let mut db = seed_users_table();
2883        process_command(
2884            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT 0;",
2885            &mut db,
2886        )
2887        .expect("NOT NULL ADD with DEFAULT should succeed even with existing rows");
2888        let users = db.get_table("users".to_string()).unwrap();
2889        for rowid in users.rowids() {
2890            assert_eq!(users.get_value("score", rowid), Some(Value::Integer(0)));
2891        }
2892    }
2893
2894    #[test]
2895    fn alter_add_column_not_null_without_default_errors_on_nonempty_table() {
2896        let mut db = seed_users_table();
2897        let err = process_command(
2898            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL;",
2899            &mut db,
2900        )
2901        .unwrap_err();
2902        let msg = format!("{err}").to_lowercase();
2903        assert!(
2904            msg.contains("not null") && msg.contains("default"),
2905            "got: {msg}"
2906        );
2907    }
2908
2909    #[test]
2910    fn alter_add_column_pk_rejected() {
2911        let mut db = seed_users_table();
2912        let err = process_command(
2913            "ALTER TABLE users ADD COLUMN extra INTEGER PRIMARY KEY;",
2914            &mut db,
2915        )
2916        .unwrap_err();
2917        assert!(
2918            format!("{err}").to_lowercase().contains("primary key"),
2919            "got: {err}"
2920        );
2921    }
2922
2923    #[test]
2924    fn alter_add_column_unique_rejected() {
2925        let mut db = seed_users_table();
2926        let err = process_command(
2927            "ALTER TABLE users ADD COLUMN extra INTEGER UNIQUE;",
2928            &mut db,
2929        )
2930        .unwrap_err();
2931        assert!(
2932            format!("{err}").to_lowercase().contains("unique"),
2933            "got: {err}"
2934        );
2935    }
2936
2937    #[test]
2938    fn alter_add_column_existing_name_errors() {
2939        let mut db = seed_users_table();
2940        let err =
2941            process_command("ALTER TABLE users ADD COLUMN age INTEGER;", &mut db).unwrap_err();
2942        assert!(format!("{err}").contains("already exists"), "got: {err}");
2943    }
2944
2945    // Note: `ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...` is not in the
2946    // SQLite dialect (PG/MSSQL extension); the AST `if_not_exists` flag is
2947    // still honoured by the executor if some other dialect ever produces
2948    // it, but there's no way to feed it via SQL in our default dialect.
2949
2950    #[test]
2951    fn alter_drop_column_basic() {
2952        let mut db = seed_users_table();
2953        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).expect("drop column");
2954        let users = db.get_table("users".to_string()).unwrap();
2955        assert!(!users.contains_column("age".to_string()));
2956        // Other columns and rowids still intact.
2957        assert!(users.contains_column("name".to_string()));
2958        assert_eq!(users.rowids().len(), 3);
2959    }
2960
2961    #[test]
2962    fn alter_drop_column_drops_dependent_indexes() {
2963        let mut db = seed_users_table();
2964        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2965        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).unwrap();
2966        let users = db.get_table("users".to_string()).unwrap();
2967        assert!(users.index_by_name("users_age_idx").is_none());
2968    }
2969
2970    #[test]
2971    fn alter_drop_column_pk_errors() {
2972        let mut db = seed_users_table();
2973        let err = process_command("ALTER TABLE users DROP COLUMN id;", &mut db).unwrap_err();
2974        assert!(
2975            format!("{err}").to_lowercase().contains("primary key"),
2976            "got: {err}"
2977        );
2978    }
2979
2980    #[test]
2981    fn alter_drop_column_only_column_errors() {
2982        let mut db = Database::new("t".to_string());
2983        process_command("CREATE TABLE solo (only_col TEXT);", &mut db).unwrap();
2984        let err = process_command("ALTER TABLE solo DROP COLUMN only_col;", &mut db).unwrap_err();
2985        assert!(
2986            format!("{err}").to_lowercase().contains("only column"),
2987            "got: {err}"
2988        );
2989    }
2990
2991    #[test]
2992    fn alter_unknown_table_errors_without_if_exists() {
2993        let mut db = Database::new("t".to_string());
2994        let err = process_command("ALTER TABLE missing RENAME TO other;", &mut db).unwrap_err();
2995        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2996    }
2997
2998    #[test]
2999    fn alter_unknown_table_if_exists_noop() {
3000        let mut db = Database::new("t".to_string());
3001        let response = process_command("ALTER TABLE IF EXISTS missing RENAME TO other;", &mut db)
3002            .expect("IF EXISTS makes missing-table ALTER a no-op");
3003        assert!(response.contains("no-op"));
3004    }
3005
3006    #[test]
3007    fn drop_table_inside_transaction_rolls_back() {
3008        // Exercises Database::deep_clone snapshot path with DROP TABLE.
3009        // A wholesale tables-HashMap restore on ROLLBACK should resurrect
3010        // the dropped table — including its data and dependent indexes.
3011        let mut db = seed_users_table();
3012        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
3013        process_command("BEGIN;", &mut db).unwrap();
3014        process_command("DROP TABLE users;", &mut db).unwrap();
3015        assert!(!db.contains_table("users".to_string()));
3016        process_command("ROLLBACK;", &mut db).unwrap();
3017        assert!(db.contains_table("users".to_string()));
3018        let users = db.get_table("users".to_string()).unwrap();
3019        assert_eq!(users.rowids().len(), 3);
3020        assert!(users.index_by_name("users_age_idx").is_some());
3021    }
3022
3023    #[test]
3024    fn alter_inside_transaction_rolls_back() {
3025        let mut db = seed_users_table();
3026        process_command("BEGIN;", &mut db).unwrap();
3027        process_command(
3028            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
3029            &mut db,
3030        )
3031        .unwrap();
3032        // Confirm in-flight visibility.
3033        assert!(
3034            db.get_table("users".to_string())
3035                .unwrap()
3036                .contains_column("status".to_string())
3037        );
3038        process_command("ROLLBACK;", &mut db).unwrap();
3039        // Snapshot restore should erase the ALTER.
3040        assert!(
3041            !db.get_table("users".to_string())
3042                .unwrap()
3043                .contains_column("status".to_string())
3044        );
3045    }
3046
3047    #[test]
3048    fn alter_rejected_on_readonly_db() {
3049        use crate::sql::pager::{open_database_read_only, save_database};
3050
3051        let mut seed = Database::new("t".to_string());
3052        process_command(
3053            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3054            &mut seed,
3055        )
3056        .unwrap();
3057        let path = {
3058            let mut p = std::env::temp_dir();
3059            let pid = std::process::id();
3060            let nanos = std::time::SystemTime::now()
3061                .duration_since(std::time::UNIX_EPOCH)
3062                .map(|d| d.as_nanos())
3063                .unwrap_or(0);
3064            p.push(format!("sqlrite-alter-ro-{pid}-{nanos}.sqlrite"));
3065            p
3066        };
3067        save_database(&mut seed, &path).unwrap();
3068        drop(seed);
3069
3070        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
3071        for stmt in [
3072            "ALTER TABLE notes RENAME TO n2;",
3073            "ALTER TABLE notes RENAME COLUMN body TO b;",
3074            "ALTER TABLE notes ADD COLUMN extra TEXT;",
3075            "ALTER TABLE notes DROP COLUMN body;",
3076        ] {
3077            let err = process_command(stmt, &mut ro).unwrap_err();
3078            assert!(
3079                format!("{err}").contains("read-only"),
3080                "{stmt:?} should surface read-only error, got: {err}"
3081            );
3082        }
3083
3084        let _ = std::fs::remove_file(&path);
3085        let mut wal = path.as_os_str().to_owned();
3086        wal.push("-wal");
3087        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
3088    }
3089}