Skip to main content

sqlrite/sql/
mod.rs

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