Skip to main content

sqlrite/sql/
mod.rs

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