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