1pub mod db;
2pub mod executor;
3pub mod fts;
4pub mod hnsw;
5pub mod pager;
6pub mod parser;
7use 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#[derive(Debug, Clone)]
64pub struct CommandOutput {
65 pub status: String,
66 pub rendered: Option<String>,
67}
68
69pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
78 process_command_with_render(query, db).map(|o| o.status)
79}
80
81pub 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 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 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 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 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 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 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 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 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 match db.contains_table(table_name.to_string()) {
231 true => {
232 let db_table = db.get_table_mut(table_name.to_string()).unwrap();
233 match columns
235 .iter()
236 .all(|column| db_table.contains_column(column.to_string()))
237 {
238 true => {
239 for value in &values {
240 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 }
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 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 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 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 let mut db = Database::new("tempdb".to_string());
420
421 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 db.tables.insert(
436 create_query.table_name.to_string(),
437 Table::new(create_query),
438 );
439
440 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 let mut db = Database::new("tempdb".to_string());
455
456 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 db.tables.insert(
470 create_query.table_name.to_string(),
471 Table::new(create_query),
472 );
473
474 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 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 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 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 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]); }
583
584 #[test]
585 fn process_command_select_arithmetic_where_test() {
586 let mut db = seed_users_table();
587 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 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 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 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 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 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 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 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 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 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 #[test]
712 fn rollback_restores_pre_begin_in_memory_state() {
713 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 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 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 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 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 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 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 {
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 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 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 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); 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 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 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); 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 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 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 process_command("ROLLBACK;", &mut db).unwrap();
955
956 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 use crate::sql::db::table::Value;
972
973 let mut db = seed_users_table(); 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(); process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); 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 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 let mut db = seed_users_table(); 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 use crate::sql::pager::save_database;
1027
1028 let (path, mut db) = seed_file_backed(
1030 "failcommit",
1031 "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1032 );
1033
1034 process_command("INSERT INTO notes (body) VALUES ('before');", &mut db).unwrap();
1036
1037 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 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 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 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 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 let _ = save_database; 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 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 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 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 #[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 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 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 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 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 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 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 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 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]", "[2.0, 0.0]", "[0.0, 3.0]", "[1.0, 4.0]", "[10.0, 10.0]", ] {
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 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 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 let mut db = seed_hnsw_table();
1408 process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1409
1410 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 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(); process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); 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 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 #[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 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 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", "rust web framework", "go embedded systems", "python web framework", "rust rust rust embedded power", ] {
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 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 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 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 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 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 assert_eq!(table.fts_indexes[0].index.len(), 6);
1648 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 let mut db = seed_fts_table();
1731 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1732 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}