Skip to main content

sqlrite/sql/
mod.rs

1pub mod db;
2pub mod executor;
3pub mod hnsw;
4pub mod pager;
5pub mod parser;
6// pub mod tokenizer;
7
8use parser::create::CreateQuery;
9use parser::insert::InsertQuery;
10use parser::select::SelectQuery;
11
12use sqlparser::ast::Statement;
13use sqlparser::dialect::SQLiteDialect;
14use sqlparser::parser::{Parser, ParserError};
15
16use crate::error::{Result, SQLRiteError};
17use crate::sql::db::database::Database;
18use crate::sql::db::table::Table;
19
20#[derive(Debug, PartialEq)]
21pub enum SQLCommand {
22    Insert(String),
23    Delete(String),
24    Update(String),
25    CreateTable(String),
26    Select(String),
27    Unknown(String),
28}
29
30impl SQLCommand {
31    pub fn new(command: String) -> SQLCommand {
32        let v = command.split(" ").collect::<Vec<&str>>();
33        match v[0] {
34            "insert" => SQLCommand::Insert(command),
35            "update" => SQLCommand::Update(command),
36            "delete" => SQLCommand::Delete(command),
37            "create" => SQLCommand::CreateTable(command),
38            "select" => SQLCommand::Select(command),
39            _ => SQLCommand::Unknown(command),
40        }
41    }
42}
43
44/// Performs initial parsing of SQL Statement using sqlparser-rs
45pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
46    let dialect = SQLiteDialect {};
47    let message: String;
48    let mut ast = Parser::parse_sql(&dialect, query).map_err(SQLRiteError::from)?;
49
50    if ast.len() > 1 {
51        return Err(SQLRiteError::SqlError(ParserError::ParserError(format!(
52            "Expected a single query statement, but there are {}",
53            ast.len()
54        ))));
55    }
56
57    // Comment-only or whitespace-only input parses to an empty Vec<Statement>.
58    // Return a benign status rather than panicking on `pop().unwrap()`. Callers
59    // (REPL, Tauri app) treat this as a no-op with no disk write triggered.
60    let Some(query) = ast.pop() else {
61        return Ok("No statement to execute.".to_string());
62    };
63
64    // Transaction boundary statements are routed to Database-level
65    // handlers before we even inspect the rest of the AST. They don't
66    // mutate table data directly, so they short-circuit the
67    // is_write_statement / auto-save path.
68    match &query {
69        Statement::StartTransaction { .. } => {
70            db.begin_transaction()?;
71            return Ok(String::from("BEGIN"));
72        }
73        Statement::Commit { .. } => {
74            if !db.in_transaction() {
75                return Err(SQLRiteError::General(
76                    "cannot COMMIT: no transaction is open".to_string(),
77                ));
78            }
79            // Flush accumulated in-memory changes to disk. If the save
80            // fails we auto-rollback the in-memory state to the
81            // pre-BEGIN snapshot and surface a combined error. Leaving
82            // the transaction open after a failed COMMIT would be
83            // unsafe: auto-save on any subsequent non-transactional
84            // statement would silently publish partial mid-transaction
85            // work. Auto-rollback keeps the disk-plus-memory pair
86            // coherent — the user loses their in-flight work on a disk
87            // error, but that's the only safe outcome.
88            if let Some(path) = db.source_path.clone() {
89                if let Err(save_err) = pager::save_database(db, &path) {
90                    let _ = db.rollback_transaction();
91                    return Err(SQLRiteError::General(format!(
92                        "COMMIT failed — transaction rolled back: {save_err}"
93                    )));
94                }
95            }
96            db.commit_transaction()?;
97            return Ok(String::from("COMMIT"));
98        }
99        Statement::Rollback { .. } => {
100            db.rollback_transaction()?;
101            return Ok(String::from("ROLLBACK"));
102        }
103        _ => {}
104    }
105
106    // Statements that mutate state — trigger auto-save on success. Read-only
107    // SELECTs skip the save entirely to avoid pointless file writes.
108    let is_write_statement = matches!(
109        &query,
110        Statement::CreateTable(_)
111            | Statement::CreateIndex(_)
112            | Statement::Insert(_)
113            | Statement::Update(_)
114            | Statement::Delete(_)
115    );
116
117    // Early-reject mutations on a read-only database before they touch
118    // in-memory state. Phase 4e: without this, a user running INSERT
119    // on a `--readonly` REPL would see the row appear in the printed
120    // table, and then the auto-save would fail — leaving the in-memory
121    // Database visibly diverged from disk.
122    if is_write_statement && db.is_read_only() {
123        return Err(SQLRiteError::General(
124            "cannot execute: database is opened read-only".to_string(),
125        ));
126    }
127
128    // Initialy only implementing some basic SQL Statements
129    match query {
130        Statement::CreateTable(_) => {
131            let create_query = CreateQuery::new(&query);
132            match create_query {
133                Ok(payload) => {
134                    let table_name = payload.table_name.clone();
135                    if table_name == pager::MASTER_TABLE_NAME {
136                        return Err(SQLRiteError::General(format!(
137                            "'{}' is a reserved name used by the internal schema catalog",
138                            pager::MASTER_TABLE_NAME
139                        )));
140                    }
141                    // Checking if table already exists, after parsing CREATE TABLE query
142                    match db.contains_table(table_name.to_string()) {
143                        true => {
144                            return Err(SQLRiteError::Internal(
145                                "Cannot create, table already exists.".to_string(),
146                            ));
147                        }
148                        false => {
149                            let table = Table::new(payload);
150                            let _ = table.print_table_schema();
151                            db.tables.insert(table_name.to_string(), table);
152                            // Iterate over everything.
153                            // for (table_name, _) in &db.tables {
154                            //     println!("{}" , table_name);
155                            // }
156                            message = String::from("CREATE TABLE Statement executed.");
157                        }
158                    }
159                }
160                Err(err) => return Err(err),
161            }
162        }
163        Statement::Insert(_) => {
164            let insert_query = InsertQuery::new(&query);
165            match insert_query {
166                Ok(payload) => {
167                    let table_name = payload.table_name;
168                    let columns = payload.columns;
169                    let values = payload.rows;
170
171                    // println!("table_name = {:?}\n cols = {:?}\n vals = {:?}", table_name, columns, values);
172                    // Checking if Table exists in Database
173                    match db.contains_table(table_name.to_string()) {
174                        true => {
175                            let db_table = db.get_table_mut(table_name.to_string()).unwrap();
176                            // Checking if columns on INSERT query exist on Table
177                            match columns
178                                .iter()
179                                .all(|column| db_table.contains_column(column.to_string()))
180                            {
181                                true => {
182                                    for value in &values {
183                                        // Checking if number of columns in query are the same as number of values
184                                        if columns.len() != value.len() {
185                                            return Err(SQLRiteError::Internal(format!(
186                                                "{} values for {} columns",
187                                                value.len(),
188                                                columns.len()
189                                            )));
190                                        }
191                                        db_table
192                                            .validate_unique_constraint(&columns, value)
193                                            .map_err(|err| {
194                                                SQLRiteError::Internal(format!(
195                                                    "Unique key constraint violation: {err}"
196                                                ))
197                                            })?;
198                                        db_table.insert_row(&columns, value)?;
199                                    }
200                                }
201                                false => {
202                                    return Err(SQLRiteError::Internal(
203                                        "Cannot insert, some of the columns do not exist"
204                                            .to_string(),
205                                    ));
206                                }
207                            }
208                            db_table.print_table_data();
209                        }
210                        false => {
211                            return Err(SQLRiteError::Internal("Table doesn't exist".to_string()));
212                        }
213                    }
214                }
215                Err(err) => return Err(err),
216            }
217
218            message = String::from("INSERT Statement executed.")
219        }
220        Statement::Query(_) => {
221            let select_query = SelectQuery::new(&query)?;
222            let (rendered, rows) = executor::execute_select(select_query, db)?;
223            // Print the result table above the status message so the REPL shows both.
224            print!("{rendered}");
225            message = format!(
226                "SELECT Statement executed. {rows} row{s} returned.",
227                s = if rows == 1 { "" } else { "s" }
228            );
229        }
230        Statement::Delete(_) => {
231            let rows = executor::execute_delete(&query, db)?;
232            message = format!(
233                "DELETE Statement executed. {rows} row{s} deleted.",
234                s = if rows == 1 { "" } else { "s" }
235            );
236        }
237        Statement::Update(_) => {
238            let rows = executor::execute_update(&query, db)?;
239            message = format!(
240                "UPDATE Statement executed. {rows} row{s} updated.",
241                s = if rows == 1 { "" } else { "s" }
242            );
243        }
244        Statement::CreateIndex(_) => {
245            let name = executor::execute_create_index(&query, db)?;
246            message = format!("CREATE INDEX '{name}' executed.");
247        }
248        _ => {
249            return Err(SQLRiteError::NotImplemented(
250                "SQL Statement not supported yet.".to_string(),
251            ));
252        }
253    };
254
255    // Auto-save: if the database is backed by a file AND no explicit
256    // transaction is open AND the statement changed state, flush to
257    // disk before returning. Inside a `BEGIN … COMMIT` block the
258    // mutations accumulate in memory (protected by the ROLLBACK
259    // snapshot) and land on disk in one shot when COMMIT runs.
260    //
261    // A failed save surfaces as an error — the in-memory state already
262    // mutated, so the caller should know disk is out of sync. The
263    // Pager held on `db` diffs against its last-committed snapshot,
264    // so only pages whose bytes actually changed are written.
265    if is_write_statement && db.source_path.is_some() && !db.in_transaction() {
266        let path = db.source_path.clone().unwrap();
267        pager::save_database(db, &path)?;
268    }
269
270    Ok(message)
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::sql::db::table::Value;
277
278    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
279    /// with three rows, for use in executor-level tests.
280    fn seed_users_table() -> Database {
281        let mut db = Database::new("tempdb".to_string());
282        process_command(
283            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
284            &mut db,
285        )
286        .expect("create table");
287        process_command(
288            "INSERT INTO users (name, age) VALUES ('alice', 30);",
289            &mut db,
290        )
291        .expect("insert alice");
292        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
293            .expect("insert bob");
294        process_command(
295            "INSERT INTO users (name, age) VALUES ('carol', 40);",
296            &mut db,
297        )
298        .expect("insert carol");
299        db
300    }
301
302    #[test]
303    fn process_command_select_all_test() {
304        let mut db = seed_users_table();
305        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
306        assert!(response.contains("3 rows returned"));
307    }
308
309    #[test]
310    fn process_command_select_where_test() {
311        let mut db = seed_users_table();
312        let response =
313            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
314        assert!(response.contains("2 rows returned"));
315    }
316
317    #[test]
318    fn process_command_select_eq_string_test() {
319        let mut db = seed_users_table();
320        let response =
321            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
322        assert!(response.contains("1 row returned"));
323    }
324
325    #[test]
326    fn process_command_select_limit_test() {
327        let mut db = seed_users_table();
328        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
329            .expect("select");
330        assert!(response.contains("2 rows returned"));
331    }
332
333    #[test]
334    fn process_command_select_unknown_table_test() {
335        let mut db = Database::new("tempdb".to_string());
336        let result = process_command("SELECT * FROM nope;", &mut db);
337        assert!(result.is_err());
338    }
339
340    #[test]
341    fn process_command_select_unknown_column_test() {
342        let mut db = seed_users_table();
343        let result = process_command("SELECT height FROM users;", &mut db);
344        assert!(result.is_err());
345    }
346
347    #[test]
348    fn process_command_insert_test() {
349        // Creating temporary database
350        let mut db = Database::new("tempdb".to_string());
351
352        // Creating temporary table for testing purposes
353        let query_statement = "CREATE TABLE users (
354            id INTEGER PRIMARY KEY,
355            name TEXT
356        );";
357        let dialect = SQLiteDialect {};
358        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
359        if ast.len() > 1 {
360            panic!("Expected a single query statement, but there are more then 1.")
361        }
362        let query = ast.pop().unwrap();
363        let create_query = CreateQuery::new(&query).unwrap();
364
365        // Inserting table into database
366        db.tables.insert(
367            create_query.table_name.to_string(),
368            Table::new(create_query),
369        );
370
371        // Inserting data into table
372        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
373        match process_command(&insert_query, &mut db) {
374            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
375            Err(err) => {
376                eprintln!("Error: {}", err);
377                assert!(false)
378            }
379        };
380    }
381
382    #[test]
383    fn process_command_insert_no_pk_test() {
384        // Creating temporary database
385        let mut db = Database::new("tempdb".to_string());
386
387        // Creating temporary table for testing purposes
388        let query_statement = "CREATE TABLE users (
389            name TEXT
390        );";
391        let dialect = SQLiteDialect {};
392        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
393        if ast.len() > 1 {
394            panic!("Expected a single query statement, but there are more then 1.")
395        }
396        let query = ast.pop().unwrap();
397        let create_query = CreateQuery::new(&query).unwrap();
398
399        // Inserting table into database
400        db.tables.insert(
401            create_query.table_name.to_string(),
402            Table::new(create_query),
403        );
404
405        // Inserting data into table
406        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
407        match process_command(&insert_query, &mut db) {
408            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
409            Err(err) => {
410                eprintln!("Error: {}", err);
411                assert!(false)
412            }
413        };
414    }
415
416    #[test]
417    fn process_command_delete_where_test() {
418        let mut db = seed_users_table();
419        let response =
420            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
421        assert!(response.contains("1 row deleted"));
422
423        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
424        assert!(remaining.contains("2 rows returned"));
425    }
426
427    #[test]
428    fn process_command_delete_all_test() {
429        let mut db = seed_users_table();
430        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
431        assert!(response.contains("3 rows deleted"));
432    }
433
434    #[test]
435    fn process_command_update_where_test() {
436        use crate::sql::db::table::Value;
437
438        let mut db = seed_users_table();
439        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
440            .expect("update");
441        assert!(response.contains("1 row updated"));
442
443        // Confirm the cell was actually rewritten.
444        let users = db.get_table("users".to_string()).unwrap();
445        let bob_rowid = users
446            .rowids()
447            .into_iter()
448            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
449            .expect("bob row must exist");
450        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
451    }
452
453    #[test]
454    fn process_command_update_unique_violation_test() {
455        let mut db = seed_users_table();
456        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
457        process_command(
458            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
459            &mut db,
460        )
461        .unwrap();
462        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
463        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
464
465        let result = process_command("UPDATE tags SET label = 'a' WHERE label = 'b';", &mut db);
466        assert!(result.is_err(), "expected UNIQUE violation, got {result:?}");
467    }
468
469    #[test]
470    fn process_command_insert_type_mismatch_returns_error_test() {
471        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
472        let mut db = Database::new("tempdb".to_string());
473        process_command(
474            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
475            &mut db,
476        )
477        .unwrap();
478        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
479        assert!(result.is_err(), "expected error, got {result:?}");
480    }
481
482    #[test]
483    fn process_command_insert_missing_integer_returns_error_test() {
484        // Non-PK INTEGER without a value should error (not panic on "Null".parse()).
485        let mut db = Database::new("tempdb".to_string());
486        process_command(
487            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
488            &mut db,
489        )
490        .unwrap();
491        let result = process_command("INSERT INTO items (id) VALUES (1);", &mut db);
492        assert!(result.is_err(), "expected error, got {result:?}");
493    }
494
495    #[test]
496    fn process_command_update_arith_test() {
497        use crate::sql::db::table::Value;
498
499        let mut db = seed_users_table();
500        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
501
502        let users = db.get_table("users".to_string()).unwrap();
503        let mut ages: Vec<i64> = users
504            .rowids()
505            .into_iter()
506            .filter_map(|r| match users.get_value("age", r) {
507                Some(Value::Integer(n)) => Some(n),
508                _ => None,
509            })
510            .collect();
511        ages.sort();
512        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
513    }
514
515    #[test]
516    fn process_command_select_arithmetic_where_test() {
517        let mut db = seed_users_table();
518        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
519        let response =
520            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
521        assert!(response.contains("2 rows returned"));
522    }
523
524    #[test]
525    fn process_command_divide_by_zero_test() {
526        let mut db = seed_users_table();
527        let result = process_command("SELECT age / 0 FROM users;", &mut db);
528        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
529        assert!(result.is_err());
530    }
531
532    #[test]
533    fn process_command_unsupported_statement_test() {
534        let mut db = Database::new("tempdb".to_string());
535        // Nothing in Phase 1 handles DROP.
536        let result = process_command("DROP TABLE users;", &mut db);
537        assert!(result.is_err());
538    }
539
540    #[test]
541    fn empty_input_is_a_noop_not_a_panic() {
542        // Regression for: desktop app pre-fills the textarea with a
543        // comment-only placeholder, and hitting Run used to panic because
544        // sqlparser produced zero statements and pop().unwrap() exploded.
545        let mut db = Database::new("t".to_string());
546        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
547            let result = process_command(input, &mut db);
548            assert!(result.is_ok(), "input {input:?} should not error");
549            let msg = result.unwrap();
550            assert!(msg.contains("No statement"), "got: {msg:?}");
551        }
552    }
553
554    #[test]
555    fn create_index_adds_explicit_index() {
556        let mut db = seed_users_table();
557        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
558            .expect("create index");
559        assert!(response.contains("users_age_idx"));
560
561        // The index should now be attached to the users table.
562        let users = db.get_table("users".to_string()).unwrap();
563        let idx = users
564            .index_by_name("users_age_idx")
565            .expect("index should exist after CREATE INDEX");
566        assert_eq!(idx.column_name, "age");
567        assert!(!idx.is_unique);
568    }
569
570    #[test]
571    fn create_unique_index_rejects_duplicate_existing_values() {
572        let mut db = seed_users_table();
573        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
574        // first so CREATE UNIQUE INDEX on age catches the conflict.
575        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
576        let result = process_command(
577            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
578            &mut db,
579        );
580        assert!(
581            result.is_err(),
582            "expected unique-index failure, got {result:?}"
583        );
584    }
585
586    #[test]
587    fn where_eq_on_indexed_column_uses_index_probe() {
588        // Build a table big enough that a full scan would be expensive,
589        // then rely on the index-probe fast path. This test verifies
590        // correctness (right rows returned); the perf win is implicit.
591        let mut db = Database::new("t".to_string());
592        process_command(
593            "CREATE TABLE big (id INTEGER PRIMARY KEY, tag TEXT);",
594            &mut db,
595        )
596        .unwrap();
597        process_command("CREATE INDEX big_tag_idx ON big (tag);", &mut db).unwrap();
598        for i in 1..=100 {
599            let tag = if i % 3 == 0 { "hot" } else { "cold" };
600            process_command(&format!("INSERT INTO big (tag) VALUES ('{tag}');"), &mut db).unwrap();
601        }
602        let response =
603            process_command("SELECT id FROM big WHERE tag = 'hot';", &mut db).expect("select");
604        // 1..=100 has 33 multiples of 3.
605        assert!(
606            response.contains("33 rows returned"),
607            "response was {response:?}"
608        );
609    }
610
611    #[test]
612    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
613        let mut db = seed_users_table();
614        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
615            .expect("select");
616        assert!(response.contains("1 row returned"));
617    }
618
619    #[test]
620    fn where_eq_literal_first_side_uses_index_probe() {
621        let mut db = seed_users_table();
622        // `'bob' = name` should hit the same path as `name = 'bob'`.
623        let response =
624            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
625        assert!(response.contains("1 row returned"));
626    }
627
628    #[test]
629    fn non_equality_where_still_falls_back_to_full_scan() {
630        // Sanity: range predicates bypass the optimizer and the full-scan
631        // path still returns correct results.
632        let mut db = seed_users_table();
633        let response =
634            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
635        assert!(response.contains("2 rows returned"));
636    }
637
638    // -------------------------------------------------------------------
639    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
640    // -------------------------------------------------------------------
641
642    #[test]
643    fn rollback_restores_pre_begin_in_memory_state() {
644        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
645        // The row must disappear from the live tables HashMap.
646        let mut db = seed_users_table();
647        let before = db.get_table("users".to_string()).unwrap().rowids().len();
648        assert_eq!(before, 3);
649
650        process_command("BEGIN;", &mut db).expect("BEGIN");
651        assert!(db.in_transaction());
652        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
653            .expect("INSERT inside txn");
654        // Mid-transaction read sees the new row.
655        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
656        assert_eq!(mid, 4);
657
658        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
659        assert!(!db.in_transaction());
660        let after = db.get_table("users".to_string()).unwrap().rowids().len();
661        assert_eq!(
662            after, 3,
663            "ROLLBACK should have restored the pre-BEGIN state"
664        );
665    }
666
667    #[test]
668    fn commit_keeps_mutations_and_clears_txn_flag() {
669        let mut db = seed_users_table();
670        process_command("BEGIN;", &mut db).expect("BEGIN");
671        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
672            .expect("INSERT inside txn");
673        process_command("COMMIT;", &mut db).expect("COMMIT");
674        assert!(!db.in_transaction());
675        let after = db.get_table("users".to_string()).unwrap().rowids().len();
676        assert_eq!(after, 4);
677    }
678
679    #[test]
680    fn rollback_undoes_update_and_delete_side_by_side() {
681        use crate::sql::db::table::Value;
682
683        let mut db = seed_users_table();
684        process_command("BEGIN;", &mut db).unwrap();
685        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
686        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
687        // Mid-txn: one row gone, others have age=999.
688        let users = db.get_table("users".to_string()).unwrap();
689        assert_eq!(users.rowids().len(), 2);
690        for r in users.rowids() {
691            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
692        }
693
694        process_command("ROLLBACK;", &mut db).unwrap();
695        let users = db.get_table("users".to_string()).unwrap();
696        assert_eq!(users.rowids().len(), 3);
697        // Original ages {30, 25, 40} — none should be 999.
698        for r in users.rowids() {
699            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
700        }
701    }
702
703    #[test]
704    fn nested_begin_is_rejected() {
705        let mut db = seed_users_table();
706        process_command("BEGIN;", &mut db).unwrap();
707        let err = process_command("BEGIN;", &mut db).unwrap_err();
708        assert!(
709            format!("{err}").contains("already open"),
710            "nested BEGIN should error; got: {err}"
711        );
712        // Still in the original transaction; a ROLLBACK clears it.
713        assert!(db.in_transaction());
714        process_command("ROLLBACK;", &mut db).unwrap();
715    }
716
717    #[test]
718    fn orphan_commit_and_rollback_are_rejected() {
719        let mut db = seed_users_table();
720        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
721        assert!(format!("{commit_err}").contains("no transaction"));
722        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
723        assert!(format!("{rollback_err}").contains("no transaction"));
724    }
725
726    #[test]
727    fn error_inside_transaction_keeps_txn_open() {
728        // A bad INSERT inside a txn doesn't commit or abort automatically —
729        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
730        // isn't modeled here.
731        let mut db = seed_users_table();
732        process_command("BEGIN;", &mut db).unwrap();
733        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
734        assert!(err.is_err());
735        assert!(db.in_transaction(), "txn should stay open after error");
736        process_command("ROLLBACK;", &mut db).unwrap();
737    }
738
739    /// Builds a file-backed Database at a unique temp path, with the
740    /// schema seeded and `source_path` set so subsequent process_command
741    /// calls auto-save. Returns (path, db). Drop the db before deleting
742    /// the files.
743    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
744        use crate::sql::pager::{open_database, save_database};
745        let mut p = std::env::temp_dir();
746        let pid = std::process::id();
747        let nanos = std::time::SystemTime::now()
748            .duration_since(std::time::UNIX_EPOCH)
749            .map(|d| d.as_nanos())
750            .unwrap_or(0);
751        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
752
753        // Seed the file, then reopen to get a source_path-attached db
754        // (save_database alone doesn't attach a fresh pager to a db
755        // whose source_path was None before the call).
756        {
757            let mut seed = Database::new("t".to_string());
758            process_command(schema, &mut seed).unwrap();
759            save_database(&mut seed, &p).unwrap();
760        }
761        let db = open_database(&p, "t".to_string()).unwrap();
762        (p, db)
763    }
764
765    fn cleanup_file(path: &std::path::Path) {
766        let _ = std::fs::remove_file(path);
767        let mut wal = path.as_os_str().to_owned();
768        wal.push("-wal");
769        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
770    }
771
772    #[test]
773    fn begin_commit_rollback_round_trip_through_disk() {
774        // File-backed DB: commit inside a transaction must actually
775        // persist. ROLLBACK inside a *later* transaction must not
776        // un-do the previously-committed changes.
777        use crate::sql::pager::open_database;
778
779        let (path, mut db) = seed_file_backed(
780            "roundtrip",
781            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
782        );
783
784        // Transaction 1: insert two rows, commit.
785        process_command("BEGIN;", &mut db).unwrap();
786        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
787        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
788        process_command("COMMIT;", &mut db).unwrap();
789
790        // Transaction 2: insert another, roll back.
791        process_command("BEGIN;", &mut db).unwrap();
792        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
793        process_command("ROLLBACK;", &mut db).unwrap();
794
795        drop(db); // release pager lock
796
797        let reopened = open_database(&path, "t".to_string()).unwrap();
798        let notes = reopened.get_table("notes".to_string()).unwrap();
799        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
800
801        drop(reopened);
802        cleanup_file(&path);
803    }
804
805    #[test]
806    fn write_inside_transaction_does_not_autosave() {
807        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
808        // the WAL until COMMIT. We prove it by checking the WAL file
809        // size before vs during the transaction.
810        let (path, mut db) =
811            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
812
813        let mut wal_path = path.as_os_str().to_owned();
814        wal_path.push("-wal");
815        let wal_path = std::path::PathBuf::from(wal_path);
816        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
817
818        process_command("BEGIN;", &mut db).unwrap();
819        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
820        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
821
822        // Mid-transaction: WAL must be unchanged — no auto-save fired.
823        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
824        assert_eq!(
825            frames_before, frames_mid,
826            "WAL should not grow during an open transaction"
827        );
828
829        process_command("COMMIT;", &mut db).unwrap();
830
831        drop(db); // release pager lock
832        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
833        assert_eq!(
834            fresh.get_table("t".to_string()).unwrap().rowids().len(),
835            2,
836            "COMMIT should have persisted both inserted rows"
837        );
838        drop(fresh);
839        cleanup_file(&path);
840    }
841
842    #[test]
843    fn rollback_undoes_create_table() {
844        // Schema DDL inside a txn: ROLLBACK must make the new table
845        // disappear. The txn snapshot captures db.tables as of BEGIN,
846        // and ROLLBACK reassigns tables from that snapshot, so a table
847        // created mid-transaction has no entry in the snapshot.
848        let mut db = seed_users_table();
849        assert_eq!(db.tables.len(), 1);
850
851        process_command("BEGIN;", &mut db).unwrap();
852        process_command(
853            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
854            &mut db,
855        )
856        .unwrap();
857        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
858        assert_eq!(db.tables.len(), 2);
859
860        process_command("ROLLBACK;", &mut db).unwrap();
861        assert_eq!(
862            db.tables.len(),
863            1,
864            "CREATE TABLE should have been rolled back"
865        );
866        assert!(db.get_table("dropme".to_string()).is_err());
867    }
868
869    #[test]
870    fn rollback_restores_secondary_index_state() {
871        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
872        // column must also clean up the index, otherwise a re-insert of
873        // the same value would spuriously collide.
874        let mut db = Database::new("t".to_string());
875        process_command(
876            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
877            &mut db,
878        )
879        .unwrap();
880        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
881
882        process_command("BEGIN;", &mut db).unwrap();
883        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
884        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
885        process_command("ROLLBACK;", &mut db).unwrap();
886
887        // Re-inserting 'b@x' after rollback must succeed — if the index
888        // wasn't properly restored, it would think 'b@x' is still a
889        // collision and fail with a UNIQUE violation.
890        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
891        assert!(
892            reinsert.is_ok(),
893            "re-insert after rollback should succeed, got {reinsert:?}"
894        );
895    }
896
897    #[test]
898    fn rollback_restores_last_rowid_counter() {
899        // Rowids allocated inside a rolled-back transaction should be
900        // reusable. The snapshot restores Table::last_rowid, so the
901        // next insert picks up where the pre-BEGIN state left off.
902        use crate::sql::db::table::Value;
903
904        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
905        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
906
907        process_command("BEGIN;", &mut db).unwrap();
908        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
909        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
910        process_command("ROLLBACK;", &mut db).unwrap();
911
912        let post = db.get_table("users".to_string()).unwrap().last_rowid;
913        assert_eq!(pre, post, "last_rowid must roll back with the snapshot");
914
915        // Confirm: the next insert reuses rowid pre+1.
916        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
917        let users = db.get_table("users".to_string()).unwrap();
918        let d_rowid = users
919            .rowids()
920            .into_iter()
921            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
922            .expect("d row must exist");
923        assert_eq!(d_rowid, pre + 1);
924    }
925
926    #[test]
927    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
928        // In-memory DB (no source_path): COMMIT must still work — just
929        // no disk flush. Covers the `if let Some(path) = …` branch
930        // where the guard falls through without calling save_database.
931        let mut db = seed_users_table(); // no source_path
932        assert!(db.source_path.is_none());
933
934        process_command("BEGIN;", &mut db).unwrap();
935        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
936        process_command("COMMIT;", &mut db).unwrap();
937
938        assert!(!db.in_transaction());
939        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
940    }
941
942    #[test]
943    fn failed_commit_auto_rolls_back_in_memory_state() {
944        // Data-safety regression: on COMMIT save failure we must auto-
945        // rollback the in-memory state. Otherwise, any subsequent
946        // non-transactional statement would auto-save the partial
947        // mid-transaction work, silently publishing uncommitted
948        // changes to disk.
949        //
950        // We simulate a save failure by making the WAL sidecar path
951        // unavailable mid-transaction: after BEGIN, we take an
952        // exclusive OS lock on the WAL via a second File handle,
953        // forcing the next save to fail when it tries to append.
954        //
955        // Simpler repro: point source_path at a directory (not a file).
956        // `OpenOptions::open` will fail with EISDIR on save.
957        use crate::sql::pager::save_database;
958
959        // Seed a file-backed db.
960        let (path, mut db) = seed_file_backed(
961            "failcommit",
962            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
963        );
964
965        // Prime one committed row so we have a baseline.
966        process_command("INSERT INTO notes (body) VALUES ('before');", &mut db).unwrap();
967
968        // Open a new txn and add a row.
969        process_command("BEGIN;", &mut db).unwrap();
970        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
971        assert_eq!(
972            db.get_table("notes".to_string()).unwrap().rowids().len(),
973            2,
974            "inflight row visible mid-txn"
975        );
976
977        // Swap source_path to a path that will fail on open. A
978        // directory is a reliable failure mode — Pager::open on a
979        // directory errors with an I/O error.
980        let orig_source = db.source_path.clone();
981        let orig_pager = db.pager.take();
982        db.source_path = Some(std::env::temp_dir());
983
984        let commit_result = process_command("COMMIT;", &mut db);
985        assert!(commit_result.is_err(), "commit must fail");
986        let err_str = format!("{}", commit_result.unwrap_err());
987        assert!(
988            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
989            "error must surface auto-rollback; got: {err_str}"
990        );
991
992        // Auto-rollback fired: the inflight row is gone, the txn flag
993        // is cleared, and a follow-up non-txn statement won't leak
994        // stale state.
995        assert!(
996            !db.in_transaction(),
997            "txn must be cleared after auto-rollback"
998        );
999        assert_eq!(
1000            db.get_table("notes".to_string()).unwrap().rowids().len(),
1001            1,
1002            "inflight row must be rolled back"
1003        );
1004
1005        // Restore the real source_path + pager and verify a clean
1006        // subsequent write goes through.
1007        db.source_path = orig_source;
1008        db.pager = orig_pager;
1009        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1010        drop(db);
1011
1012        // Reopen and assert only 'before' + 'after' landed on disk.
1013        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1014        let notes = reopened.get_table("notes".to_string()).unwrap();
1015        assert_eq!(notes.rowids().len(), 2);
1016        // Ensure no leaked save_database partial happened.
1017        let _ = save_database; // silence unused-import lint if any
1018        drop(reopened);
1019        cleanup_file(&path);
1020    }
1021
1022    #[test]
1023    fn begin_on_read_only_is_rejected() {
1024        use crate::sql::pager::{open_database_read_only, save_database};
1025
1026        let path = {
1027            let mut p = std::env::temp_dir();
1028            let pid = std::process::id();
1029            let nanos = std::time::SystemTime::now()
1030                .duration_since(std::time::UNIX_EPOCH)
1031                .map(|d| d.as_nanos())
1032                .unwrap_or(0);
1033            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1034            p
1035        };
1036        {
1037            let mut seed = Database::new("t".to_string());
1038            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
1039            save_database(&mut seed, &path).unwrap();
1040        }
1041
1042        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1043        let err = process_command("BEGIN;", &mut ro).unwrap_err();
1044        assert!(
1045            format!("{err}").contains("read-only"),
1046            "BEGIN on RO db should surface read-only; got: {err}"
1047        );
1048        assert!(!ro.in_transaction());
1049
1050        let _ = std::fs::remove_file(&path);
1051        let mut wal = path.as_os_str().to_owned();
1052        wal.push("-wal");
1053        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1054    }
1055
1056    #[test]
1057    fn read_only_database_rejects_mutations_before_touching_state() {
1058        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1059        // must error *before* the row is added to the in-memory table.
1060        // Otherwise the user sees a rendered result table with the
1061        // phantom row, followed by the auto-save error — UX rot and a
1062        // state-drift risk.
1063        use crate::sql::pager::open_database_read_only;
1064
1065        let mut seed = Database::new("t".to_string());
1066        process_command(
1067            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1068            &mut seed,
1069        )
1070        .unwrap();
1071        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1072
1073        let path = {
1074            let mut p = std::env::temp_dir();
1075            let pid = std::process::id();
1076            let nanos = std::time::SystemTime::now()
1077                .duration_since(std::time::UNIX_EPOCH)
1078                .map(|d| d.as_nanos())
1079                .unwrap_or(0);
1080            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1081            p
1082        };
1083        crate::sql::pager::save_database(&mut seed, &path).unwrap();
1084        drop(seed);
1085
1086        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1087        let notes_before = ro.get_table("notes".to_string()).unwrap().rowids().len();
1088
1089        for stmt in [
1090            "INSERT INTO notes (body) VALUES ('beta');",
1091            "UPDATE notes SET body = 'x';",
1092            "DELETE FROM notes;",
1093            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1094            "CREATE INDEX notes_body ON notes (body);",
1095        ] {
1096            let err = process_command(stmt, &mut ro).unwrap_err();
1097            assert!(
1098                format!("{err}").contains("read-only"),
1099                "stmt {stmt:?} should surface a read-only error; got: {err}"
1100            );
1101        }
1102
1103        // Nothing mutated: same row count as before, and SELECTs still work.
1104        let notes_after = ro.get_table("notes".to_string()).unwrap().rowids().len();
1105        assert_eq!(notes_before, notes_after);
1106        let sel = process_command("SELECT * FROM notes;", &mut ro).expect("select on RO must work");
1107        assert!(sel.contains("1 row returned"));
1108
1109        // Cleanup.
1110        drop(ro);
1111        let _ = std::fs::remove_file(&path);
1112        let mut wal = path.as_os_str().to_owned();
1113        wal.push("-wal");
1114        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1115    }
1116
1117    // -----------------------------------------------------------------
1118    // Phase 7a — VECTOR(N) end-to-end through process_command
1119    // -----------------------------------------------------------------
1120
1121    #[test]
1122    fn vector_create_table_and_insert_basic() {
1123        let mut db = Database::new("tempdb".to_string());
1124        process_command(
1125            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1126            &mut db,
1127        )
1128        .expect("create table with VECTOR(3)");
1129        process_command(
1130            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1131            &mut db,
1132        )
1133        .expect("insert vector");
1134
1135        // process_command returns a status string; the rendered table
1136        // goes to stdout via print_table. Verify state by inspecting
1137        // the database directly.
1138        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1139        assert!(sel.contains("1 row returned"));
1140
1141        let docs = db.get_table("docs".to_string()).expect("docs table");
1142        let rowids = docs.rowids();
1143        assert_eq!(rowids.len(), 1);
1144        match docs.get_value("embedding", rowids[0]) {
1145            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1146            other => panic!("expected Value::Vector(...), got {other:?}"),
1147        }
1148    }
1149
1150    #[test]
1151    fn vector_dim_mismatch_at_insert_is_clean_error() {
1152        let mut db = Database::new("tempdb".to_string());
1153        process_command(
1154            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1155            &mut db,
1156        )
1157        .expect("create table");
1158
1159        // Too few elements.
1160        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1161            .unwrap_err();
1162        let msg = format!("{err}");
1163        assert!(
1164            msg.to_lowercase().contains("dimension")
1165                && msg.contains("declared 3")
1166                && msg.contains("got 2"),
1167            "expected clear dim-mismatch error, got: {msg}"
1168        );
1169
1170        // Too many elements.
1171        let err = process_command(
1172            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1173            &mut db,
1174        )
1175        .unwrap_err();
1176        assert!(
1177            format!("{err}").contains("got 5"),
1178            "expected dim-mismatch error mentioning got 5, got: {err}"
1179        );
1180    }
1181
1182    #[test]
1183    fn vector_create_table_rejects_missing_dim() {
1184        let mut db = Database::new("tempdb".to_string());
1185        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1186        // empty args from sqlparser, OR may not parse as Custom at all
1187        // depending on dialect. Either way, the column shouldn't end up
1188        // as a usable Vector type. Accept any error here — the precise
1189        // message is parser-version-dependent.
1190        let result = process_command(
1191            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1192            &mut db,
1193        );
1194        assert!(
1195            result.is_err(),
1196            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1197        );
1198    }
1199
1200    #[test]
1201    fn vector_create_table_rejects_zero_dim() {
1202        let mut db = Database::new("tempdb".to_string());
1203        let err = process_command(
1204            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1205            &mut db,
1206        )
1207        .unwrap_err();
1208        let msg = format!("{err}");
1209        assert!(
1210            msg.to_lowercase().contains("vector"),
1211            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1212        );
1213    }
1214
1215    #[test]
1216    fn vector_high_dim_works() {
1217        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1218        // smoke test — if cell encoding mishandles the size, this fails.
1219        let mut db = Database::new("tempdb".to_string());
1220        process_command(
1221            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1222            &mut db,
1223        )
1224        .expect("create table VECTOR(384)");
1225
1226        let lit = format!(
1227            "[{}]",
1228            (0..384)
1229                .map(|i| format!("{}", i as f32 * 0.001))
1230                .collect::<Vec<_>>()
1231                .join(",")
1232        );
1233        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
1234        process_command(&sql, &mut db).expect("insert 384-dim vector");
1235
1236        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1237        assert!(sel.contains("1 row returned"));
1238    }
1239
1240    #[test]
1241    fn vector_multiple_rows() {
1242        // Three rows with different vectors — exercises the Row::Vector
1243        // BTreeMap path (not just single-row insertion).
1244        let mut db = Database::new("tempdb".to_string());
1245        process_command(
1246            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1247            &mut db,
1248        )
1249        .expect("create");
1250        for i in 0..3 {
1251            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
1252            process_command(&sql, &mut db).expect("insert");
1253        }
1254        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1255        assert!(sel.contains("3 rows returned"));
1256
1257        // Verify each vector round-tripped correctly via direct DB inspection.
1258        let docs = db.get_table("docs".to_string()).expect("docs table");
1259        let rowids = docs.rowids();
1260        assert_eq!(rowids.len(), 3);
1261        let mut vectors: Vec<Vec<f32>> = rowids
1262            .iter()
1263            .filter_map(|r| match docs.get_value("e", *r) {
1264                Some(Value::Vector(v)) => Some(v),
1265                _ => None,
1266            })
1267            .collect();
1268        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
1269        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1270        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1271        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1272    }
1273
1274    // -----------------------------------------------------------------
1275    // Phase 7b — vector distance functions through process_command
1276    // -----------------------------------------------------------------
1277
1278    /// Builds a 3-row docs table with 2-dim vectors aligned along the
1279    /// axes so the expected distances are easy to reason about:
1280    ///   id=1: [1, 0]
1281    ///   id=2: [0, 1]
1282    ///   id=3: [1, 1]
1283    fn seed_vector_docs() -> Database {
1284        let mut db = Database::new("tempdb".to_string());
1285        process_command(
1286            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1287            &mut db,
1288        )
1289        .expect("create");
1290        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1291        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1292        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1293        db
1294    }
1295
1296    #[test]
1297    fn vec_distance_l2_in_where_filters_correctly() {
1298        // Distance from [1,0]:
1299        //   id=1 [1,0]: 0
1300        //   id=2 [0,1]: √2 ≈ 1.414
1301        //   id=3 [1,1]: 1
1302        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
1303        let mut db = seed_vector_docs();
1304        let resp = process_command(
1305            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
1306            &mut db,
1307        )
1308        .expect("select");
1309        assert!(
1310            resp.contains("2 rows returned"),
1311            "expected 2 rows, got: {resp}"
1312        );
1313    }
1314
1315    #[test]
1316    fn vec_distance_cosine_in_where() {
1317        // [1,0] vs [1,0]: cosine distance = 0
1318        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
1319        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
1320        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
1321        let mut db = seed_vector_docs();
1322        let resp = process_command(
1323            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
1324            &mut db,
1325        )
1326        .expect("select");
1327        assert!(
1328            resp.contains("2 rows returned"),
1329            "expected 2 rows, got: {resp}"
1330        );
1331    }
1332
1333    #[test]
1334    fn vec_distance_dot_negated() {
1335        // [1,0]·[1,0] = 1 → -1
1336        // [1,0]·[0,1] = 0 → 0
1337        // [1,0]·[1,1] = 1 → -1
1338        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
1339        let mut db = seed_vector_docs();
1340        let resp = process_command(
1341            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
1342            &mut db,
1343        )
1344        .expect("select");
1345        assert!(
1346            resp.contains("2 rows returned"),
1347            "expected 2 rows, got: {resp}"
1348        );
1349    }
1350
1351    #[test]
1352    fn knn_via_order_by_distance_limit() {
1353        // Classic KNN shape: ORDER BY distance LIMIT k.
1354        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
1355        // LIMIT 2 should return id=1 then id=3 in that order.
1356        let mut db = seed_vector_docs();
1357        let resp = process_command(
1358            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
1359            &mut db,
1360        )
1361        .expect("select");
1362        assert!(
1363            resp.contains("2 rows returned"),
1364            "expected 2 rows, got: {resp}"
1365        );
1366    }
1367
1368    #[test]
1369    fn distance_function_dim_mismatch_errors() {
1370        // 2-dim column queried with a 3-dim probe → clean error.
1371        let mut db = seed_vector_docs();
1372        let err = process_command(
1373            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
1374            &mut db,
1375        )
1376        .unwrap_err();
1377        let msg = format!("{err}");
1378        assert!(
1379            msg.to_lowercase().contains("dimension")
1380                && msg.contains("lhs=2")
1381                && msg.contains("rhs=3"),
1382            "expected dim mismatch error, got: {msg}"
1383        );
1384    }
1385
1386    #[test]
1387    fn unknown_function_errors_with_name() {
1388        // Use the function in WHERE, not projection — the projection
1389        // parser still requires bare column references; function calls
1390        // there are a future enhancement (with `AS alias` support).
1391        let mut db = seed_vector_docs();
1392        let err = process_command(
1393            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
1394            &mut db,
1395        )
1396        .unwrap_err();
1397        let msg = format!("{err}");
1398        assert!(
1399            msg.contains("vec_does_not_exist"),
1400            "expected error mentioning function name, got: {msg}"
1401        );
1402    }
1403}