Skip to main content

sqlrite/sql/
mod.rs

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