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