Skip to main content

sqlrite/sql/
mod.rs

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