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::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    );
171
172    // Early-reject mutations on a read-only database before they touch
173    // in-memory state. Phase 4e: without this, a user running INSERT
174    // on a `--readonly` REPL would see the row appear in the printed
175    // table, and then the auto-save would fail — leaving the in-memory
176    // Database visibly diverged from disk.
177    if is_write_statement && db.is_read_only() {
178        return Err(SQLRiteError::General(
179            "cannot execute: database is opened read-only".to_string(),
180        ));
181    }
182
183    // Initialy only implementing some basic SQL Statements
184    match query {
185        Statement::CreateTable(_) => {
186            let create_query = CreateQuery::new(&query);
187            match create_query {
188                Ok(payload) => {
189                    let table_name = payload.table_name.clone();
190                    if table_name == pager::MASTER_TABLE_NAME {
191                        return Err(SQLRiteError::General(format!(
192                            "'{}' is a reserved name used by the internal schema catalog",
193                            pager::MASTER_TABLE_NAME
194                        )));
195                    }
196                    // Checking if table already exists, after parsing CREATE TABLE query
197                    match db.contains_table(table_name.to_string()) {
198                        true => {
199                            return Err(SQLRiteError::Internal(
200                                "Cannot create, table already exists.".to_string(),
201                            ));
202                        }
203                        false => {
204                            let table = Table::new(payload);
205                            // Note: we used to call `table.print_table_schema()` here
206                            // for REPL convenience. Removed because it wrote
207                            // directly to stdout, which corrupted any non-REPL
208                            // protocol channel (most painfully the MCP server's
209                            // JSON-RPC wire). The status line below is enough for
210                            // the REPL; users who want to inspect the schema can
211                            // run a follow-up describe / `.tables`-style command.
212                            db.tables.insert(table_name.to_string(), table);
213                            message = String::from("CREATE TABLE Statement executed.");
214                        }
215                    }
216                }
217                Err(err) => return Err(err),
218            }
219        }
220        Statement::Insert(_) => {
221            let insert_query = InsertQuery::new(&query);
222            match insert_query {
223                Ok(payload) => {
224                    let table_name = payload.table_name;
225                    let columns = payload.columns;
226                    let values = payload.rows;
227
228                    // println!("table_name = {:?}\n cols = {:?}\n vals = {:?}", table_name, columns, values);
229                    // Checking if Table exists in Database
230                    match db.contains_table(table_name.to_string()) {
231                        true => {
232                            let db_table = db.get_table_mut(table_name.to_string()).unwrap();
233                            // Checking if columns on INSERT query exist on Table
234                            match columns
235                                .iter()
236                                .all(|column| db_table.contains_column(column.to_string()))
237                            {
238                                true => {
239                                    for value in &values {
240                                        // Checking if number of columns in query are the same as number of values
241                                        if columns.len() != value.len() {
242                                            return Err(SQLRiteError::Internal(format!(
243                                                "{} values for {} columns",
244                                                value.len(),
245                                                columns.len()
246                                            )));
247                                        }
248                                        db_table
249                                            .validate_unique_constraint(&columns, value)
250                                            .map_err(|err| {
251                                                SQLRiteError::Internal(format!(
252                                                    "Unique key constraint violation: {err}"
253                                                ))
254                                            })?;
255                                        db_table.insert_row(&columns, value)?;
256                                    }
257                                }
258                                false => {
259                                    return Err(SQLRiteError::Internal(
260                                        "Cannot insert, some of the columns do not exist"
261                                            .to_string(),
262                                    ));
263                                }
264                            }
265                            // Note: we used to call `db_table.print_table_data()`
266                            // here, which dumped the *entire* table to stdout
267                            // after every INSERT. Beyond corrupting non-REPL
268                            // stdout channels, that's actively bad UX on any
269                            // table with more than a few rows. Removed in the
270                            // engine-stdout-pollution cleanup.
271                        }
272                        false => {
273                            return Err(SQLRiteError::Internal("Table doesn't exist".to_string()));
274                        }
275                    }
276                }
277                Err(err) => return Err(err),
278            }
279
280            message = String::from("INSERT Statement executed.")
281        }
282        Statement::Query(_) => {
283            let select_query = SelectQuery::new(&query)?;
284            let (rendered_table, rows) = executor::execute_select(select_query, db)?;
285            // Stash the rendered prettytable in the output so the REPL
286            // (or any terminal-style consumer) can print it above the
287            // status line. SDK / FFI / MCP callers ignore this field.
288            // The previous implementation `print!("{rendered}")`-ed
289            // directly to stdout, which broke every non-REPL embedder.
290            rendered = Some(rendered_table);
291            message = format!(
292                "SELECT Statement executed. {rows} row{s} returned.",
293                s = if rows == 1 { "" } else { "s" }
294            );
295        }
296        Statement::Delete(_) => {
297            let rows = executor::execute_delete(&query, db)?;
298            message = format!(
299                "DELETE Statement executed. {rows} row{s} deleted.",
300                s = if rows == 1 { "" } else { "s" }
301            );
302        }
303        Statement::Update(_) => {
304            let rows = executor::execute_update(&query, db)?;
305            message = format!(
306                "UPDATE Statement executed. {rows} row{s} updated.",
307                s = if rows == 1 { "" } else { "s" }
308            );
309        }
310        Statement::CreateIndex(_) => {
311            let name = executor::execute_create_index(&query, db)?;
312            message = format!("CREATE INDEX '{name}' executed.");
313        }
314        _ => {
315            return Err(SQLRiteError::NotImplemented(
316                "SQL Statement not supported yet.".to_string(),
317            ));
318        }
319    };
320
321    // Auto-save: if the database is backed by a file AND no explicit
322    // transaction is open AND the statement changed state, flush to
323    // disk before returning. Inside a `BEGIN … COMMIT` block the
324    // mutations accumulate in memory (protected by the ROLLBACK
325    // snapshot) and land on disk in one shot when COMMIT runs.
326    //
327    // A failed save surfaces as an error — the in-memory state already
328    // mutated, so the caller should know disk is out of sync. The
329    // Pager held on `db` diffs against its last-committed snapshot,
330    // so only pages whose bytes actually changed are written.
331    if is_write_statement && db.source_path.is_some() && !db.in_transaction() {
332        let path = db.source_path.clone().unwrap();
333        pager::save_database(db, &path)?;
334    }
335
336    Ok(CommandOutput {
337        status: message,
338        rendered,
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::sql::db::table::Value;
346
347    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
348    /// with three rows, for use in executor-level tests.
349    fn seed_users_table() -> Database {
350        let mut db = Database::new("tempdb".to_string());
351        process_command(
352            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
353            &mut db,
354        )
355        .expect("create table");
356        process_command(
357            "INSERT INTO users (name, age) VALUES ('alice', 30);",
358            &mut db,
359        )
360        .expect("insert alice");
361        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
362            .expect("insert bob");
363        process_command(
364            "INSERT INTO users (name, age) VALUES ('carol', 40);",
365            &mut db,
366        )
367        .expect("insert carol");
368        db
369    }
370
371    #[test]
372    fn process_command_select_all_test() {
373        let mut db = seed_users_table();
374        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
375        assert!(response.contains("3 rows returned"));
376    }
377
378    #[test]
379    fn process_command_select_where_test() {
380        let mut db = seed_users_table();
381        let response =
382            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
383        assert!(response.contains("2 rows returned"));
384    }
385
386    #[test]
387    fn process_command_select_eq_string_test() {
388        let mut db = seed_users_table();
389        let response =
390            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
391        assert!(response.contains("1 row returned"));
392    }
393
394    #[test]
395    fn process_command_select_limit_test() {
396        let mut db = seed_users_table();
397        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
398            .expect("select");
399        assert!(response.contains("2 rows returned"));
400    }
401
402    #[test]
403    fn process_command_select_unknown_table_test() {
404        let mut db = Database::new("tempdb".to_string());
405        let result = process_command("SELECT * FROM nope;", &mut db);
406        assert!(result.is_err());
407    }
408
409    #[test]
410    fn process_command_select_unknown_column_test() {
411        let mut db = seed_users_table();
412        let result = process_command("SELECT height FROM users;", &mut db);
413        assert!(result.is_err());
414    }
415
416    #[test]
417    fn process_command_insert_test() {
418        // Creating temporary database
419        let mut db = Database::new("tempdb".to_string());
420
421        // Creating temporary table for testing purposes
422        let query_statement = "CREATE TABLE users (
423            id INTEGER PRIMARY KEY,
424            name TEXT
425        );";
426        let dialect = SQLiteDialect {};
427        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
428        if ast.len() > 1 {
429            panic!("Expected a single query statement, but there are more then 1.")
430        }
431        let query = ast.pop().unwrap();
432        let create_query = CreateQuery::new(&query).unwrap();
433
434        // Inserting table into database
435        db.tables.insert(
436            create_query.table_name.to_string(),
437            Table::new(create_query),
438        );
439
440        // Inserting data into table
441        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
442        match process_command(&insert_query, &mut db) {
443            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
444            Err(err) => {
445                eprintln!("Error: {}", err);
446                assert!(false)
447            }
448        };
449    }
450
451    #[test]
452    fn process_command_insert_no_pk_test() {
453        // Creating temporary database
454        let mut db = Database::new("tempdb".to_string());
455
456        // Creating temporary table for testing purposes
457        let query_statement = "CREATE TABLE users (
458            name TEXT
459        );";
460        let dialect = SQLiteDialect {};
461        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
462        if ast.len() > 1 {
463            panic!("Expected a single query statement, but there are more then 1.")
464        }
465        let query = ast.pop().unwrap();
466        let create_query = CreateQuery::new(&query).unwrap();
467
468        // Inserting table into database
469        db.tables.insert(
470            create_query.table_name.to_string(),
471            Table::new(create_query),
472        );
473
474        // Inserting data into table
475        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
476        match process_command(&insert_query, &mut db) {
477            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
478            Err(err) => {
479                eprintln!("Error: {}", err);
480                assert!(false)
481            }
482        };
483    }
484
485    #[test]
486    fn process_command_delete_where_test() {
487        let mut db = seed_users_table();
488        let response =
489            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
490        assert!(response.contains("1 row deleted"));
491
492        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
493        assert!(remaining.contains("2 rows returned"));
494    }
495
496    #[test]
497    fn process_command_delete_all_test() {
498        let mut db = seed_users_table();
499        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
500        assert!(response.contains("3 rows deleted"));
501    }
502
503    #[test]
504    fn process_command_update_where_test() {
505        use crate::sql::db::table::Value;
506
507        let mut db = seed_users_table();
508        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
509            .expect("update");
510        assert!(response.contains("1 row updated"));
511
512        // Confirm the cell was actually rewritten.
513        let users = db.get_table("users".to_string()).unwrap();
514        let bob_rowid = users
515            .rowids()
516            .into_iter()
517            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
518            .expect("bob row must exist");
519        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
520    }
521
522    #[test]
523    fn process_command_update_unique_violation_test() {
524        let mut db = seed_users_table();
525        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
526        process_command(
527            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
528            &mut db,
529        )
530        .unwrap();
531        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
532        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
533
534        let result = process_command("UPDATE tags SET label = 'a' WHERE label = 'b';", &mut db);
535        assert!(result.is_err(), "expected UNIQUE violation, got {result:?}");
536    }
537
538    #[test]
539    fn process_command_insert_type_mismatch_returns_error_test() {
540        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
541        let mut db = Database::new("tempdb".to_string());
542        process_command(
543            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
544            &mut db,
545        )
546        .unwrap();
547        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
548        assert!(result.is_err(), "expected error, got {result:?}");
549    }
550
551    #[test]
552    fn process_command_insert_missing_integer_returns_error_test() {
553        // Non-PK INTEGER without a value should error (not panic on "Null".parse()).
554        let mut db = Database::new("tempdb".to_string());
555        process_command(
556            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
557            &mut db,
558        )
559        .unwrap();
560        let result = process_command("INSERT INTO items (id) VALUES (1);", &mut db);
561        assert!(result.is_err(), "expected error, got {result:?}");
562    }
563
564    #[test]
565    fn process_command_update_arith_test() {
566        use crate::sql::db::table::Value;
567
568        let mut db = seed_users_table();
569        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
570
571        let users = db.get_table("users".to_string()).unwrap();
572        let mut ages: Vec<i64> = users
573            .rowids()
574            .into_iter()
575            .filter_map(|r| match users.get_value("age", r) {
576                Some(Value::Integer(n)) => Some(n),
577                _ => None,
578            })
579            .collect();
580        ages.sort();
581        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
582    }
583
584    #[test]
585    fn process_command_select_arithmetic_where_test() {
586        let mut db = seed_users_table();
587        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
588        let response =
589            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
590        assert!(response.contains("2 rows returned"));
591    }
592
593    #[test]
594    fn process_command_divide_by_zero_test() {
595        let mut db = seed_users_table();
596        let result = process_command("SELECT age / 0 FROM users;", &mut db);
597        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
598        assert!(result.is_err());
599    }
600
601    #[test]
602    fn process_command_unsupported_statement_test() {
603        let mut db = Database::new("tempdb".to_string());
604        // Nothing in Phase 1 handles DROP.
605        let result = process_command("DROP TABLE users;", &mut db);
606        assert!(result.is_err());
607    }
608
609    #[test]
610    fn empty_input_is_a_noop_not_a_panic() {
611        // Regression for: desktop app pre-fills the textarea with a
612        // comment-only placeholder, and hitting Run used to panic because
613        // sqlparser produced zero statements and pop().unwrap() exploded.
614        let mut db = Database::new("t".to_string());
615        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
616            let result = process_command(input, &mut db);
617            assert!(result.is_ok(), "input {input:?} should not error");
618            let msg = result.unwrap();
619            assert!(msg.contains("No statement"), "got: {msg:?}");
620        }
621    }
622
623    #[test]
624    fn create_index_adds_explicit_index() {
625        let mut db = seed_users_table();
626        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
627            .expect("create index");
628        assert!(response.contains("users_age_idx"));
629
630        // The index should now be attached to the users table.
631        let users = db.get_table("users".to_string()).unwrap();
632        let idx = users
633            .index_by_name("users_age_idx")
634            .expect("index should exist after CREATE INDEX");
635        assert_eq!(idx.column_name, "age");
636        assert!(!idx.is_unique);
637    }
638
639    #[test]
640    fn create_unique_index_rejects_duplicate_existing_values() {
641        let mut db = seed_users_table();
642        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
643        // first so CREATE UNIQUE INDEX on age catches the conflict.
644        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
645        let result = process_command(
646            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
647            &mut db,
648        );
649        assert!(
650            result.is_err(),
651            "expected unique-index failure, got {result:?}"
652        );
653    }
654
655    #[test]
656    fn where_eq_on_indexed_column_uses_index_probe() {
657        // Build a table big enough that a full scan would be expensive,
658        // then rely on the index-probe fast path. This test verifies
659        // correctness (right rows returned); the perf win is implicit.
660        let mut db = Database::new("t".to_string());
661        process_command(
662            "CREATE TABLE big (id INTEGER PRIMARY KEY, tag TEXT);",
663            &mut db,
664        )
665        .unwrap();
666        process_command("CREATE INDEX big_tag_idx ON big (tag);", &mut db).unwrap();
667        for i in 1..=100 {
668            let tag = if i % 3 == 0 { "hot" } else { "cold" };
669            process_command(&format!("INSERT INTO big (tag) VALUES ('{tag}');"), &mut db).unwrap();
670        }
671        let response =
672            process_command("SELECT id FROM big WHERE tag = 'hot';", &mut db).expect("select");
673        // 1..=100 has 33 multiples of 3.
674        assert!(
675            response.contains("33 rows returned"),
676            "response was {response:?}"
677        );
678    }
679
680    #[test]
681    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
682        let mut db = seed_users_table();
683        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
684            .expect("select");
685        assert!(response.contains("1 row returned"));
686    }
687
688    #[test]
689    fn where_eq_literal_first_side_uses_index_probe() {
690        let mut db = seed_users_table();
691        // `'bob' = name` should hit the same path as `name = 'bob'`.
692        let response =
693            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
694        assert!(response.contains("1 row returned"));
695    }
696
697    #[test]
698    fn non_equality_where_still_falls_back_to_full_scan() {
699        // Sanity: range predicates bypass the optimizer and the full-scan
700        // path still returns correct results.
701        let mut db = seed_users_table();
702        let response =
703            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
704        assert!(response.contains("2 rows returned"));
705    }
706
707    // -------------------------------------------------------------------
708    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
709    // -------------------------------------------------------------------
710
711    #[test]
712    fn rollback_restores_pre_begin_in_memory_state() {
713        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
714        // The row must disappear from the live tables HashMap.
715        let mut db = seed_users_table();
716        let before = db.get_table("users".to_string()).unwrap().rowids().len();
717        assert_eq!(before, 3);
718
719        process_command("BEGIN;", &mut db).expect("BEGIN");
720        assert!(db.in_transaction());
721        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
722            .expect("INSERT inside txn");
723        // Mid-transaction read sees the new row.
724        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
725        assert_eq!(mid, 4);
726
727        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
728        assert!(!db.in_transaction());
729        let after = db.get_table("users".to_string()).unwrap().rowids().len();
730        assert_eq!(
731            after, 3,
732            "ROLLBACK should have restored the pre-BEGIN state"
733        );
734    }
735
736    #[test]
737    fn commit_keeps_mutations_and_clears_txn_flag() {
738        let mut db = seed_users_table();
739        process_command("BEGIN;", &mut db).expect("BEGIN");
740        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
741            .expect("INSERT inside txn");
742        process_command("COMMIT;", &mut db).expect("COMMIT");
743        assert!(!db.in_transaction());
744        let after = db.get_table("users".to_string()).unwrap().rowids().len();
745        assert_eq!(after, 4);
746    }
747
748    #[test]
749    fn rollback_undoes_update_and_delete_side_by_side() {
750        use crate::sql::db::table::Value;
751
752        let mut db = seed_users_table();
753        process_command("BEGIN;", &mut db).unwrap();
754        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
755        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
756        // Mid-txn: one row gone, others have age=999.
757        let users = db.get_table("users".to_string()).unwrap();
758        assert_eq!(users.rowids().len(), 2);
759        for r in users.rowids() {
760            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
761        }
762
763        process_command("ROLLBACK;", &mut db).unwrap();
764        let users = db.get_table("users".to_string()).unwrap();
765        assert_eq!(users.rowids().len(), 3);
766        // Original ages {30, 25, 40} — none should be 999.
767        for r in users.rowids() {
768            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
769        }
770    }
771
772    #[test]
773    fn nested_begin_is_rejected() {
774        let mut db = seed_users_table();
775        process_command("BEGIN;", &mut db).unwrap();
776        let err = process_command("BEGIN;", &mut db).unwrap_err();
777        assert!(
778            format!("{err}").contains("already open"),
779            "nested BEGIN should error; got: {err}"
780        );
781        // Still in the original transaction; a ROLLBACK clears it.
782        assert!(db.in_transaction());
783        process_command("ROLLBACK;", &mut db).unwrap();
784    }
785
786    #[test]
787    fn orphan_commit_and_rollback_are_rejected() {
788        let mut db = seed_users_table();
789        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
790        assert!(format!("{commit_err}").contains("no transaction"));
791        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
792        assert!(format!("{rollback_err}").contains("no transaction"));
793    }
794
795    #[test]
796    fn error_inside_transaction_keeps_txn_open() {
797        // A bad INSERT inside a txn doesn't commit or abort automatically —
798        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
799        // isn't modeled here.
800        let mut db = seed_users_table();
801        process_command("BEGIN;", &mut db).unwrap();
802        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
803        assert!(err.is_err());
804        assert!(db.in_transaction(), "txn should stay open after error");
805        process_command("ROLLBACK;", &mut db).unwrap();
806    }
807
808    /// Builds a file-backed Database at a unique temp path, with the
809    /// schema seeded and `source_path` set so subsequent process_command
810    /// calls auto-save. Returns (path, db). Drop the db before deleting
811    /// the files.
812    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
813        use crate::sql::pager::{open_database, save_database};
814        let mut p = std::env::temp_dir();
815        let pid = std::process::id();
816        let nanos = std::time::SystemTime::now()
817            .duration_since(std::time::UNIX_EPOCH)
818            .map(|d| d.as_nanos())
819            .unwrap_or(0);
820        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
821
822        // Seed the file, then reopen to get a source_path-attached db
823        // (save_database alone doesn't attach a fresh pager to a db
824        // whose source_path was None before the call).
825        {
826            let mut seed = Database::new("t".to_string());
827            process_command(schema, &mut seed).unwrap();
828            save_database(&mut seed, &p).unwrap();
829        }
830        let db = open_database(&p, "t".to_string()).unwrap();
831        (p, db)
832    }
833
834    fn cleanup_file(path: &std::path::Path) {
835        let _ = std::fs::remove_file(path);
836        let mut wal = path.as_os_str().to_owned();
837        wal.push("-wal");
838        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
839    }
840
841    #[test]
842    fn begin_commit_rollback_round_trip_through_disk() {
843        // File-backed DB: commit inside a transaction must actually
844        // persist. ROLLBACK inside a *later* transaction must not
845        // un-do the previously-committed changes.
846        use crate::sql::pager::open_database;
847
848        let (path, mut db) = seed_file_backed(
849            "roundtrip",
850            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
851        );
852
853        // Transaction 1: insert two rows, commit.
854        process_command("BEGIN;", &mut db).unwrap();
855        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
856        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
857        process_command("COMMIT;", &mut db).unwrap();
858
859        // Transaction 2: insert another, roll back.
860        process_command("BEGIN;", &mut db).unwrap();
861        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
862        process_command("ROLLBACK;", &mut db).unwrap();
863
864        drop(db); // release pager lock
865
866        let reopened = open_database(&path, "t".to_string()).unwrap();
867        let notes = reopened.get_table("notes".to_string()).unwrap();
868        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
869
870        drop(reopened);
871        cleanup_file(&path);
872    }
873
874    #[test]
875    fn write_inside_transaction_does_not_autosave() {
876        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
877        // the WAL until COMMIT. We prove it by checking the WAL file
878        // size before vs during the transaction.
879        let (path, mut db) =
880            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
881
882        let mut wal_path = path.as_os_str().to_owned();
883        wal_path.push("-wal");
884        let wal_path = std::path::PathBuf::from(wal_path);
885        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
886
887        process_command("BEGIN;", &mut db).unwrap();
888        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
889        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
890
891        // Mid-transaction: WAL must be unchanged — no auto-save fired.
892        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
893        assert_eq!(
894            frames_before, frames_mid,
895            "WAL should not grow during an open transaction"
896        );
897
898        process_command("COMMIT;", &mut db).unwrap();
899
900        drop(db); // release pager lock
901        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
902        assert_eq!(
903            fresh.get_table("t".to_string()).unwrap().rowids().len(),
904            2,
905            "COMMIT should have persisted both inserted rows"
906        );
907        drop(fresh);
908        cleanup_file(&path);
909    }
910
911    #[test]
912    fn rollback_undoes_create_table() {
913        // Schema DDL inside a txn: ROLLBACK must make the new table
914        // disappear. The txn snapshot captures db.tables as of BEGIN,
915        // and ROLLBACK reassigns tables from that snapshot, so a table
916        // created mid-transaction has no entry in the snapshot.
917        let mut db = seed_users_table();
918        assert_eq!(db.tables.len(), 1);
919
920        process_command("BEGIN;", &mut db).unwrap();
921        process_command(
922            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
923            &mut db,
924        )
925        .unwrap();
926        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
927        assert_eq!(db.tables.len(), 2);
928
929        process_command("ROLLBACK;", &mut db).unwrap();
930        assert_eq!(
931            db.tables.len(),
932            1,
933            "CREATE TABLE should have been rolled back"
934        );
935        assert!(db.get_table("dropme".to_string()).is_err());
936    }
937
938    #[test]
939    fn rollback_restores_secondary_index_state() {
940        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
941        // column must also clean up the index, otherwise a re-insert of
942        // the same value would spuriously collide.
943        let mut db = Database::new("t".to_string());
944        process_command(
945            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
946            &mut db,
947        )
948        .unwrap();
949        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
950
951        process_command("BEGIN;", &mut db).unwrap();
952        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
953        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
954        process_command("ROLLBACK;", &mut db).unwrap();
955
956        // Re-inserting 'b@x' after rollback must succeed — if the index
957        // wasn't properly restored, it would think 'b@x' is still a
958        // collision and fail with a UNIQUE violation.
959        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
960        assert!(
961            reinsert.is_ok(),
962            "re-insert after rollback should succeed, got {reinsert:?}"
963        );
964    }
965
966    #[test]
967    fn rollback_restores_last_rowid_counter() {
968        // Rowids allocated inside a rolled-back transaction should be
969        // reusable. The snapshot restores Table::last_rowid, so the
970        // next insert picks up where the pre-BEGIN state left off.
971        use crate::sql::db::table::Value;
972
973        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
974        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
975
976        process_command("BEGIN;", &mut db).unwrap();
977        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
978        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
979        process_command("ROLLBACK;", &mut db).unwrap();
980
981        let post = db.get_table("users".to_string()).unwrap().last_rowid;
982        assert_eq!(pre, post, "last_rowid must roll back with the snapshot");
983
984        // Confirm: the next insert reuses rowid pre+1.
985        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
986        let users = db.get_table("users".to_string()).unwrap();
987        let d_rowid = users
988            .rowids()
989            .into_iter()
990            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
991            .expect("d row must exist");
992        assert_eq!(d_rowid, pre + 1);
993    }
994
995    #[test]
996    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
997        // In-memory DB (no source_path): COMMIT must still work — just
998        // no disk flush. Covers the `if let Some(path) = …` branch
999        // where the guard falls through without calling save_database.
1000        let mut db = seed_users_table(); // no source_path
1001        assert!(db.source_path.is_none());
1002
1003        process_command("BEGIN;", &mut db).unwrap();
1004        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1005        process_command("COMMIT;", &mut db).unwrap();
1006
1007        assert!(!db.in_transaction());
1008        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
1009    }
1010
1011    #[test]
1012    fn failed_commit_auto_rolls_back_in_memory_state() {
1013        // Data-safety regression: on COMMIT save failure we must auto-
1014        // rollback the in-memory state. Otherwise, any subsequent
1015        // non-transactional statement would auto-save the partial
1016        // mid-transaction work, silently publishing uncommitted
1017        // changes to disk.
1018        //
1019        // We simulate a save failure by making the WAL sidecar path
1020        // unavailable mid-transaction: after BEGIN, we take an
1021        // exclusive OS lock on the WAL via a second File handle,
1022        // forcing the next save to fail when it tries to append.
1023        //
1024        // Simpler repro: point source_path at a directory (not a file).
1025        // `OpenOptions::open` will fail with EISDIR on save.
1026        use crate::sql::pager::save_database;
1027
1028        // Seed a file-backed db.
1029        let (path, mut db) = seed_file_backed(
1030            "failcommit",
1031            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1032        );
1033
1034        // Prime one committed row so we have a baseline.
1035        process_command("INSERT INTO notes (body) VALUES ('before');", &mut db).unwrap();
1036
1037        // Open a new txn and add a row.
1038        process_command("BEGIN;", &mut db).unwrap();
1039        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1040        assert_eq!(
1041            db.get_table("notes".to_string()).unwrap().rowids().len(),
1042            2,
1043            "inflight row visible mid-txn"
1044        );
1045
1046        // Swap source_path to a path that will fail on open. A
1047        // directory is a reliable failure mode — Pager::open on a
1048        // directory errors with an I/O error.
1049        let orig_source = db.source_path.clone();
1050        let orig_pager = db.pager.take();
1051        db.source_path = Some(std::env::temp_dir());
1052
1053        let commit_result = process_command("COMMIT;", &mut db);
1054        assert!(commit_result.is_err(), "commit must fail");
1055        let err_str = format!("{}", commit_result.unwrap_err());
1056        assert!(
1057            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
1058            "error must surface auto-rollback; got: {err_str}"
1059        );
1060
1061        // Auto-rollback fired: the inflight row is gone, the txn flag
1062        // is cleared, and a follow-up non-txn statement won't leak
1063        // stale state.
1064        assert!(
1065            !db.in_transaction(),
1066            "txn must be cleared after auto-rollback"
1067        );
1068        assert_eq!(
1069            db.get_table("notes".to_string()).unwrap().rowids().len(),
1070            1,
1071            "inflight row must be rolled back"
1072        );
1073
1074        // Restore the real source_path + pager and verify a clean
1075        // subsequent write goes through.
1076        db.source_path = orig_source;
1077        db.pager = orig_pager;
1078        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1079        drop(db);
1080
1081        // Reopen and assert only 'before' + 'after' landed on disk.
1082        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1083        let notes = reopened.get_table("notes".to_string()).unwrap();
1084        assert_eq!(notes.rowids().len(), 2);
1085        // Ensure no leaked save_database partial happened.
1086        let _ = save_database; // silence unused-import lint if any
1087        drop(reopened);
1088        cleanup_file(&path);
1089    }
1090
1091    #[test]
1092    fn begin_on_read_only_is_rejected() {
1093        use crate::sql::pager::{open_database_read_only, save_database};
1094
1095        let path = {
1096            let mut p = std::env::temp_dir();
1097            let pid = std::process::id();
1098            let nanos = std::time::SystemTime::now()
1099                .duration_since(std::time::UNIX_EPOCH)
1100                .map(|d| d.as_nanos())
1101                .unwrap_or(0);
1102            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1103            p
1104        };
1105        {
1106            let mut seed = Database::new("t".to_string());
1107            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
1108            save_database(&mut seed, &path).unwrap();
1109        }
1110
1111        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1112        let err = process_command("BEGIN;", &mut ro).unwrap_err();
1113        assert!(
1114            format!("{err}").contains("read-only"),
1115            "BEGIN on RO db should surface read-only; got: {err}"
1116        );
1117        assert!(!ro.in_transaction());
1118
1119        let _ = std::fs::remove_file(&path);
1120        let mut wal = path.as_os_str().to_owned();
1121        wal.push("-wal");
1122        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1123    }
1124
1125    #[test]
1126    fn read_only_database_rejects_mutations_before_touching_state() {
1127        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1128        // must error *before* the row is added to the in-memory table.
1129        // Otherwise the user sees a rendered result table with the
1130        // phantom row, followed by the auto-save error — UX rot and a
1131        // state-drift risk.
1132        use crate::sql::pager::open_database_read_only;
1133
1134        let mut seed = Database::new("t".to_string());
1135        process_command(
1136            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1137            &mut seed,
1138        )
1139        .unwrap();
1140        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1141
1142        let path = {
1143            let mut p = std::env::temp_dir();
1144            let pid = std::process::id();
1145            let nanos = std::time::SystemTime::now()
1146                .duration_since(std::time::UNIX_EPOCH)
1147                .map(|d| d.as_nanos())
1148                .unwrap_or(0);
1149            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1150            p
1151        };
1152        crate::sql::pager::save_database(&mut seed, &path).unwrap();
1153        drop(seed);
1154
1155        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1156        let notes_before = ro.get_table("notes".to_string()).unwrap().rowids().len();
1157
1158        for stmt in [
1159            "INSERT INTO notes (body) VALUES ('beta');",
1160            "UPDATE notes SET body = 'x';",
1161            "DELETE FROM notes;",
1162            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1163            "CREATE INDEX notes_body ON notes (body);",
1164        ] {
1165            let err = process_command(stmt, &mut ro).unwrap_err();
1166            assert!(
1167                format!("{err}").contains("read-only"),
1168                "stmt {stmt:?} should surface a read-only error; got: {err}"
1169            );
1170        }
1171
1172        // Nothing mutated: same row count as before, and SELECTs still work.
1173        let notes_after = ro.get_table("notes".to_string()).unwrap().rowids().len();
1174        assert_eq!(notes_before, notes_after);
1175        let sel = process_command("SELECT * FROM notes;", &mut ro).expect("select on RO must work");
1176        assert!(sel.contains("1 row returned"));
1177
1178        // Cleanup.
1179        drop(ro);
1180        let _ = std::fs::remove_file(&path);
1181        let mut wal = path.as_os_str().to_owned();
1182        wal.push("-wal");
1183        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1184    }
1185
1186    // -----------------------------------------------------------------
1187    // Phase 7a — VECTOR(N) end-to-end through process_command
1188    // -----------------------------------------------------------------
1189
1190    #[test]
1191    fn vector_create_table_and_insert_basic() {
1192        let mut db = Database::new("tempdb".to_string());
1193        process_command(
1194            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1195            &mut db,
1196        )
1197        .expect("create table with VECTOR(3)");
1198        process_command(
1199            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1200            &mut db,
1201        )
1202        .expect("insert vector");
1203
1204        // process_command returns a status string; the rendered table
1205        // goes to stdout via print_table. Verify state by inspecting
1206        // the database directly.
1207        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1208        assert!(sel.contains("1 row returned"));
1209
1210        let docs = db.get_table("docs".to_string()).expect("docs table");
1211        let rowids = docs.rowids();
1212        assert_eq!(rowids.len(), 1);
1213        match docs.get_value("embedding", rowids[0]) {
1214            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1215            other => panic!("expected Value::Vector(...), got {other:?}"),
1216        }
1217    }
1218
1219    #[test]
1220    fn vector_dim_mismatch_at_insert_is_clean_error() {
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");
1227
1228        // Too few elements.
1229        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1230            .unwrap_err();
1231        let msg = format!("{err}");
1232        assert!(
1233            msg.to_lowercase().contains("dimension")
1234                && msg.contains("declared 3")
1235                && msg.contains("got 2"),
1236            "expected clear dim-mismatch error, got: {msg}"
1237        );
1238
1239        // Too many elements.
1240        let err = process_command(
1241            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1242            &mut db,
1243        )
1244        .unwrap_err();
1245        assert!(
1246            format!("{err}").contains("got 5"),
1247            "expected dim-mismatch error mentioning got 5, got: {err}"
1248        );
1249    }
1250
1251    #[test]
1252    fn vector_create_table_rejects_missing_dim() {
1253        let mut db = Database::new("tempdb".to_string());
1254        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1255        // empty args from sqlparser, OR may not parse as Custom at all
1256        // depending on dialect. Either way, the column shouldn't end up
1257        // as a usable Vector type. Accept any error here — the precise
1258        // message is parser-version-dependent.
1259        let result = process_command(
1260            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1261            &mut db,
1262        );
1263        assert!(
1264            result.is_err(),
1265            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1266        );
1267    }
1268
1269    #[test]
1270    fn vector_create_table_rejects_zero_dim() {
1271        let mut db = Database::new("tempdb".to_string());
1272        let err = process_command(
1273            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1274            &mut db,
1275        )
1276        .unwrap_err();
1277        let msg = format!("{err}");
1278        assert!(
1279            msg.to_lowercase().contains("vector"),
1280            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1281        );
1282    }
1283
1284    #[test]
1285    fn vector_high_dim_works() {
1286        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1287        // smoke test — if cell encoding mishandles the size, this fails.
1288        let mut db = Database::new("tempdb".to_string());
1289        process_command(
1290            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1291            &mut db,
1292        )
1293        .expect("create table VECTOR(384)");
1294
1295        let lit = format!(
1296            "[{}]",
1297            (0..384)
1298                .map(|i| format!("{}", i as f32 * 0.001))
1299                .collect::<Vec<_>>()
1300                .join(",")
1301        );
1302        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
1303        process_command(&sql, &mut db).expect("insert 384-dim vector");
1304
1305        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1306        assert!(sel.contains("1 row returned"));
1307    }
1308
1309    #[test]
1310    fn vector_multiple_rows() {
1311        // Three rows with different vectors — exercises the Row::Vector
1312        // BTreeMap path (not just single-row insertion).
1313        let mut db = Database::new("tempdb".to_string());
1314        process_command(
1315            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1316            &mut db,
1317        )
1318        .expect("create");
1319        for i in 0..3 {
1320            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
1321            process_command(&sql, &mut db).expect("insert");
1322        }
1323        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1324        assert!(sel.contains("3 rows returned"));
1325
1326        // Verify each vector round-tripped correctly via direct DB inspection.
1327        let docs = db.get_table("docs".to_string()).expect("docs table");
1328        let rowids = docs.rowids();
1329        assert_eq!(rowids.len(), 3);
1330        let mut vectors: Vec<Vec<f32>> = rowids
1331            .iter()
1332            .filter_map(|r| match docs.get_value("e", *r) {
1333                Some(Value::Vector(v)) => Some(v),
1334                _ => None,
1335            })
1336            .collect();
1337        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
1338        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1339        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1340        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1341    }
1342
1343    // -----------------------------------------------------------------
1344    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1345    // -----------------------------------------------------------------
1346
1347    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1348    /// at known positions for clear distance reasoning. Used by both
1349    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1350    fn seed_hnsw_table() -> Database {
1351        let mut db = Database::new("tempdb".to_string());
1352        process_command(
1353            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1354            &mut db,
1355        )
1356        .unwrap();
1357        for v in &[
1358            "[1.0, 0.0]",   // id=1
1359            "[2.0, 0.0]",   // id=2
1360            "[0.0, 3.0]",   // id=3
1361            "[1.0, 4.0]",   // id=4
1362            "[10.0, 10.0]", // id=5
1363        ] {
1364            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
1365        }
1366        db
1367    }
1368
1369    #[test]
1370    fn create_index_using_hnsw_succeeds() {
1371        let mut db = seed_hnsw_table();
1372        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1373        assert!(resp.to_lowercase().contains("create index"));
1374        // Index attached.
1375        let table = db.get_table("docs".to_string()).unwrap();
1376        assert_eq!(table.hnsw_indexes.len(), 1);
1377        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1378        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1379        // Existing rows landed in the graph.
1380        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1381    }
1382
1383    #[test]
1384    fn create_index_using_hnsw_rejects_non_vector_column() {
1385        let mut db = Database::new("tempdb".to_string());
1386        process_command(
1387            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1388            &mut db,
1389        )
1390        .unwrap();
1391        let err =
1392            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1393        let msg = format!("{err}");
1394        assert!(
1395            msg.to_lowercase().contains("vector"),
1396            "expected error mentioning VECTOR; got: {msg}"
1397        );
1398    }
1399
1400    #[test]
1401    fn knn_query_uses_hnsw_after_create_index() {
1402        // The KNN-shaped query route through try_hnsw_probe rather than
1403        // the brute-force select_topk. The user-visible result should
1404        // be the same (HNSW recall is high on small graphs); we
1405        // primarily verify the index is being hit by checking that
1406        // the right rowids come back in the right order.
1407        let mut db = seed_hnsw_table();
1408        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1409
1410        // Top-3 closest to [1.0, 0.0]:
1411        //   id=1 [1.0, 0.0]   distance=0
1412        //   id=2 [2.0, 0.0]   distance=1
1413        //   id=3 [0.0, 3.0]   distance≈3.16
1414        let resp = process_command(
1415            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1416            &mut db,
1417        )
1418        .unwrap();
1419        assert!(resp.contains("3 rows returned"), "got: {resp}");
1420    }
1421
1422    #[test]
1423    fn knn_query_works_after_subsequent_inserts() {
1424        // Index built when 5 rows existed; insert 2 more after; the
1425        // HNSW gets maintained incrementally by insert_row, so the
1426        // KNN query should see the newly-inserted vectors.
1427        let mut db = seed_hnsw_table();
1428        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1429        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1430        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1431
1432        let table = db.get_table("docs".to_string()).unwrap();
1433        assert_eq!(
1434            table.hnsw_indexes[0].index.len(),
1435            7,
1436            "incremental insert should grow HNSW alongside row storage"
1437        );
1438
1439        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1440        // original 5 rows.
1441        let resp = process_command(
1442            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1443            &mut db,
1444        )
1445        .unwrap();
1446        assert!(resp.contains("1 row returned"), "got: {resp}");
1447    }
1448
1449    // Phase 7d.3 — DELETE / UPDATE on HNSW-indexed tables now works.
1450    // The 7d.2 versions of these tests asserted a refusal; replaced
1451    // with assertions that the operation succeeds + the index entry's
1452    // needs_rebuild flag flipped so the next save will rebuild.
1453
1454    #[test]
1455    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
1456        let mut db = seed_hnsw_table();
1457        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1458        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1459        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
1460
1461        let docs = db.get_table("docs".to_string()).unwrap();
1462        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
1463        assert!(
1464            entry.needs_rebuild,
1465            "DELETE should have marked HNSW index dirty for rebuild on next save"
1466        );
1467    }
1468
1469    #[test]
1470    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
1471        let mut db = seed_hnsw_table();
1472        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1473        let resp =
1474            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1475        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
1476
1477        let docs = db.get_table("docs".to_string()).unwrap();
1478        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
1479        assert!(
1480            entry.needs_rebuild,
1481            "UPDATE on the vector column should have marked HNSW index dirty"
1482        );
1483    }
1484
1485    #[test]
1486    fn duplicate_index_name_errors() {
1487        let mut db = seed_hnsw_table();
1488        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1489        let err =
1490            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1491        let msg = format!("{err}");
1492        assert!(
1493            msg.to_lowercase().contains("already exists"),
1494            "expected duplicate-index error; got: {msg}"
1495        );
1496    }
1497
1498    #[test]
1499    fn index_if_not_exists_is_idempotent() {
1500        let mut db = seed_hnsw_table();
1501        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1502        // Second time with IF NOT EXISTS should succeed (no-op).
1503        process_command(
1504            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1505            &mut db,
1506        )
1507        .unwrap();
1508        let table = db.get_table("docs".to_string()).unwrap();
1509        assert_eq!(table.hnsw_indexes.len(), 1);
1510    }
1511
1512    // -----------------------------------------------------------------
1513    // Phase 8b — CREATE INDEX … USING fts end-to-end
1514    // -----------------------------------------------------------------
1515
1516    /// 5-row docs(id INTEGER PK, body TEXT) populated with overlapping
1517    /// vocabulary so BM25 ranking has interesting structure.
1518    fn seed_fts_table() -> Database {
1519        let mut db = Database::new("tempdb".to_string());
1520        process_command(
1521            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1522            &mut db,
1523        )
1524        .unwrap();
1525        for body in &[
1526            "rust embedded database",        // id=1 — both 'rust' and 'embedded'
1527            "rust web framework",            // id=2 — 'rust' only
1528            "go embedded systems",           // id=3 — 'embedded' only
1529            "python web framework",          // id=4 — neither
1530            "rust rust rust embedded power", // id=5 — heavy on 'rust'
1531        ] {
1532            process_command(
1533                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
1534                &mut db,
1535            )
1536            .unwrap();
1537        }
1538        db
1539    }
1540
1541    #[test]
1542    fn create_index_using_fts_succeeds_and_indexes_existing_rows() {
1543        let mut db = seed_fts_table();
1544        let resp =
1545            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1546        assert!(resp.to_lowercase().contains("create index"), "got {resp}");
1547        let table = db.get_table("docs".to_string()).unwrap();
1548        assert_eq!(table.fts_indexes.len(), 1);
1549        assert_eq!(table.fts_indexes[0].name, "ix_body");
1550        assert_eq!(table.fts_indexes[0].column_name, "body");
1551        // All five rows should be in the in-memory PostingList.
1552        assert_eq!(table.fts_indexes[0].index.len(), 5);
1553    }
1554
1555    #[test]
1556    fn create_index_using_fts_rejects_non_text_column() {
1557        let mut db = Database::new("tempdb".to_string());
1558        process_command(
1559            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
1560            &mut db,
1561        )
1562        .unwrap();
1563        let err = process_command("CREATE INDEX ix_n ON t USING fts (n);", &mut db).unwrap_err();
1564        let msg = format!("{err}");
1565        assert!(
1566            msg.to_lowercase().contains("text"),
1567            "expected error mentioning TEXT; got: {msg}"
1568        );
1569    }
1570
1571    #[test]
1572    fn fts_match_returns_expected_rows() {
1573        let mut db = seed_fts_table();
1574        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1575        // Rows that contain 'rust': ids 1, 2, 5.
1576        let resp = process_command(
1577            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1578            &mut db,
1579        )
1580        .unwrap();
1581        assert!(resp.contains("3 rows returned"), "got: {resp}");
1582    }
1583
1584    #[test]
1585    fn fts_match_without_index_errors_clearly() {
1586        let mut db = seed_fts_table();
1587        // No CREATE INDEX — fts_match must surface a useful error.
1588        let err = process_command(
1589            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1590            &mut db,
1591        )
1592        .unwrap_err();
1593        let msg = format!("{err}");
1594        assert!(
1595            msg.contains("no FTS index"),
1596            "expected no-index error; got: {msg}"
1597        );
1598    }
1599
1600    #[test]
1601    fn bm25_score_orders_descending_by_relevance() {
1602        let mut db = seed_fts_table();
1603        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1604        // ORDER BY bm25_score DESC LIMIT 1: id=5 has 'rust' three times in
1605        // a 5-token doc — highest tf, modest length penalty → top score.
1606        let out = process_command_with_render(
1607            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1608             ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1609            &mut db,
1610        )
1611        .unwrap();
1612        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
1613        let rendered = out.rendered.expect("SELECT should produce rendered output");
1614        // The rendered prettytable contains the integer 5 in a cell.
1615        assert!(
1616            rendered.contains(" 5 "),
1617            "expected id=5 to be top-ranked; rendered:\n{rendered}"
1618        );
1619    }
1620
1621    #[test]
1622    fn bm25_score_without_index_errors_clearly() {
1623        let mut db = seed_fts_table();
1624        let err = process_command(
1625            "SELECT id FROM docs ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1626            &mut db,
1627        )
1628        .unwrap_err();
1629        let msg = format!("{err}");
1630        assert!(
1631            msg.contains("no FTS index"),
1632            "expected no-index error; got: {msg}"
1633        );
1634    }
1635
1636    #[test]
1637    fn fts_post_create_inserts_are_indexed_incrementally() {
1638        let mut db = seed_fts_table();
1639        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1640        process_command(
1641            "INSERT INTO docs (body) VALUES ('rust embedded analytics');",
1642            &mut db,
1643        )
1644        .unwrap();
1645        let table = db.get_table("docs".to_string()).unwrap();
1646        // PostingList::len() reports doc count; should be 6 now.
1647        assert_eq!(table.fts_indexes[0].index.len(), 6);
1648        // 'analytics' appears only in the new row → query returns 1 hit.
1649        let resp = process_command(
1650            "SELECT id FROM docs WHERE fts_match(body, 'analytics');",
1651            &mut db,
1652        )
1653        .unwrap();
1654        assert!(resp.contains("1 row returned"), "got: {resp}");
1655    }
1656
1657    #[test]
1658    fn delete_on_fts_indexed_table_marks_dirty() {
1659        let mut db = seed_fts_table();
1660        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1661        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1662        assert!(resp.contains("1 row"), "got: {resp}");
1663        let docs = db.get_table("docs".to_string()).unwrap();
1664        let entry = docs
1665            .fts_indexes
1666            .iter()
1667            .find(|e| e.name == "ix_body")
1668            .unwrap();
1669        assert!(
1670            entry.needs_rebuild,
1671            "DELETE should have flagged the FTS index dirty"
1672        );
1673    }
1674
1675    #[test]
1676    fn update_on_fts_indexed_text_col_marks_dirty() {
1677        let mut db = seed_fts_table();
1678        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1679        let resp = process_command(
1680            "UPDATE docs SET body = 'java spring framework' WHERE id = 1;",
1681            &mut db,
1682        )
1683        .unwrap();
1684        assert!(resp.contains("1 row"), "got: {resp}");
1685        let docs = db.get_table("docs".to_string()).unwrap();
1686        let entry = docs
1687            .fts_indexes
1688            .iter()
1689            .find(|e| e.name == "ix_body")
1690            .unwrap();
1691        assert!(
1692            entry.needs_rebuild,
1693            "UPDATE on the indexed TEXT column should have flagged dirty"
1694        );
1695    }
1696
1697    #[test]
1698    fn fts_index_name_collides_with_btree_and_hnsw_namespaces() {
1699        let mut db = seed_fts_table();
1700        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1701        let err = process_command("CREATE INDEX ix_body ON docs (body);", &mut db).unwrap_err();
1702        let msg = format!("{err}");
1703        assert!(
1704            msg.to_lowercase().contains("already exists"),
1705            "expected duplicate-index error; got: {msg}"
1706        );
1707    }
1708
1709    #[test]
1710    fn fts_index_rejects_unique() {
1711        let mut db = seed_fts_table();
1712        let err = process_command(
1713            "CREATE UNIQUE INDEX ix_body ON docs USING fts (body);",
1714            &mut db,
1715        )
1716        .unwrap_err();
1717        let msg = format!("{err}");
1718        assert!(
1719            msg.to_lowercase().contains("unique"),
1720            "expected UNIQUE-rejection error; got: {msg}"
1721        );
1722    }
1723
1724    #[test]
1725    fn try_fts_probe_falls_through_on_ascending() {
1726        // BM25 is "higher = better"; ASC is rejected so the slow path
1727        // applies. We verify by running the query and checking the
1728        // result is still correct (the slow path goes through scalar
1729        // bm25_score on every row).
1730        let mut db = seed_fts_table();
1731        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1732        // Same query as bm25_score_orders_descending but ASC → should
1733        // still succeed (slow path), and id=5 should now be LAST.
1734        let resp = process_command(
1735            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1736             ORDER BY bm25_score(body, 'rust') ASC LIMIT 3;",
1737            &mut db,
1738        )
1739        .unwrap();
1740        assert!(resp.contains("3 rows returned"), "got: {resp}");
1741    }
1742
1743    // -----------------------------------------------------------------
1744    // Phase 7b — vector distance functions through process_command
1745    // -----------------------------------------------------------------
1746
1747    /// Builds a 3-row docs table with 2-dim vectors aligned along the
1748    /// axes so the expected distances are easy to reason about:
1749    ///   id=1: [1, 0]
1750    ///   id=2: [0, 1]
1751    ///   id=3: [1, 1]
1752    fn seed_vector_docs() -> Database {
1753        let mut db = Database::new("tempdb".to_string());
1754        process_command(
1755            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1756            &mut db,
1757        )
1758        .expect("create");
1759        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1760        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1761        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1762        db
1763    }
1764
1765    #[test]
1766    fn vec_distance_l2_in_where_filters_correctly() {
1767        // Distance from [1,0]:
1768        //   id=1 [1,0]: 0
1769        //   id=2 [0,1]: √2 ≈ 1.414
1770        //   id=3 [1,1]: 1
1771        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
1772        let mut db = seed_vector_docs();
1773        let resp = process_command(
1774            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
1775            &mut db,
1776        )
1777        .expect("select");
1778        assert!(
1779            resp.contains("2 rows returned"),
1780            "expected 2 rows, got: {resp}"
1781        );
1782    }
1783
1784    #[test]
1785    fn vec_distance_cosine_in_where() {
1786        // [1,0] vs [1,0]: cosine distance = 0
1787        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
1788        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
1789        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
1790        let mut db = seed_vector_docs();
1791        let resp = process_command(
1792            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
1793            &mut db,
1794        )
1795        .expect("select");
1796        assert!(
1797            resp.contains("2 rows returned"),
1798            "expected 2 rows, got: {resp}"
1799        );
1800    }
1801
1802    #[test]
1803    fn vec_distance_dot_negated() {
1804        // [1,0]·[1,0] = 1 → -1
1805        // [1,0]·[0,1] = 0 → 0
1806        // [1,0]·[1,1] = 1 → -1
1807        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
1808        let mut db = seed_vector_docs();
1809        let resp = process_command(
1810            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
1811            &mut db,
1812        )
1813        .expect("select");
1814        assert!(
1815            resp.contains("2 rows returned"),
1816            "expected 2 rows, got: {resp}"
1817        );
1818    }
1819
1820    #[test]
1821    fn knn_via_order_by_distance_limit() {
1822        // Classic KNN shape: ORDER BY distance LIMIT k.
1823        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
1824        // LIMIT 2 should return id=1 then id=3 in that order.
1825        let mut db = seed_vector_docs();
1826        let resp = process_command(
1827            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
1828            &mut db,
1829        )
1830        .expect("select");
1831        assert!(
1832            resp.contains("2 rows returned"),
1833            "expected 2 rows, got: {resp}"
1834        );
1835    }
1836
1837    #[test]
1838    fn distance_function_dim_mismatch_errors() {
1839        // 2-dim column queried with a 3-dim probe → clean error.
1840        let mut db = seed_vector_docs();
1841        let err = process_command(
1842            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
1843            &mut db,
1844        )
1845        .unwrap_err();
1846        let msg = format!("{err}");
1847        assert!(
1848            msg.to_lowercase().contains("dimension")
1849                && msg.contains("lhs=2")
1850                && msg.contains("rhs=3"),
1851            "expected dim mismatch error, got: {msg}"
1852        );
1853    }
1854
1855    #[test]
1856    fn unknown_function_errors_with_name() {
1857        // Use the function in WHERE, not projection — the projection
1858        // parser still requires bare column references; function calls
1859        // there are a future enhancement (with `AS alias` support).
1860        let mut db = seed_vector_docs();
1861        let err = process_command(
1862            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
1863            &mut db,
1864        )
1865        .unwrap_err();
1866        let msg = format!("{err}");
1867        assert!(
1868            msg.contains("vec_does_not_exist"),
1869            "expected error mentioning function name, got: {msg}"
1870        );
1871    }
1872
1873    // -----------------------------------------------------------------
1874    // Phase 7e — JSON column type + path-extraction functions
1875    // -----------------------------------------------------------------
1876
1877    fn seed_json_table() -> Database {
1878        let mut db = Database::new("tempdb".to_string());
1879        process_command(
1880            "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
1881            &mut db,
1882        )
1883        .expect("create json table");
1884        db
1885    }
1886
1887    #[test]
1888    fn json_column_round_trip_primitive_values() {
1889        let mut db = seed_json_table();
1890        process_command(
1891            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
1892            &mut db,
1893        )
1894        .expect("insert json");
1895        let docs = db.get_table("docs".to_string()).unwrap();
1896        let rowids = docs.rowids();
1897        assert_eq!(rowids.len(), 1);
1898        // Stored verbatim as Text underneath.
1899        match docs.get_value("payload", rowids[0]) {
1900            Some(Value::Text(s)) => {
1901                assert!(s.contains("alice"), "expected JSON text to round-trip: {s}");
1902            }
1903            other => panic!("expected Value::Text holding JSON, got {other:?}"),
1904        }
1905    }
1906
1907    #[test]
1908    fn json_insert_rejects_invalid_json() {
1909        let mut db = seed_json_table();
1910        let err = process_command(
1911            "INSERT INTO docs (payload) VALUES ('not-valid-json{');",
1912            &mut db,
1913        )
1914        .unwrap_err();
1915        let msg = format!("{err}").to_lowercase();
1916        assert!(
1917            msg.contains("json") && msg.contains("payload"),
1918            "expected JSON validation error mentioning column, got: {msg}"
1919        );
1920    }
1921
1922    #[test]
1923    fn json_extract_object_field() {
1924        let mut db = seed_json_table();
1925        process_command(
1926            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
1927            &mut db,
1928        )
1929        .unwrap();
1930        // We don't have function calls in projection (yet), so test
1931        // the function via WHERE.
1932        let resp = process_command(
1933            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
1934            &mut db,
1935        )
1936        .expect("select via json_extract");
1937        assert!(resp.contains("1 row returned"), "got: {resp}");
1938
1939        let resp = process_command(
1940            r#"SELECT id FROM docs WHERE json_extract(payload, '$.age') = 30;"#,
1941            &mut db,
1942        )
1943        .expect("select via numeric json_extract");
1944        assert!(resp.contains("1 row returned"), "got: {resp}");
1945    }
1946
1947    #[test]
1948    fn json_extract_array_index_and_nested() {
1949        let mut db = seed_json_table();
1950        process_command(
1951            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["rust", "sql", "vectors"], "meta": {"author": "joao"}}');"#,
1952            &mut db,
1953        )
1954        .unwrap();
1955        let resp = process_command(
1956            r#"SELECT id FROM docs WHERE json_extract(payload, '$.tags[0]') = 'rust';"#,
1957            &mut db,
1958        )
1959        .expect("select via array index");
1960        assert!(resp.contains("1 row returned"), "got: {resp}");
1961
1962        let resp = process_command(
1963            r#"SELECT id FROM docs WHERE json_extract(payload, '$.meta.author') = 'joao';"#,
1964            &mut db,
1965        )
1966        .expect("select via nested object");
1967        assert!(resp.contains("1 row returned"), "got: {resp}");
1968    }
1969
1970    #[test]
1971    fn json_extract_missing_path_returns_null() {
1972        let mut db = seed_json_table();
1973        process_command(
1974            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice"}');"#,
1975            &mut db,
1976        )
1977        .unwrap();
1978        // Missing key under WHERE returns NULL → predicate is false →
1979        // 0 rows returned. (Standard SQL three-valued logic.)
1980        let resp = process_command(
1981            r#"SELECT id FROM docs WHERE json_extract(payload, '$.missing') = 'something';"#,
1982            &mut db,
1983        )
1984        .expect("select with missing path");
1985        assert!(resp.contains("0 rows returned"), "got: {resp}");
1986    }
1987
1988    #[test]
1989    fn json_extract_malformed_path_errors() {
1990        let mut db = seed_json_table();
1991        process_command(
1992            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
1993            &mut db,
1994        )
1995        .unwrap();
1996        // Path doesn't start with '$' — syntax error.
1997        let err = process_command(
1998            r#"SELECT id FROM docs WHERE json_extract(payload, 'a.b') = 1;"#,
1999            &mut db,
2000        )
2001        .unwrap_err();
2002        assert!(format!("{err}").contains("'$'"));
2003    }
2004
2005    #[test]
2006    fn json_array_length_on_array() {
2007        // Note: json_array_length used in WHERE clause where it can be
2008        // compared; that exercises the function dispatch end-to-end.
2009        let mut db = seed_json_table();
2010        process_command(
2011            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["a", "b", "c"]}');"#,
2012            &mut db,
2013        )
2014        .unwrap();
2015        let resp = process_command(
2016            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 3;"#,
2017            &mut db,
2018        )
2019        .expect("select via array_length");
2020        assert!(resp.contains("1 row returned"), "got: {resp}");
2021    }
2022
2023    #[test]
2024    fn json_array_length_on_non_array_errors() {
2025        let mut db = seed_json_table();
2026        process_command(
2027            r#"INSERT INTO docs (payload) VALUES ('{"tags": "not-an-array"}');"#,
2028            &mut db,
2029        )
2030        .unwrap();
2031        let err = process_command(
2032            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 1;"#,
2033            &mut db,
2034        )
2035        .unwrap_err();
2036        let msg = format!("{err}").to_lowercase();
2037        assert!(
2038            msg.contains("non-array"),
2039            "expected non-array error, got: {msg}"
2040        );
2041    }
2042
2043    #[test]
2044    fn json_type_recognizes_each_kind() {
2045        let mut db = seed_json_table();
2046        process_command(
2047            r#"INSERT INTO docs (payload) VALUES ('{"o": {}, "a": [], "s": "x", "i": 1, "f": 1.5, "t": true, "n": null}');"#,
2048            &mut db,
2049        )
2050        .unwrap();
2051        let cases = &[
2052            ("$.o", "object"),
2053            ("$.a", "array"),
2054            ("$.s", "text"),
2055            ("$.i", "integer"),
2056            ("$.f", "real"),
2057            ("$.t", "true"),
2058            ("$.n", "null"),
2059        ];
2060        for (path, expected_type) in cases {
2061            let sql = format!(
2062                "SELECT id FROM docs WHERE json_type(payload, '{path}') = '{expected_type}';"
2063            );
2064            let resp =
2065                process_command(&sql, &mut db).unwrap_or_else(|e| panic!("path {path}: {e}"));
2066            assert!(
2067                resp.contains("1 row returned"),
2068                "path {path} expected type {expected_type}; got response: {resp}"
2069            );
2070        }
2071    }
2072
2073    #[test]
2074    fn update_on_json_column_revalidates() {
2075        let mut db = seed_json_table();
2076        process_command(
2077            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2078            &mut db,
2079        )
2080        .unwrap();
2081        // Valid JSON update succeeds.
2082        process_command(
2083            r#"UPDATE docs SET payload = '{"a": 2, "b": 3}' WHERE id = 1;"#,
2084            &mut db,
2085        )
2086        .expect("valid JSON UPDATE");
2087        // Invalid JSON in UPDATE is rejected with the same shape of
2088        // error as INSERT.
2089        let err = process_command(
2090            r#"UPDATE docs SET payload = 'not-json{' WHERE id = 1;"#,
2091            &mut db,
2092        )
2093        .unwrap_err();
2094        let msg = format!("{err}").to_lowercase();
2095        assert!(
2096            msg.contains("json") && msg.contains("payload"),
2097            "got: {msg}"
2098        );
2099    }
2100}