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