1#[allow(dead_code)]
33pub mod allocator;
34#[allow(dead_code)]
35pub mod cell;
36pub mod file;
37#[allow(dead_code)]
38pub mod freelist;
39#[allow(dead_code)]
40pub mod fts_cell;
41pub mod header;
42#[allow(dead_code)]
43pub mod hnsw_cell;
44#[allow(dead_code)]
45pub mod index_cell;
46#[allow(dead_code)]
47pub mod interior_page;
48pub mod overflow;
49pub mod page;
50pub mod pager;
51#[allow(dead_code)]
52pub mod table_page;
53#[allow(dead_code)]
54pub mod varint;
55#[allow(dead_code)]
56pub mod wal;
57
58use std::collections::{BTreeMap, HashMap};
59use std::path::Path;
60use std::sync::{Arc, Mutex};
61
62use crate::sql::dialect::SqlriteDialect;
63use sqlparser::parser::Parser;
64
65use crate::error::{Result, SQLRiteError};
66use crate::sql::db::database::Database;
67use crate::sql::db::secondary_index::{IndexOrigin, SecondaryIndex};
68use crate::sql::db::table::{Column, DataType, Row, Table, Value};
69use crate::sql::hnsw::DistanceMetric;
70use crate::sql::pager::cell::Cell;
71use crate::sql::pager::header::DbHeader;
72use crate::sql::pager::index_cell::IndexCell;
73use crate::sql::pager::interior_page::{InteriorCell, InteriorPage};
74use crate::sql::pager::overflow::{
75 OVERFLOW_THRESHOLD, OverflowRef, PagedEntry, read_overflow_chain, write_overflow_chain,
76};
77use crate::sql::pager::page::{PAGE_HEADER_SIZE, PAGE_SIZE, PAYLOAD_PER_PAGE, PageType};
78use crate::sql::pager::pager::Pager;
79use crate::sql::pager::table_page::TablePage;
80use crate::sql::parser::create::CreateQuery;
81
82pub use crate::sql::pager::pager::AccessMode;
85
86pub const MASTER_TABLE_NAME: &str = "sqlrite_master";
89
90pub fn open_database(path: &Path, db_name: String) -> Result<Database> {
93 open_database_with_mode(path, db_name, AccessMode::ReadWrite)
94}
95
96pub fn open_database_read_only(path: &Path, db_name: String) -> Result<Database> {
102 open_database_with_mode(path, db_name, AccessMode::ReadOnly)
103}
104
105pub fn open_database_with_mode(path: &Path, db_name: String, mode: AccessMode) -> Result<Database> {
109 let pager = Pager::open_with_mode(path, mode)?;
110
111 let mut master = build_empty_master_table();
113 load_table_rows(&pager, &mut master, pager.header().schema_root_page)?;
114
115 let mut db = Database::new(db_name);
122 let mut index_rows: Vec<IndexCatalogRow> = Vec::new();
123
124 for rowid in master.rowids() {
125 let ty = take_text(&master, "type", rowid)?;
126 let name = take_text(&master, "name", rowid)?;
127 let sql = take_text(&master, "sql", rowid)?;
128 let rootpage = take_integer(&master, "rootpage", rowid)? as u32;
129 let last_rowid = take_integer(&master, "last_rowid", rowid)?;
130
131 match ty.as_str() {
132 "table" => {
133 let (parsed_name, columns) = parse_create_sql(&sql)?;
134 if parsed_name != name {
135 return Err(SQLRiteError::Internal(format!(
136 "sqlrite_master row '{name}' carries SQL for '{parsed_name}' — corrupt catalog?"
137 )));
138 }
139 let mut table = build_empty_table(&name, columns, last_rowid);
140 if rootpage != 0 {
141 load_table_rows(&pager, &mut table, rootpage)?;
142 }
143 if last_rowid > table.last_rowid {
144 table.last_rowid = last_rowid;
145 }
146 db.tables.insert(name, table);
147 }
148 "index" => {
149 index_rows.push(IndexCatalogRow {
150 name,
151 sql,
152 rootpage,
153 });
154 }
155 other => {
156 return Err(SQLRiteError::Internal(format!(
157 "sqlrite_master row '{name}' has unknown type '{other}'"
158 )));
159 }
160 }
161 }
162
163 for row in index_rows {
173 if create_index_sql_uses_hnsw(&row.sql) {
174 rebuild_hnsw_index(&mut db, &pager, &row)?;
175 } else if create_index_sql_uses_fts(&row.sql) {
176 rebuild_fts_index(&mut db, &pager, &row)?;
177 } else {
178 attach_index(&mut db, &pager, row)?;
179 }
180 }
181
182 replay_mvcc_into_db(&mut db, &pager)?;
198
199 db.source_path = Some(path.to_path_buf());
200 db.pager = Some(pager);
201 Ok(db)
202}
203
204fn replay_mvcc_into_db(db: &mut Database, pager: &Pager) -> Result<()> {
214 use crate::mvcc::RowVersion;
215
216 let mut clock_seed = pager.clock_high_water();
217 for batch in pager.recovered_mvcc_commits() {
218 if batch.commit_ts > clock_seed {
219 clock_seed = batch.commit_ts;
220 }
221 for rec in &batch.records {
222 let version = RowVersion::committed(batch.commit_ts, rec.payload.clone());
223 db.mv_store
224 .push_committed(rec.row.clone(), version)
225 .map_err(|e| {
226 SQLRiteError::Internal(format!(
227 "WAL MVCC replay: push_committed failed for {}/{}: {e}",
228 rec.row.table, rec.row.rowid,
229 ))
230 })?;
231 }
232 }
233 if clock_seed > 0 {
234 db.mvcc_clock.observe(clock_seed);
235 }
236 Ok(())
237}
238
239struct IndexCatalogRow {
242 name: String,
243 sql: String,
244 rootpage: u32,
245}
246
247pub fn save_database(db: &mut Database, path: &Path) -> Result<()> {
260 save_database_with_mode(db, path, false)
261}
262
263pub fn vacuum_database(db: &mut Database, path: &Path) -> Result<()> {
271 save_database_with_mode(db, path, true)
272}
273
274fn save_database_with_mode(db: &mut Database, path: &Path, compact: bool) -> Result<()> {
279 rebuild_dirty_hnsw_indexes(db)?;
284 rebuild_dirty_fts_indexes(db);
286
287 let same_path = db.source_path.as_deref() == Some(path);
288 let mut pager = if same_path {
289 match db.pager.take() {
290 Some(p) => p,
291 None if path.exists() => Pager::open(path)?,
292 None => Pager::create(path)?,
293 }
294 } else if path.exists() {
295 Pager::open(path)?
296 } else {
297 Pager::create(path)?
298 };
299
300 let old_header = pager.header();
304 let old_live: std::collections::HashSet<u32> = (1..old_header.page_count).collect();
305
306 let (old_free_leaves, old_free_trunks) = if compact || old_header.freelist_head == 0 {
309 (Vec::new(), Vec::new())
310 } else {
311 crate::sql::pager::freelist::read_freelist(&pager, old_header.freelist_head)?
312 };
313
314 let old_rootpages = if compact {
318 HashMap::new()
319 } else {
320 read_old_rootpages(&pager, old_header.schema_root_page)?
321 };
322
323 let old_preferred_pages: HashMap<(String, String), Vec<u32>> = if compact {
337 HashMap::new()
338 } else {
339 let mut map: HashMap<(String, String), Vec<u32>> = HashMap::new();
340 for ((kind, name), &root) in &old_rootpages {
341 let follow = kind == "table";
345 let pages = collect_pages_for_btree(&pager, root, follow)?;
346 map.insert((kind.clone(), name.clone()), pages);
347 }
348 map
349 };
350 let old_master_pages: Vec<u32> = if compact || old_header.schema_root_page == 0 {
351 Vec::new()
352 } else {
353 collect_pages_for_btree(
354 &pager,
355 old_header.schema_root_page,
356 true,
357 )?
358 };
359
360 pager.clear_staged();
361
362 use std::collections::VecDeque;
365 let initial_freelist: VecDeque<u32> = if compact {
366 VecDeque::new()
367 } else {
368 crate::sql::pager::freelist::freelist_to_deque(old_free_leaves.clone())
369 };
370 let mut alloc = crate::sql::pager::allocator::PageAllocator::new(initial_freelist, 1);
371
372 let mut master_rows: Vec<CatalogEntry> = Vec::new();
375
376 let mut table_names: Vec<&String> = db.tables.keys().collect();
377 table_names.sort();
378 for name in table_names {
379 if name == MASTER_TABLE_NAME {
380 return Err(SQLRiteError::Internal(format!(
381 "user table cannot be named '{MASTER_TABLE_NAME}' (reserved)"
382 )));
383 }
384 if !compact {
385 if let Some(prev) = old_preferred_pages.get(&("table".to_string(), name.to_string())) {
386 alloc.set_preferred(prev.clone());
387 }
388 }
389 let table = &db.tables[name];
390 let rootpage = stage_table_btree(&mut pager, table, &mut alloc)?;
391 alloc.finish_preferred();
392 master_rows.push(CatalogEntry {
393 kind: "table".into(),
394 name: name.clone(),
395 sql: table_to_create_sql(table),
396 rootpage,
397 last_rowid: table.last_rowid,
398 });
399 }
400
401 let mut index_entries: Vec<(&Table, &SecondaryIndex)> = Vec::new();
404 for table in db.tables.values() {
405 for idx in &table.secondary_indexes {
406 index_entries.push((table, idx));
407 }
408 }
409 index_entries
410 .sort_by(|(ta, ia), (tb, ib)| ta.tb_name.cmp(&tb.tb_name).then(ia.name.cmp(&ib.name)));
411 for (_table, idx) in index_entries {
412 if !compact {
413 if let Some(prev) =
414 old_preferred_pages.get(&("index".to_string(), idx.name.to_string()))
415 {
416 alloc.set_preferred(prev.clone());
417 }
418 }
419 let rootpage = stage_index_btree(&mut pager, idx, &mut alloc)?;
420 alloc.finish_preferred();
421 master_rows.push(CatalogEntry {
422 kind: "index".into(),
423 name: idx.name.clone(),
424 sql: idx.synthesized_sql(),
425 rootpage,
426 last_rowid: 0,
427 });
428 }
429
430 let mut hnsw_entries: Vec<(&Table, &crate::sql::db::table::HnswIndexEntry)> = Vec::new();
439 for table in db.tables.values() {
440 for entry in &table.hnsw_indexes {
441 hnsw_entries.push((table, entry));
442 }
443 }
444 hnsw_entries
445 .sort_by(|(ta, ea), (tb, eb)| ta.tb_name.cmp(&tb.tb_name).then(ea.name.cmp(&eb.name)));
446 for (table, entry) in hnsw_entries {
447 if !compact {
448 if let Some(prev) =
449 old_preferred_pages.get(&("index".to_string(), entry.name.to_string()))
450 {
451 alloc.set_preferred(prev.clone());
452 }
453 }
454 let rootpage = stage_hnsw_btree(&mut pager, &entry.index, &mut alloc)?;
455 alloc.finish_preferred();
456 master_rows.push(CatalogEntry {
457 kind: "index".into(),
458 name: entry.name.clone(),
459 sql: synthesize_hnsw_create_index_sql(
460 &entry.name,
461 &table.tb_name,
462 &entry.column_name,
463 entry.metric,
464 ),
465 rootpage,
466 last_rowid: 0,
467 });
468 }
469
470 let mut fts_entries: Vec<(&Table, &crate::sql::db::table::FtsIndexEntry)> = Vec::new();
480 for table in db.tables.values() {
481 for entry in &table.fts_indexes {
482 fts_entries.push((table, entry));
483 }
484 }
485 fts_entries
486 .sort_by(|(ta, ea), (tb, eb)| ta.tb_name.cmp(&tb.tb_name).then(ea.name.cmp(&eb.name)));
487 let any_fts = !fts_entries.is_empty();
488 for (table, entry) in fts_entries {
489 if !compact {
490 if let Some(prev) =
491 old_preferred_pages.get(&("index".to_string(), entry.name.to_string()))
492 {
493 alloc.set_preferred(prev.clone());
494 }
495 }
496 let rootpage = stage_fts_btree(&mut pager, &entry.index, &mut alloc)?;
497 alloc.finish_preferred();
498 master_rows.push(CatalogEntry {
499 kind: "index".into(),
500 name: entry.name.clone(),
501 sql: format!(
502 "CREATE INDEX {} ON {} USING fts ({})",
503 entry.name, table.tb_name, entry.column_name
504 ),
505 rootpage,
506 last_rowid: 0,
507 });
508 }
509
510 let mut master = build_empty_master_table();
516 for (i, entry) in master_rows.into_iter().enumerate() {
517 let rowid = (i as i64) + 1;
518 master.restore_row(
519 rowid,
520 vec![
521 Some(Value::Text(entry.kind)),
522 Some(Value::Text(entry.name)),
523 Some(Value::Text(entry.sql)),
524 Some(Value::Integer(entry.rootpage as i64)),
525 Some(Value::Integer(entry.last_rowid)),
526 ],
527 )?;
528 }
529 if !compact && !old_master_pages.is_empty() {
530 alloc.set_preferred(old_master_pages.clone());
534 }
535 let master_root = stage_table_btree(&mut pager, &master, &mut alloc)?;
536 alloc.finish_preferred();
537
538 if !compact {
548 let used = alloc.used().clone();
549 let mut newly_freed: Vec<u32> = old_live
550 .iter()
551 .copied()
552 .filter(|p| !used.contains(p))
553 .collect();
554 let _ = &old_free_trunks; alloc.add_to_freelist(newly_freed.drain(..));
556 }
557
558 let new_free_pages = alloc.drain_freelist();
565 let new_freelist_head =
566 crate::sql::pager::freelist::stage_freelist(&mut pager, new_free_pages)?;
567
568 use crate::sql::pager::header::{FORMAT_VERSION_V5, FORMAT_VERSION_V6};
572 let format_version = if new_freelist_head != 0 {
573 FORMAT_VERSION_V6
574 } else if any_fts {
575 std::cmp::max(FORMAT_VERSION_V5, old_header.format_version)
578 } else {
579 old_header.format_version
581 };
582
583 pager.commit(DbHeader {
584 page_count: alloc.high_water(),
585 schema_root_page: master_root,
586 format_version,
587 freelist_head: new_freelist_head,
588 })?;
589
590 if same_path {
591 db.pager = Some(pager);
592 }
593 Ok(())
594}
595
596struct CatalogEntry {
598 kind: String, name: String,
600 sql: String,
601 rootpage: u32,
602 last_rowid: i64,
603}
604
605fn build_empty_master_table() -> Table {
609 let columns = vec![
612 Column::new("type".into(), "text".into(), false, true, false),
613 Column::new("name".into(), "text".into(), true, true, true),
614 Column::new("sql".into(), "text".into(), false, true, false),
615 Column::new("rootpage".into(), "integer".into(), false, true, false),
616 Column::new("last_rowid".into(), "integer".into(), false, true, false),
617 ];
618 build_empty_table(MASTER_TABLE_NAME, columns, 0)
619}
620
621fn take_text(table: &Table, col: &str, rowid: i64) -> Result<String> {
623 match table.get_value(col, rowid) {
624 Some(Value::Text(s)) => Ok(s),
625 other => Err(SQLRiteError::Internal(format!(
626 "sqlrite_master column '{col}' at rowid {rowid}: expected Text, got {other:?}"
627 ))),
628 }
629}
630
631fn take_integer(table: &Table, col: &str, rowid: i64) -> Result<i64> {
633 match table.get_value(col, rowid) {
634 Some(Value::Integer(v)) => Ok(v),
635 other => Err(SQLRiteError::Internal(format!(
636 "sqlrite_master column '{col}' at rowid {rowid}: expected Integer, got {other:?}"
637 ))),
638 }
639}
640
641fn table_to_create_sql(table: &Table) -> String {
647 let mut parts = Vec::with_capacity(table.columns.len());
648 for c in &table.columns {
649 let ty: String = match &c.datatype {
653 DataType::Integer => "INTEGER".to_string(),
654 DataType::Text => "TEXT".to_string(),
655 DataType::Real => "REAL".to_string(),
656 DataType::Bool => "BOOLEAN".to_string(),
657 DataType::Vector(dim) => format!("VECTOR({dim})"),
658 DataType::Json => "JSON".to_string(),
659 DataType::None | DataType::Invalid => "TEXT".to_string(),
660 };
661 let mut piece = format!("{} {}", c.column_name, ty);
662 if c.is_pk {
663 piece.push_str(" PRIMARY KEY");
664 } else {
665 if c.is_unique {
666 piece.push_str(" UNIQUE");
667 }
668 if c.not_null {
669 piece.push_str(" NOT NULL");
670 }
671 }
672 if let Some(default) = &c.default {
673 piece.push_str(" DEFAULT ");
674 piece.push_str(&render_default_literal(default));
675 }
676 parts.push(piece);
677 }
678 format!("CREATE TABLE {} ({});", table.tb_name, parts.join(", "))
679}
680
681fn render_default_literal(value: &Value) -> String {
687 match value {
688 Value::Integer(i) => i.to_string(),
689 Value::Real(f) => f.to_string(),
690 Value::Bool(b) => {
691 if *b {
692 "TRUE".to_string()
693 } else {
694 "FALSE".to_string()
695 }
696 }
697 Value::Text(s) => format!("'{}'", s.replace('\'', "''")),
698 Value::Null => "NULL".to_string(),
699 Value::Vector(_) => value.to_display_string(),
700 }
701}
702
703fn parse_create_sql(sql: &str) -> Result<(String, Vec<Column>)> {
706 let dialect = SqlriteDialect::new();
707 let mut ast = Parser::parse_sql(&dialect, sql).map_err(SQLRiteError::from)?;
708 let stmt = ast.pop().ok_or_else(|| {
709 SQLRiteError::Internal("sqlrite_master row held an empty SQL string".to_string())
710 })?;
711 let create = CreateQuery::new(&stmt)?;
712 let columns = create
713 .columns
714 .into_iter()
715 .map(|pc| {
716 Column::with_default(
717 pc.name,
718 pc.datatype,
719 pc.is_pk,
720 pc.not_null,
721 pc.is_unique,
722 pc.default,
723 )
724 })
725 .collect();
726 Ok((create.table_name, columns))
727}
728
729fn build_empty_table(name: &str, columns: Vec<Column>, last_rowid: i64) -> Table {
734 let rows: Arc<Mutex<HashMap<String, Row>>> = Arc::new(Mutex::new(HashMap::new()));
735 let mut secondary_indexes: Vec<SecondaryIndex> = Vec::new();
736 {
737 let mut map = rows.lock().expect("rows mutex poisoned");
738 for col in &columns {
739 let row = match &col.datatype {
746 DataType::Integer => Row::Integer(BTreeMap::new()),
747 DataType::Text => Row::Text(BTreeMap::new()),
748 DataType::Real => Row::Real(BTreeMap::new()),
749 DataType::Bool => Row::Bool(BTreeMap::new()),
750 DataType::Vector(_dim) => Row::Vector(BTreeMap::new()),
751 DataType::Json => Row::Text(BTreeMap::new()),
754 DataType::None | DataType::Invalid => Row::None,
755 };
756 map.insert(col.column_name.clone(), row);
757
758 if (col.is_pk || col.is_unique)
761 && matches!(col.datatype, DataType::Integer | DataType::Text)
762 {
763 if let Ok(idx) = SecondaryIndex::new(
764 SecondaryIndex::auto_name(name, &col.column_name),
765 name.to_string(),
766 col.column_name.clone(),
767 &col.datatype,
768 true,
769 IndexOrigin::Auto,
770 ) {
771 secondary_indexes.push(idx);
772 }
773 }
774 }
775 }
776
777 let primary_key = columns
778 .iter()
779 .find(|c| c.is_pk)
780 .map(|c| c.column_name.clone())
781 .unwrap_or_else(|| "-1".to_string());
782
783 Table {
784 tb_name: name.to_string(),
785 columns,
786 rows,
787 secondary_indexes,
788 hnsw_indexes: Vec::new(),
796 fts_indexes: Vec::new(),
801 last_rowid,
802 primary_key,
803 }
804}
805
806fn attach_index(db: &mut Database, pager: &Pager, row: IndexCatalogRow) -> Result<()> {
821 let (table_name, column_name, is_unique) = parse_create_index_sql(&row.sql)?;
822
823 let table = db.get_table_mut(table_name.clone()).map_err(|_| {
824 SQLRiteError::Internal(format!(
825 "index '{}' references unknown table '{table_name}' (sqlrite_master out of sync?)",
826 row.name
827 ))
828 })?;
829 let datatype = table
830 .columns
831 .iter()
832 .find(|c| c.column_name == column_name)
833 .map(|c| clone_datatype(&c.datatype))
834 .ok_or_else(|| {
835 SQLRiteError::Internal(format!(
836 "index '{}' references unknown column '{column_name}' on '{table_name}'",
837 row.name
838 ))
839 })?;
840
841 let existing_slot = table
845 .secondary_indexes
846 .iter()
847 .position(|i| i.name == row.name);
848 let idx = match existing_slot {
849 Some(i) => {
850 table.secondary_indexes.remove(i)
854 }
855 None => SecondaryIndex::new(
856 row.name.clone(),
857 table_name.clone(),
858 column_name.clone(),
859 &datatype,
860 is_unique,
861 IndexOrigin::Explicit,
862 )?,
863 };
864 let mut idx = idx;
865 let is_unique_flag = idx.is_unique;
867 let origin = idx.origin;
868 idx = SecondaryIndex::new(
869 idx.name,
870 idx.table_name,
871 idx.column_name,
872 &datatype,
873 is_unique_flag,
874 origin,
875 )?;
876
877 load_index_rows(pager, &mut idx, row.rootpage)?;
879
880 table.secondary_indexes.push(idx);
881 Ok(())
882}
883
884fn load_index_rows(pager: &Pager, idx: &mut SecondaryIndex, root_page: u32) -> Result<()> {
887 if root_page == 0 {
888 return Ok(());
889 }
890 let first_leaf = find_leftmost_leaf(pager, root_page)?;
891 let mut current = first_leaf;
892 while current != 0 {
893 let page_buf = pager
894 .read_page(current)
895 .ok_or_else(|| SQLRiteError::Internal(format!("missing index leaf page {current}")))?;
896 if page_buf[0] != PageType::TableLeaf as u8 {
897 return Err(SQLRiteError::Internal(format!(
898 "page {current} tagged {} but expected TableLeaf (index)",
899 page_buf[0]
900 )));
901 }
902 let next_leaf = u32::from_le_bytes(page_buf[1..5].try_into().unwrap());
903 let payload: &[u8; PAYLOAD_PER_PAGE] = (&page_buf[PAGE_HEADER_SIZE..])
904 .try_into()
905 .map_err(|_| SQLRiteError::Internal("index leaf payload size".to_string()))?;
906 let leaf = TablePage::from_bytes(payload);
907
908 for slot in 0..leaf.slot_count() {
909 let offset = leaf.slot_offset_raw(slot)?;
911 let (ic, _) = IndexCell::decode(leaf.as_bytes(), offset)?;
912 idx.insert(&ic.value, ic.rowid)?;
913 }
914 current = next_leaf;
915 }
916 Ok(())
917}
918
919fn parse_create_index_sql(sql: &str) -> Result<(String, String, bool)> {
925 use sqlparser::ast::{CreateIndex, Expr, Statement};
926
927 let dialect = SqlriteDialect::new();
928 let mut ast = Parser::parse_sql(&dialect, sql).map_err(SQLRiteError::from)?;
929 let Some(Statement::CreateIndex(CreateIndex {
930 table_name,
931 columns,
932 unique,
933 ..
934 })) = ast.pop()
935 else {
936 return Err(SQLRiteError::Internal(format!(
937 "sqlrite_master index row's SQL isn't a CREATE INDEX: {sql}"
938 )));
939 };
940 if columns.len() != 1 {
941 return Err(SQLRiteError::NotImplemented(
942 "multi-column indexes aren't supported yet".to_string(),
943 ));
944 }
945 let col = match &columns[0].column.expr {
946 Expr::Identifier(ident) => ident.value.clone(),
947 Expr::CompoundIdentifier(parts) => {
948 parts.last().map(|p| p.value.clone()).unwrap_or_default()
949 }
950 other => {
951 return Err(SQLRiteError::Internal(format!(
952 "unsupported indexed column expression: {other:?}"
953 )));
954 }
955 };
956 Ok((table_name.to_string(), col, unique))
957}
958
959fn create_index_sql_uses_hnsw(sql: &str) -> bool {
965 use sqlparser::ast::{CreateIndex, IndexType, Statement};
966
967 let dialect = SqlriteDialect::new();
968 let Ok(mut ast) = Parser::parse_sql(&dialect, sql) else {
969 return false;
970 };
971 let Some(Statement::CreateIndex(CreateIndex { using, .. })) = ast.pop() else {
972 return false;
973 };
974 matches!(using, Some(IndexType::Custom(ident)) if ident.value.eq_ignore_ascii_case("hnsw"))
975}
976
977fn create_index_sql_uses_fts(sql: &str) -> bool {
980 use sqlparser::ast::{CreateIndex, IndexType, Statement};
981
982 let dialect = SqlriteDialect::new();
983 let Ok(mut ast) = Parser::parse_sql(&dialect, sql) else {
984 return false;
985 };
986 let Some(Statement::CreateIndex(CreateIndex { using, .. })) = ast.pop() else {
987 return false;
988 };
989 matches!(using, Some(IndexType::Custom(ident)) if ident.value.eq_ignore_ascii_case("fts"))
990}
991
992fn rebuild_fts_index(db: &mut Database, pager: &Pager, row: &IndexCatalogRow) -> Result<()> {
1005 use crate::sql::db::table::FtsIndexEntry;
1006 use crate::sql::executor::execute_create_index;
1007 use crate::sql::fts::PostingList;
1008 use sqlparser::ast::Statement;
1009
1010 let dialect = SqlriteDialect::new();
1011 let mut ast = Parser::parse_sql(&dialect, &row.sql).map_err(SQLRiteError::from)?;
1012 let Some(stmt @ Statement::CreateIndex(_)) = ast.pop() else {
1013 return Err(SQLRiteError::Internal(format!(
1014 "sqlrite_master FTS row's SQL isn't a CREATE INDEX: {}",
1015 row.sql
1016 )));
1017 };
1018
1019 if row.rootpage == 0 {
1020 execute_create_index(&stmt, db)?;
1022 return Ok(());
1023 }
1024
1025 let (doc_lengths, postings) = load_fts_postings(pager, row.rootpage)?;
1026 let index = PostingList::from_persisted_postings(doc_lengths, postings);
1027 let (tbl_name, col_name) = parse_fts_create_index_sql(&row.sql)?;
1028 let table_mut = db.get_table_mut(tbl_name.clone()).map_err(|_| {
1029 SQLRiteError::Internal(format!(
1030 "FTS index '{}' references unknown table '{tbl_name}'",
1031 row.name
1032 ))
1033 })?;
1034 table_mut.fts_indexes.push(FtsIndexEntry {
1035 name: row.name.clone(),
1036 column_name: col_name,
1037 index,
1038 needs_rebuild: false,
1039 });
1040 Ok(())
1041}
1042
1043fn parse_fts_create_index_sql(sql: &str) -> Result<(String, String)> {
1046 use sqlparser::ast::{CreateIndex, Expr, Statement};
1047
1048 let dialect = SqlriteDialect::new();
1049 let mut ast = Parser::parse_sql(&dialect, sql).map_err(SQLRiteError::from)?;
1050 let Some(Statement::CreateIndex(CreateIndex {
1051 table_name,
1052 columns,
1053 ..
1054 })) = ast.pop()
1055 else {
1056 return Err(SQLRiteError::Internal(format!(
1057 "sqlrite_master FTS row's SQL isn't a CREATE INDEX: {sql}"
1058 )));
1059 };
1060 if columns.len() != 1 {
1061 return Err(SQLRiteError::NotImplemented(
1062 "multi-column FTS indexes aren't supported yet".to_string(),
1063 ));
1064 }
1065 let col = match &columns[0].column.expr {
1066 Expr::Identifier(ident) => ident.value.clone(),
1067 Expr::CompoundIdentifier(parts) => {
1068 parts.last().map(|p| p.value.clone()).unwrap_or_default()
1069 }
1070 other => {
1071 return Err(SQLRiteError::Internal(format!(
1072 "FTS CREATE INDEX has unexpected column expr: {other:?}"
1073 )));
1074 }
1075 };
1076 Ok((table_name.to_string(), col))
1077}
1078
1079fn rebuild_hnsw_index(db: &mut Database, pager: &Pager, row: &IndexCatalogRow) -> Result<()> {
1092 use crate::sql::db::table::HnswIndexEntry;
1093 use crate::sql::executor::execute_create_index;
1094 use crate::sql::hnsw::HnswIndex;
1095 use sqlparser::ast::Statement;
1096
1097 let dialect = SqlriteDialect::new();
1098 let mut ast = Parser::parse_sql(&dialect, &row.sql).map_err(SQLRiteError::from)?;
1099 let Some(stmt @ Statement::CreateIndex(_)) = ast.pop() else {
1100 return Err(SQLRiteError::Internal(format!(
1101 "sqlrite_master HNSW row's SQL isn't a CREATE INDEX: {}",
1102 row.sql
1103 )));
1104 };
1105
1106 if row.rootpage == 0 {
1107 execute_create_index(&stmt, db)?;
1109 return Ok(());
1110 }
1111
1112 let (tbl_name, col_name, metric) = parse_hnsw_create_index_sql(&row.sql)?;
1117 let nodes = load_hnsw_nodes(pager, row.rootpage)?;
1118 let index = HnswIndex::from_persisted_nodes(metric, 0xC0FFEE, nodes);
1119
1120 let table_mut = db.get_table_mut(tbl_name.clone()).map_err(|_| {
1123 SQLRiteError::Internal(format!(
1124 "HNSW index '{}' references unknown table '{tbl_name}'",
1125 row.name
1126 ))
1127 })?;
1128 table_mut.hnsw_indexes.push(HnswIndexEntry {
1129 name: row.name.clone(),
1130 column_name: col_name,
1131 metric,
1132 index,
1133 needs_rebuild: false,
1134 });
1135 Ok(())
1136}
1137
1138fn load_hnsw_nodes(pager: &Pager, root_page: u32) -> Result<Vec<(i64, Vec<Vec<i64>>)>> {
1144 use crate::sql::pager::hnsw_cell::HnswNodeCell;
1145
1146 let mut nodes: Vec<(i64, Vec<Vec<i64>>)> = Vec::new();
1147 let first_leaf = find_leftmost_leaf(pager, root_page)?;
1148 let mut current = first_leaf;
1149 while current != 0 {
1150 let page_buf = pager
1151 .read_page(current)
1152 .ok_or_else(|| SQLRiteError::Internal(format!("missing HNSW leaf page {current}")))?;
1153 if page_buf[0] != PageType::TableLeaf as u8 {
1154 return Err(SQLRiteError::Internal(format!(
1155 "page {current} tagged {} but expected TableLeaf (HNSW)",
1156 page_buf[0]
1157 )));
1158 }
1159 let next_leaf = u32::from_le_bytes(page_buf[1..5].try_into().unwrap());
1160 let payload: &[u8; PAYLOAD_PER_PAGE] = (&page_buf[PAGE_HEADER_SIZE..])
1161 .try_into()
1162 .map_err(|_| SQLRiteError::Internal("HNSW leaf payload size".to_string()))?;
1163 let leaf = TablePage::from_bytes(payload);
1164 for slot in 0..leaf.slot_count() {
1165 let offset = leaf.slot_offset_raw(slot)?;
1166 let (cell, _) = HnswNodeCell::decode(leaf.as_bytes(), offset)?;
1167 nodes.push((cell.node_id, cell.layers));
1168 }
1169 current = next_leaf;
1170 }
1171 Ok(nodes)
1172}
1173
1174fn parse_hnsw_create_index_sql(sql: &str) -> Result<(String, String, DistanceMetric)> {
1181 use crate::sql::hnsw::DistanceMetric;
1182 use sqlparser::ast::{BinaryOperator, CreateIndex, Expr, Statement, Value as AstValue};
1183
1184 let dialect = SqlriteDialect::new();
1185 let mut ast = Parser::parse_sql(&dialect, sql).map_err(SQLRiteError::from)?;
1186 let Some(Statement::CreateIndex(CreateIndex {
1187 table_name,
1188 columns,
1189 with,
1190 ..
1191 })) = ast.pop()
1192 else {
1193 return Err(SQLRiteError::Internal(format!(
1194 "sqlrite_master HNSW row's SQL isn't a CREATE INDEX: {sql}"
1195 )));
1196 };
1197 if columns.len() != 1 {
1198 return Err(SQLRiteError::NotImplemented(
1199 "multi-column HNSW indexes aren't supported yet".to_string(),
1200 ));
1201 }
1202 let col = match &columns[0].column.expr {
1203 Expr::Identifier(ident) => ident.value.clone(),
1204 Expr::CompoundIdentifier(parts) => {
1205 parts.last().map(|p| p.value.clone()).unwrap_or_default()
1206 }
1207 other => {
1208 return Err(SQLRiteError::Internal(format!(
1209 "unsupported HNSW indexed column expression: {other:?}"
1210 )));
1211 }
1212 };
1213
1214 let mut metric = DistanceMetric::L2;
1220 for opt in &with {
1221 if let Expr::BinaryOp { left, op, right } = opt {
1222 if matches!(op, BinaryOperator::Eq) {
1223 if let (Expr::Identifier(key), Expr::Value(v)) = (left.as_ref(), right.as_ref())
1224 && key.value.eq_ignore_ascii_case("metric")
1225 {
1226 if let AstValue::SingleQuotedString(s) | AstValue::DoubleQuotedString(s) =
1227 &v.value
1228 {
1229 metric = DistanceMetric::from_sql_name(s).ok_or_else(|| {
1230 SQLRiteError::Internal(format!(
1231 "sqlrite_master HNSW row carries unknown metric '{s}'"
1232 ))
1233 })?;
1234 }
1235 }
1236 }
1237 }
1238 }
1239
1240 Ok((table_name.to_string(), col, metric))
1241}
1242
1243fn rebuild_dirty_hnsw_indexes(db: &mut Database) -> Result<()> {
1255 for table in db.tables.values_mut() {
1256 table.rebuild_dirty_hnsw_indexes()?;
1257 }
1258 Ok(())
1259}
1260
1261fn synthesize_hnsw_create_index_sql(
1266 index_name: &str,
1267 table_name: &str,
1268 column_name: &str,
1269 metric: DistanceMetric,
1270) -> String {
1271 if matches!(metric, DistanceMetric::L2) {
1272 format!("CREATE INDEX {index_name} ON {table_name} USING hnsw ({column_name})")
1273 } else {
1274 format!(
1275 "CREATE INDEX {index_name} ON {table_name} USING hnsw ({column_name}) WITH (metric = '{}')",
1276 metric.sql_name()
1277 )
1278 }
1279}
1280
1281fn rebuild_dirty_fts_indexes(db: &mut Database) {
1286 use crate::sql::fts::PostingList;
1287
1288 for table in db.tables.values_mut() {
1289 let dirty: Vec<(String, String)> = table
1290 .fts_indexes
1291 .iter()
1292 .filter(|e| e.needs_rebuild)
1293 .map(|e| (e.name.clone(), e.column_name.clone()))
1294 .collect();
1295 if dirty.is_empty() {
1296 continue;
1297 }
1298
1299 for (idx_name, col_name) in dirty {
1300 let mut docs: Vec<(i64, String)> = Vec::new();
1303 {
1304 let row_data = table.rows.lock().expect("rows mutex poisoned");
1305 if let Some(Row::Text(map)) = row_data.get(&col_name) {
1306 for (id, v) in map.iter() {
1307 if v != "Null" {
1313 docs.push((*id, v.clone()));
1314 }
1315 }
1316 }
1317 }
1318
1319 let mut new_idx = PostingList::new();
1320 docs.sort_by_key(|(id, _)| *id);
1325 for (id, text) in &docs {
1326 new_idx.insert(*id, text);
1327 }
1328
1329 if let Some(entry) = table.fts_indexes.iter_mut().find(|e| e.name == idx_name) {
1330 entry.index = new_idx;
1331 entry.needs_rebuild = false;
1332 }
1333 }
1334 }
1335}
1336
1337fn clone_datatype(dt: &DataType) -> DataType {
1339 match dt {
1340 DataType::Integer => DataType::Integer,
1341 DataType::Text => DataType::Text,
1342 DataType::Real => DataType::Real,
1343 DataType::Bool => DataType::Bool,
1344 DataType::Vector(dim) => DataType::Vector(*dim),
1345 DataType::Json => DataType::Json,
1346 DataType::None => DataType::None,
1347 DataType::Invalid => DataType::Invalid,
1348 }
1349}
1350
1351fn stage_index_btree(
1360 pager: &mut Pager,
1361 idx: &SecondaryIndex,
1362 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1363) -> Result<u32> {
1364 let leaves = stage_index_leaves(pager, idx, alloc)?;
1366 if leaves.len() == 1 {
1367 return Ok(leaves[0].0);
1368 }
1369 let mut level: Vec<(u32, i64)> = leaves;
1370 while level.len() > 1 {
1371 level = stage_interior_level(pager, &level, alloc)?;
1372 }
1373 Ok(level[0].0)
1374}
1375
1376fn stage_index_leaves(
1383 pager: &mut Pager,
1384 idx: &SecondaryIndex,
1385 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1386) -> Result<Vec<(u32, i64)>> {
1387 let mut leaves: Vec<(u32, i64)> = Vec::new();
1388 let mut current_leaf = TablePage::empty();
1389 let mut current_leaf_page = alloc.allocate();
1390 let mut current_max_rowid: Option<i64> = None;
1391
1392 let mut entries: Vec<(Value, i64)> = idx.iter_entries().collect();
1396 entries.sort_by_key(|(_, r)| *r);
1397
1398 for (value, rowid) in entries {
1399 let cell = IndexCell::new(rowid, value);
1400 let entry_bytes = cell.encode()?;
1401
1402 if !current_leaf.would_fit(entry_bytes.len()) {
1403 let next_leaf_page_num = alloc.allocate();
1404 emit_leaf(pager, current_leaf_page, ¤t_leaf, next_leaf_page_num);
1405 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1406 current_leaf = TablePage::empty();
1407 current_leaf_page = next_leaf_page_num;
1408
1409 if !current_leaf.would_fit(entry_bytes.len()) {
1410 return Err(SQLRiteError::Internal(format!(
1411 "index entry of {} bytes exceeds empty-page capacity {}",
1412 entry_bytes.len(),
1413 current_leaf.free_space()
1414 )));
1415 }
1416 }
1417 current_leaf.insert_entry(rowid, &entry_bytes)?;
1418 current_max_rowid = Some(rowid);
1419 }
1420
1421 emit_leaf(pager, current_leaf_page, ¤t_leaf, 0);
1422 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1423 Ok(leaves)
1424}
1425
1426fn stage_hnsw_btree(
1437 pager: &mut Pager,
1438 idx: &crate::sql::hnsw::HnswIndex,
1439 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1440) -> Result<u32> {
1441 let leaves = stage_hnsw_leaves(pager, idx, alloc)?;
1442 if leaves.len() == 1 {
1443 return Ok(leaves[0].0);
1444 }
1445 let mut level: Vec<(u32, i64)> = leaves;
1446 while level.len() > 1 {
1447 level = stage_interior_level(pager, &level, alloc)?;
1448 }
1449 Ok(level[0].0)
1450}
1451
1452fn stage_fts_btree(
1458 pager: &mut Pager,
1459 idx: &crate::sql::fts::PostingList,
1460 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1461) -> Result<u32> {
1462 let leaves = stage_fts_leaves(pager, idx, alloc)?;
1463 if leaves.len() == 1 {
1464 return Ok(leaves[0].0);
1465 }
1466 let mut level: Vec<(u32, i64)> = leaves;
1467 while level.len() > 1 {
1468 level = stage_interior_level(pager, &level, alloc)?;
1469 }
1470 Ok(level[0].0)
1471}
1472
1473fn stage_fts_leaves(
1480 pager: &mut Pager,
1481 idx: &crate::sql::fts::PostingList,
1482 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1483) -> Result<Vec<(u32, i64)>> {
1484 use crate::sql::pager::fts_cell::FtsPostingCell;
1485
1486 let mut leaves: Vec<(u32, i64)> = Vec::new();
1487 let mut current_leaf = TablePage::empty();
1488 let mut current_leaf_page = alloc.allocate();
1489 let mut current_max_rowid: Option<i64> = None;
1490
1491 let mut cell_id: i64 = 1;
1495 let mut cells: Vec<FtsPostingCell> = Vec::new();
1496 cells.push(FtsPostingCell::doc_lengths(
1497 cell_id,
1498 idx.serialize_doc_lengths(),
1499 ));
1500 for (term, entries) in idx.serialize_postings() {
1501 cell_id += 1;
1502 cells.push(FtsPostingCell::posting(cell_id, term, entries));
1503 }
1504
1505 for cell in cells {
1506 let entry_bytes = cell.encode()?;
1507
1508 if !current_leaf.would_fit(entry_bytes.len()) {
1509 let next_leaf_page_num = alloc.allocate();
1510 emit_leaf(pager, current_leaf_page, ¤t_leaf, next_leaf_page_num);
1511 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1512 current_leaf = TablePage::empty();
1513 current_leaf_page = next_leaf_page_num;
1514
1515 if !current_leaf.would_fit(entry_bytes.len()) {
1516 return Err(SQLRiteError::Internal(format!(
1521 "FTS posting cell {} of {} bytes exceeds empty-page capacity {} \
1522 (term too long or too many postings; overflow chaining is Phase 8.1)",
1523 cell.cell_id,
1524 entry_bytes.len(),
1525 current_leaf.free_space()
1526 )));
1527 }
1528 }
1529 current_leaf.insert_entry(cell.cell_id, &entry_bytes)?;
1530 current_max_rowid = Some(cell.cell_id);
1531 }
1532
1533 emit_leaf(pager, current_leaf_page, ¤t_leaf, 0);
1534 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1535 Ok(leaves)
1536}
1537
1538type FtsEntries = Vec<(i64, u32)>;
1541type FtsPostings = Vec<(String, FtsEntries)>;
1543
1544fn load_fts_postings(pager: &Pager, root_page: u32) -> Result<(FtsEntries, FtsPostings)> {
1549 use crate::sql::pager::fts_cell::FtsPostingCell;
1550
1551 let mut doc_lengths: Vec<(i64, u32)> = Vec::new();
1552 let mut postings: Vec<(String, Vec<(i64, u32)>)> = Vec::new();
1553 let mut saw_sidecar = false;
1554
1555 let first_leaf = find_leftmost_leaf(pager, root_page)?;
1556 let mut current = first_leaf;
1557 while current != 0 {
1558 let page_buf = pager
1559 .read_page(current)
1560 .ok_or_else(|| SQLRiteError::Internal(format!("missing FTS leaf page {current}")))?;
1561 if page_buf[0] != PageType::TableLeaf as u8 {
1562 return Err(SQLRiteError::Internal(format!(
1563 "page {current} tagged {} but expected TableLeaf (FTS)",
1564 page_buf[0]
1565 )));
1566 }
1567 let next_leaf = u32::from_le_bytes(page_buf[1..5].try_into().unwrap());
1568 let payload: &[u8; PAYLOAD_PER_PAGE] = (&page_buf[PAGE_HEADER_SIZE..])
1569 .try_into()
1570 .map_err(|_| SQLRiteError::Internal("FTS leaf payload size".to_string()))?;
1571 let leaf = TablePage::from_bytes(payload);
1572 for slot in 0..leaf.slot_count() {
1573 let offset = leaf.slot_offset_raw(slot)?;
1574 let (cell, _) = FtsPostingCell::decode(leaf.as_bytes(), offset)?;
1575 if cell.is_doc_lengths() {
1576 if saw_sidecar {
1577 return Err(SQLRiteError::Internal(
1578 "FTS index has more than one doc-lengths sidecar cell".to_string(),
1579 ));
1580 }
1581 saw_sidecar = true;
1582 doc_lengths = cell.entries;
1583 } else {
1584 postings.push((cell.term, cell.entries));
1585 }
1586 }
1587 current = next_leaf;
1588 }
1589
1590 if !saw_sidecar {
1591 return Err(SQLRiteError::Internal(
1592 "FTS index missing doc-lengths sidecar cell — corrupt or truncated tree".to_string(),
1593 ));
1594 }
1595 Ok((doc_lengths, postings))
1596}
1597
1598fn stage_hnsw_leaves(
1602 pager: &mut Pager,
1603 idx: &crate::sql::hnsw::HnswIndex,
1604 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1605) -> Result<Vec<(u32, i64)>> {
1606 use crate::sql::pager::hnsw_cell::HnswNodeCell;
1607
1608 let mut leaves: Vec<(u32, i64)> = Vec::new();
1609 let mut current_leaf = TablePage::empty();
1610 let mut current_leaf_page = alloc.allocate();
1611 let mut current_max_rowid: Option<i64> = None;
1612
1613 let serialized = idx.serialize_nodes();
1614
1615 for (node_id, layers) in serialized {
1620 let cell = HnswNodeCell::new(node_id, layers);
1621 let entry_bytes = cell.encode()?;
1622
1623 if !current_leaf.would_fit(entry_bytes.len()) {
1624 let next_leaf_page_num = alloc.allocate();
1625 emit_leaf(pager, current_leaf_page, ¤t_leaf, next_leaf_page_num);
1626 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1627 current_leaf = TablePage::empty();
1628 current_leaf_page = next_leaf_page_num;
1629
1630 if !current_leaf.would_fit(entry_bytes.len()) {
1631 return Err(SQLRiteError::Internal(format!(
1632 "HNSW node {node_id} cell of {} bytes exceeds empty-page capacity {}",
1633 entry_bytes.len(),
1634 current_leaf.free_space()
1635 )));
1636 }
1637 }
1638 current_leaf.insert_entry(node_id, &entry_bytes)?;
1639 current_max_rowid = Some(node_id);
1640 }
1641
1642 emit_leaf(pager, current_leaf_page, ¤t_leaf, 0);
1643 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1644 Ok(leaves)
1645}
1646
1647fn load_table_rows(pager: &Pager, table: &mut Table, root_page: u32) -> Result<()> {
1648 let first_leaf = find_leftmost_leaf(pager, root_page)?;
1649 let mut current = first_leaf;
1650 while current != 0 {
1651 let page_buf = pager
1652 .read_page(current)
1653 .ok_or_else(|| SQLRiteError::Internal(format!("missing leaf page {current}")))?;
1654 if page_buf[0] != PageType::TableLeaf as u8 {
1655 return Err(SQLRiteError::Internal(format!(
1656 "page {current} tagged {} but expected TableLeaf",
1657 page_buf[0]
1658 )));
1659 }
1660 let next_leaf = u32::from_le_bytes(page_buf[1..5].try_into().unwrap());
1661 let payload: &[u8; PAYLOAD_PER_PAGE] = (&page_buf[PAGE_HEADER_SIZE..])
1662 .try_into()
1663 .map_err(|_| SQLRiteError::Internal("leaf payload slice size".to_string()))?;
1664 let leaf = TablePage::from_bytes(payload);
1665
1666 for slot in 0..leaf.slot_count() {
1667 let entry = leaf.entry_at(slot)?;
1668 let cell = match entry {
1669 PagedEntry::Local(c) => c,
1670 PagedEntry::Overflow(r) => {
1671 let body_bytes =
1672 read_overflow_chain(pager, r.first_overflow_page, r.total_body_len)?;
1673 let (c, _) = Cell::decode(&body_bytes, 0)?;
1674 c
1675 }
1676 };
1677 table.restore_row(cell.rowid, cell.values)?;
1678 }
1679 current = next_leaf;
1680 }
1681 Ok(())
1682}
1683
1684fn collect_pages_for_btree(
1695 pager: &Pager,
1696 root_page: u32,
1697 follow_overflow: bool,
1698) -> Result<Vec<u32>> {
1699 if root_page == 0 {
1700 return Ok(Vec::new());
1701 }
1702 let mut pages: Vec<u32> = Vec::new();
1703 let mut stack: Vec<u32> = vec![root_page];
1704
1705 while let Some(p) = stack.pop() {
1706 let buf = pager.read_page(p).ok_or_else(|| {
1707 SQLRiteError::Internal(format!(
1708 "collect_pages: missing page {p} (rooted at {root_page})"
1709 ))
1710 })?;
1711 pages.push(p);
1712 match buf[0] {
1713 t if t == PageType::InteriorNode as u8 => {
1714 let payload: &[u8; PAYLOAD_PER_PAGE] =
1715 (&buf[PAGE_HEADER_SIZE..]).try_into().map_err(|_| {
1716 SQLRiteError::Internal("interior payload slice size".to_string())
1717 })?;
1718 let interior = InteriorPage::from_bytes(payload);
1719 for slot in 0..interior.slot_count() {
1721 let cell = interior.cell_at(slot)?;
1722 stack.push(cell.child_page);
1723 }
1724 stack.push(interior.rightmost_child());
1725 }
1726 t if t == PageType::TableLeaf as u8 => {
1727 if follow_overflow {
1728 let payload: &[u8; PAYLOAD_PER_PAGE] =
1729 (&buf[PAGE_HEADER_SIZE..]).try_into().map_err(|_| {
1730 SQLRiteError::Internal("leaf payload slice size".to_string())
1731 })?;
1732 let leaf = TablePage::from_bytes(payload);
1733 for slot in 0..leaf.slot_count() {
1734 match leaf.entry_at(slot)? {
1735 PagedEntry::Local(_) => {}
1736 PagedEntry::Overflow(r) => {
1737 let mut cur = r.first_overflow_page;
1738 while cur != 0 {
1739 pages.push(cur);
1740 let ob = pager.read_page(cur).ok_or_else(|| {
1741 SQLRiteError::Internal(format!(
1742 "collect_pages: missing overflow page {cur}"
1743 ))
1744 })?;
1745 if ob[0] != PageType::Overflow as u8 {
1746 return Err(SQLRiteError::Internal(format!(
1747 "collect_pages: page {cur} expected Overflow, got tag {}",
1748 ob[0]
1749 )));
1750 }
1751 cur = u32::from_le_bytes(ob[1..5].try_into().unwrap());
1752 }
1753 }
1754 }
1755 }
1756 }
1757 }
1758 other => {
1759 return Err(SQLRiteError::Internal(format!(
1760 "collect_pages: unexpected page type {other} at page {p}"
1761 )));
1762 }
1763 }
1764 }
1765 Ok(pages)
1766}
1767
1768fn read_old_rootpages(pager: &Pager, schema_root: u32) -> Result<HashMap<(String, String), u32>> {
1778 let mut out: HashMap<(String, String), u32> = HashMap::new();
1779 if schema_root == 0 {
1780 return Ok(out);
1781 }
1782 let mut master = build_empty_master_table();
1783 load_table_rows(pager, &mut master, schema_root)?;
1784 for rowid in master.rowids() {
1785 let kind = take_text(&master, "type", rowid)?;
1786 let name = take_text(&master, "name", rowid)?;
1787 let rootpage = take_integer(&master, "rootpage", rowid)? as u32;
1788 out.insert((kind, name), rootpage);
1789 }
1790 Ok(out)
1791}
1792
1793fn find_leftmost_leaf(pager: &Pager, root_page: u32) -> Result<u32> {
1797 let mut current = root_page;
1798 loop {
1799 let page_buf = pager.read_page(current).ok_or_else(|| {
1800 SQLRiteError::Internal(format!("missing page {current} during tree descent"))
1801 })?;
1802 match page_buf[0] {
1803 t if t == PageType::TableLeaf as u8 => return Ok(current),
1804 t if t == PageType::InteriorNode as u8 => {
1805 let payload: &[u8; PAYLOAD_PER_PAGE] =
1806 (&page_buf[PAGE_HEADER_SIZE..]).try_into().map_err(|_| {
1807 SQLRiteError::Internal("interior payload slice size".to_string())
1808 })?;
1809 let interior = InteriorPage::from_bytes(payload);
1810 current = interior.leftmost_child()?;
1811 }
1812 other => {
1813 return Err(SQLRiteError::Internal(format!(
1814 "unexpected page type {other} during tree descent at page {current}"
1815 )));
1816 }
1817 }
1818 }
1819}
1820
1821fn stage_table_btree(
1832 pager: &mut Pager,
1833 table: &Table,
1834 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1835) -> Result<u32> {
1836 let leaves = stage_leaves(pager, table, alloc)?;
1837 if leaves.len() == 1 {
1838 return Ok(leaves[0].0);
1839 }
1840 let mut level: Vec<(u32, i64)> = leaves;
1841 while level.len() > 1 {
1842 level = stage_interior_level(pager, &level, alloc)?;
1843 }
1844 Ok(level[0].0)
1845}
1846
1847fn stage_leaves(
1851 pager: &mut Pager,
1852 table: &Table,
1853 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1854) -> Result<Vec<(u32, i64)>> {
1855 let mut leaves: Vec<(u32, i64)> = Vec::new();
1856 let mut current_leaf = TablePage::empty();
1857 let mut current_leaf_page = alloc.allocate();
1858 let mut current_max_rowid: Option<i64> = None;
1859
1860 for rowid in table.rowids() {
1861 let entry_bytes = build_row_entry(pager, table, rowid, alloc)?;
1862
1863 if !current_leaf.would_fit(entry_bytes.len()) {
1864 let next_leaf_page_num = alloc.allocate();
1868 emit_leaf(pager, current_leaf_page, ¤t_leaf, next_leaf_page_num);
1869 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1870 current_leaf = TablePage::empty();
1871 current_leaf_page = next_leaf_page_num;
1872 if !current_leaf.would_fit(entry_bytes.len()) {
1876 return Err(SQLRiteError::Internal(format!(
1877 "entry of {} bytes exceeds empty-page capacity {}",
1878 entry_bytes.len(),
1879 current_leaf.free_space()
1880 )));
1881 }
1882 }
1883 current_leaf.insert_entry(rowid, &entry_bytes)?;
1884 current_max_rowid = Some(rowid);
1885 }
1886
1887 emit_leaf(pager, current_leaf_page, ¤t_leaf, 0);
1889 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1890 Ok(leaves)
1891}
1892
1893fn build_row_entry(
1898 pager: &mut Pager,
1899 table: &Table,
1900 rowid: i64,
1901 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1902) -> Result<Vec<u8>> {
1903 let values = table.extract_row(rowid);
1904 let local_cell = Cell::new(rowid, values);
1905 let local_bytes = local_cell.encode()?;
1906 if local_bytes.len() > OVERFLOW_THRESHOLD {
1907 let overflow_start = write_overflow_chain(pager, &local_bytes, alloc)?;
1908 Ok(OverflowRef {
1909 rowid,
1910 total_body_len: local_bytes.len() as u64,
1911 first_overflow_page: overflow_start,
1912 }
1913 .encode())
1914 } else {
1915 Ok(local_bytes)
1916 }
1917}
1918
1919fn stage_interior_level(
1924 pager: &mut Pager,
1925 children: &[(u32, i64)],
1926 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1927) -> Result<Vec<(u32, i64)>> {
1928 let mut next_level: Vec<(u32, i64)> = Vec::new();
1929 let mut idx = 0usize;
1930
1931 while idx < children.len() {
1932 let interior_page_num = alloc.allocate();
1933
1934 let (mut rightmost_child_page, mut rightmost_child_max) = children[idx];
1939 idx += 1;
1940 let mut interior = InteriorPage::empty(rightmost_child_page);
1941
1942 while idx < children.len() {
1943 let new_divider_cell = InteriorCell {
1944 divider_rowid: rightmost_child_max,
1945 child_page: rightmost_child_page,
1946 };
1947 let new_divider_bytes = new_divider_cell.encode();
1948 if !interior.would_fit(new_divider_bytes.len()) {
1949 break;
1950 }
1951 interior.insert_divider(rightmost_child_max, rightmost_child_page)?;
1952 let (next_child_page, next_child_max) = children[idx];
1953 interior.set_rightmost_child(next_child_page);
1954 rightmost_child_page = next_child_page;
1955 rightmost_child_max = next_child_max;
1956 idx += 1;
1957 }
1958
1959 emit_interior(pager, interior_page_num, &interior);
1960 next_level.push((interior_page_num, rightmost_child_max));
1961 }
1962
1963 Ok(next_level)
1964}
1965
1966fn emit_leaf(pager: &mut Pager, page_num: u32, leaf: &TablePage, next_leaf: u32) {
1968 let mut buf = [0u8; PAGE_SIZE];
1969 buf[0] = PageType::TableLeaf as u8;
1970 buf[1..5].copy_from_slice(&next_leaf.to_le_bytes());
1971 buf[5..7].copy_from_slice(&0u16.to_le_bytes());
1974 buf[PAGE_HEADER_SIZE..].copy_from_slice(leaf.as_bytes());
1975 pager.stage_page(page_num, buf);
1976}
1977
1978fn emit_interior(pager: &mut Pager, page_num: u32, interior: &InteriorPage) {
1982 let mut buf = [0u8; PAGE_SIZE];
1983 buf[0] = PageType::InteriorNode as u8;
1984 buf[1..5].copy_from_slice(&0u32.to_le_bytes());
1985 buf[5..7].copy_from_slice(&0u16.to_le_bytes());
1986 buf[PAGE_HEADER_SIZE..].copy_from_slice(interior.as_bytes());
1987 pager.stage_page(page_num, buf);
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992 use super::*;
1993 use crate::sql::pager::freelist::MIN_PAGES_FOR_AUTO_VACUUM;
1994 use crate::sql::process_command;
1995
1996 fn seed_db() -> Database {
1997 let mut db = Database::new("test".to_string());
1998 process_command(
1999 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, age INTEGER);",
2000 &mut db,
2001 )
2002 .unwrap();
2003 process_command(
2004 "INSERT INTO users (name, age) VALUES ('alice', 30);",
2005 &mut db,
2006 )
2007 .unwrap();
2008 process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db).unwrap();
2009 process_command(
2010 "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2011 &mut db,
2012 )
2013 .unwrap();
2014 process_command("INSERT INTO notes (body) VALUES ('hello');", &mut db).unwrap();
2015 db
2016 }
2017
2018 fn tmp_path(name: &str) -> std::path::PathBuf {
2019 let mut p = std::env::temp_dir();
2020 let pid = std::process::id();
2021 let nanos = std::time::SystemTime::now()
2022 .duration_since(std::time::UNIX_EPOCH)
2023 .map(|d| d.as_nanos())
2024 .unwrap_or(0);
2025 p.push(format!("sqlrite-{pid}-{nanos}-{name}.sqlrite"));
2026 p
2027 }
2028
2029 fn cleanup(path: &std::path::Path) {
2032 let _ = std::fs::remove_file(path);
2033 let mut wal = path.as_os_str().to_owned();
2034 wal.push("-wal");
2035 let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
2036 }
2037
2038 #[test]
2039 fn round_trip_preserves_schema_and_data() {
2040 let path = tmp_path("roundtrip");
2041 let mut db = seed_db();
2042 save_database(&mut db, &path).expect("save");
2043
2044 let loaded = open_database(&path, "test".to_string()).expect("open");
2045 assert_eq!(loaded.tables.len(), 2);
2046
2047 let users = loaded.get_table("users".to_string()).expect("users table");
2048 assert_eq!(users.columns.len(), 3);
2049 let rowids = users.rowids();
2050 assert_eq!(rowids.len(), 2);
2051 let names: Vec<String> = rowids
2052 .iter()
2053 .filter_map(|r| match users.get_value("name", *r) {
2054 Some(Value::Text(s)) => Some(s),
2055 _ => None,
2056 })
2057 .collect();
2058 assert!(names.contains(&"alice".to_string()));
2059 assert!(names.contains(&"bob".to_string()));
2060
2061 let notes = loaded.get_table("notes".to_string()).expect("notes table");
2062 assert_eq!(notes.rowids().len(), 1);
2063
2064 cleanup(&path);
2065 }
2066
2067 #[test]
2072 fn round_trip_preserves_vector_column() {
2073 let path = tmp_path("vec_roundtrip");
2074
2075 {
2077 let mut db = Database::new("test".to_string());
2078 process_command(
2079 "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
2080 &mut db,
2081 )
2082 .unwrap();
2083 process_command(
2084 "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
2085 &mut db,
2086 )
2087 .unwrap();
2088 process_command(
2089 "INSERT INTO docs (embedding) VALUES ([1.5, -2.0, 3.5]);",
2090 &mut db,
2091 )
2092 .unwrap();
2093 save_database(&mut db, &path).expect("save");
2094 } let loaded = open_database(&path, "test".to_string()).expect("open");
2098 let docs = loaded.get_table("docs".to_string()).expect("docs table");
2099
2100 let embedding_col = docs
2102 .columns
2103 .iter()
2104 .find(|c| c.column_name == "embedding")
2105 .expect("embedding column");
2106 assert!(
2107 matches!(embedding_col.datatype, DataType::Vector(3)),
2108 "expected DataType::Vector(3) after round-trip, got {:?}",
2109 embedding_col.datatype
2110 );
2111
2112 let mut rows: Vec<Vec<f32>> = docs
2114 .rowids()
2115 .iter()
2116 .filter_map(|r| match docs.get_value("embedding", *r) {
2117 Some(Value::Vector(v)) => Some(v),
2118 _ => None,
2119 })
2120 .collect();
2121 rows.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
2122 assert_eq!(rows.len(), 2);
2123 assert_eq!(rows[0], vec![0.1f32, 0.2, 0.3]);
2124 assert_eq!(rows[1], vec![1.5f32, -2.0, 3.5]);
2125
2126 cleanup(&path);
2127 }
2128
2129 #[test]
2130 fn round_trip_preserves_json_column() {
2131 let path = tmp_path("json_roundtrip");
2136
2137 {
2138 let mut db = Database::new("test".to_string());
2139 process_command(
2140 "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
2141 &mut db,
2142 )
2143 .unwrap();
2144 process_command(
2145 r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "tags": ["rust","sql"]}');"#,
2146 &mut db,
2147 )
2148 .unwrap();
2149 save_database(&mut db, &path).expect("save");
2150 }
2151
2152 let mut loaded = open_database(&path, "test".to_string()).expect("open");
2153 let docs = loaded.get_table("docs".to_string()).expect("docs");
2154
2155 let payload_col = docs
2157 .columns
2158 .iter()
2159 .find(|c| c.column_name == "payload")
2160 .unwrap();
2161 assert!(
2162 matches!(payload_col.datatype, DataType::Json),
2163 "expected DataType::Json, got {:?}",
2164 payload_col.datatype
2165 );
2166
2167 let resp = process_command(
2170 r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
2171 &mut loaded,
2172 )
2173 .expect("select via json_extract after reopen");
2174 assert!(resp.contains("1 row returned"), "got: {resp}");
2175
2176 cleanup(&path);
2177 }
2178
2179 #[test]
2180 fn round_trip_rebuilds_hnsw_index_from_create_sql() {
2181 let path = tmp_path("hnsw_roundtrip");
2186
2187 {
2189 let mut db = Database::new("test".to_string());
2190 process_command(
2191 "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2192 &mut db,
2193 )
2194 .unwrap();
2195 for v in &[
2196 "[1.0, 0.0]",
2197 "[2.0, 0.0]",
2198 "[0.0, 3.0]",
2199 "[1.0, 4.0]",
2200 "[10.0, 10.0]",
2201 ] {
2202 process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2203 }
2204 process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2205 save_database(&mut db, &path).expect("save");
2206 } let mut loaded = open_database(&path, "test".to_string()).expect("open");
2211 {
2212 let table = loaded.get_table("docs".to_string()).expect("docs");
2213 assert_eq!(table.hnsw_indexes.len(), 1, "HNSW index should reattach");
2214 let entry = &table.hnsw_indexes[0];
2215 assert_eq!(entry.name, "ix_e");
2216 assert_eq!(entry.column_name, "e");
2217 assert_eq!(entry.index.len(), 5, "loaded graph should hold all 5 rows");
2218 assert!(
2219 !entry.needs_rebuild,
2220 "fresh load should not be marked dirty"
2221 );
2222 }
2223
2224 let resp = process_command(
2227 "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
2228 &mut loaded,
2229 )
2230 .unwrap();
2231 assert!(resp.contains("3 rows returned"), "got: {resp}");
2232
2233 cleanup(&path);
2234 }
2235
2236 #[test]
2241 fn round_trip_preserves_hnsw_cosine_metric() {
2242 use crate::sql::hnsw::DistanceMetric;
2243 let path = tmp_path("hnsw_metric_roundtrip");
2244
2245 {
2246 let mut db = Database::new("test".to_string());
2247 process_command(
2248 "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2249 &mut db,
2250 )
2251 .unwrap();
2252 for v in &["[1.0, 0.0]", "[0.0, 1.0]", "[0.7071, 0.7071]"] {
2253 process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2254 }
2255 process_command(
2256 "CREATE INDEX ix_cos ON docs USING hnsw (e) WITH (metric = 'cosine');",
2257 &mut db,
2258 )
2259 .unwrap();
2260 save_database(&mut db, &path).expect("save");
2261 }
2262
2263 let mut loaded = open_database(&path, "test".to_string()).expect("open");
2264 {
2265 let table = loaded.get_table("docs".to_string()).expect("docs");
2266 assert_eq!(table.hnsw_indexes.len(), 1);
2267 assert_eq!(
2268 table.hnsw_indexes[0].metric,
2269 DistanceMetric::Cosine,
2270 "metric should round-trip through CREATE INDEX SQL"
2271 );
2272 assert_eq!(table.hnsw_indexes[0].index.distance, DistanceMetric::Cosine);
2273 }
2274
2275 let resp = process_command(
2279 "SELECT id FROM docs ORDER BY vec_distance_cosine(e, [1.0, 0.0]) ASC LIMIT 1;",
2280 &mut loaded,
2281 )
2282 .unwrap();
2283 assert!(resp.contains("1 row returned"), "got: {resp}");
2284
2285 cleanup(&path);
2286 }
2287
2288 #[test]
2289 fn round_trip_rebuilds_fts_index_from_create_sql() {
2290 let path = tmp_path("fts_roundtrip");
2295
2296 {
2297 let mut db = Database::new("test".to_string());
2298 process_command(
2299 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2300 &mut db,
2301 )
2302 .unwrap();
2303 for body in &[
2304 "rust embedded database",
2305 "rust web framework",
2306 "go embedded systems",
2307 "python web framework",
2308 "rust rust embedded power",
2309 ] {
2310 process_command(
2311 &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2312 &mut db,
2313 )
2314 .unwrap();
2315 }
2316 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2317 save_database(&mut db, &path).expect("save");
2318 } let mut loaded = open_database(&path, "test".to_string()).expect("open");
2321 {
2322 let table = loaded.get_table("docs".to_string()).expect("docs");
2323 assert_eq!(table.fts_indexes.len(), 1, "FTS index should reattach");
2324 let entry = &table.fts_indexes[0];
2325 assert_eq!(entry.name, "ix_body");
2326 assert_eq!(entry.column_name, "body");
2327 assert_eq!(
2328 entry.index.len(),
2329 5,
2330 "rebuilt posting list should hold all 5 rows"
2331 );
2332 assert!(!entry.needs_rebuild);
2333 }
2334
2335 let resp = process_command(
2338 "SELECT id FROM docs WHERE fts_match(body, 'rust');",
2339 &mut loaded,
2340 )
2341 .unwrap();
2342 assert!(resp.contains("3 rows returned"), "got: {resp}");
2343
2344 cleanup(&path);
2345 }
2346
2347 #[test]
2348 fn delete_then_save_then_reopen_excludes_deleted_node_from_fts() {
2349 let path = tmp_path("fts_delete_rebuild");
2354 let mut db = Database::new("test".to_string());
2355 process_command(
2356 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2357 &mut db,
2358 )
2359 .unwrap();
2360 for body in &[
2361 "rust embedded",
2362 "rust framework",
2363 "go embedded",
2364 "python web",
2365 ] {
2366 process_command(
2367 &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2368 &mut db,
2369 )
2370 .unwrap();
2371 }
2372 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2373
2374 process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
2376 save_database(&mut db, &path).expect("save");
2377 drop(db);
2378
2379 let mut loaded = open_database(&path, "test".to_string()).expect("open");
2380 let resp = process_command(
2381 "SELECT id FROM docs WHERE fts_match(body, 'rust');",
2382 &mut loaded,
2383 )
2384 .unwrap();
2385 assert!(resp.contains("1 row returned"), "got: {resp}");
2388
2389 cleanup(&path);
2390 }
2391
2392 #[test]
2393 fn fts_roundtrip_uses_persistence_path_not_replay() {
2394 let path = tmp_path("fts_persistence_path");
2399
2400 {
2401 let mut db = Database::new("test".to_string());
2402 process_command(
2403 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2404 &mut db,
2405 )
2406 .unwrap();
2407 process_command(
2408 "INSERT INTO docs (body) VALUES ('rust embedded database');",
2409 &mut db,
2410 )
2411 .unwrap();
2412 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2413 save_database(&mut db, &path).expect("save");
2414 }
2415
2416 let pager = Pager::open(&path).expect("open pager");
2418 let mut master = build_empty_master_table();
2419 load_table_rows(&pager, &mut master, pager.header().schema_root_page).unwrap();
2420 let mut found_rootpage: Option<u32> = None;
2421 for rowid in master.rowids() {
2422 let name = take_text(&master, "name", rowid).unwrap();
2423 if name == "ix_body" {
2424 let rp = take_integer(&master, "rootpage", rowid).unwrap();
2425 found_rootpage = Some(rp as u32);
2426 }
2427 }
2428 let rootpage = found_rootpage.expect("ix_body row in sqlrite_master");
2429 assert!(
2430 rootpage != 0,
2431 "Phase 8c FTS save should set rootpage != 0; got {rootpage}"
2432 );
2433
2434 cleanup(&path);
2435 }
2436
2437 #[test]
2438 fn save_without_fts_keeps_format_v4() {
2439 use crate::sql::pager::header::FORMAT_VERSION_V4;
2443
2444 let path = tmp_path("fts_no_bump");
2445 let mut db = Database::new("test".to_string());
2446 process_command(
2447 "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
2448 &mut db,
2449 )
2450 .unwrap();
2451 process_command("INSERT INTO t (n) VALUES (1);", &mut db).unwrap();
2452 save_database(&mut db, &path).unwrap();
2453 drop(db);
2454
2455 let pager = Pager::open(&path).expect("open");
2456 assert_eq!(
2457 pager.header().format_version,
2458 FORMAT_VERSION_V4,
2459 "no-FTS save should keep v4"
2460 );
2461 cleanup(&path);
2462 }
2463
2464 #[test]
2465 fn save_with_fts_bumps_to_v5() {
2466 use crate::sql::pager::header::FORMAT_VERSION_V5;
2470
2471 let path = tmp_path("fts_bump_v5");
2472 let mut db = Database::new("test".to_string());
2473 process_command(
2474 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2475 &mut db,
2476 )
2477 .unwrap();
2478 process_command("INSERT INTO docs (body) VALUES ('hello');", &mut db).unwrap();
2479 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2480 save_database(&mut db, &path).unwrap();
2481 drop(db);
2482
2483 let pager = Pager::open(&path).expect("open");
2484 assert_eq!(
2485 pager.header().format_version,
2486 FORMAT_VERSION_V5,
2487 "FTS save should promote to v5"
2488 );
2489 cleanup(&path);
2490 }
2491
2492 #[test]
2493 fn fts_persistence_handles_empty_and_zero_token_docs() {
2494 let path = tmp_path("fts_edges");
2500
2501 {
2502 let mut db = Database::new("test".to_string());
2503 process_command(
2504 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2505 &mut db,
2506 )
2507 .unwrap();
2508 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2509 process_command("INSERT INTO docs (body) VALUES ('rust embedded');", &mut db).unwrap();
2512 process_command("INSERT INTO docs (body) VALUES ('!!!---???');", &mut db).unwrap();
2513 process_command("INSERT INTO docs (body) VALUES ('go embedded');", &mut db).unwrap();
2514 save_database(&mut db, &path).unwrap();
2515 }
2516
2517 let loaded = open_database(&path, "test".to_string()).expect("open");
2518 let table = loaded.get_table("docs".to_string()).unwrap();
2519 let entry = &table.fts_indexes[0];
2520 assert_eq!(entry.index.len(), 3);
2523 let res = entry
2525 .index
2526 .query("embedded", &crate::sql::fts::Bm25Params::default());
2527 assert_eq!(res.len(), 2);
2528
2529 cleanup(&path);
2530 }
2531
2532 #[test]
2533 fn fts_persistence_round_trips_large_corpus() {
2534 let path = tmp_path("fts_large_corpus");
2538
2539 let mut expected_terms: std::collections::BTreeSet<String> =
2540 std::collections::BTreeSet::new();
2541 {
2542 let mut db = Database::new("test".to_string());
2543 process_command(
2544 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2545 &mut db,
2546 )
2547 .unwrap();
2548 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2549 for i in 0..500 {
2552 let term = format!("term{i:04}");
2553 process_command(
2554 &format!("INSERT INTO docs (body) VALUES ('{term}');"),
2555 &mut db,
2556 )
2557 .unwrap();
2558 expected_terms.insert(term);
2559 }
2560 save_database(&mut db, &path).unwrap();
2561 }
2562
2563 let loaded = open_database(&path, "test".to_string()).expect("open");
2564 let table = loaded.get_table("docs".to_string()).unwrap();
2565 let entry = &table.fts_indexes[0];
2566 assert_eq!(entry.index.len(), 500);
2567
2568 for &i in &[0_i64, 137, 248, 391, 499] {
2571 let term = format!("term{i:04}");
2572 let res = entry
2573 .index
2574 .query(&term, &crate::sql::fts::Bm25Params::default());
2575 assert_eq!(res.len(), 1, "term {term} should match exactly 1 row");
2576 assert_eq!(res[0].0, i + 1);
2579 }
2580
2581 cleanup(&path);
2582 }
2583
2584 #[test]
2585 fn delete_then_save_then_reopen_excludes_deleted_node_from_hnsw() {
2586 let path = tmp_path("hnsw_delete_rebuild");
2591 let mut db = Database::new("test".to_string());
2592 process_command(
2593 "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2594 &mut db,
2595 )
2596 .unwrap();
2597 for v in &["[1.0, 0.0]", "[2.0, 0.0]", "[3.0, 0.0]", "[4.0, 0.0]"] {
2598 process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2599 }
2600 process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2601
2602 process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
2604 let dirty_before_save = db.tables["docs"].hnsw_indexes[0].needs_rebuild;
2606 assert!(dirty_before_save, "DELETE should mark dirty");
2607
2608 save_database(&mut db, &path).expect("save");
2609 let dirty_after_save = db.tables["docs"].hnsw_indexes[0].needs_rebuild;
2611 assert!(!dirty_after_save, "save should clear dirty");
2612 drop(db);
2613
2614 let loaded = open_database(&path, "test".to_string()).expect("open");
2617 let docs = loaded.get_table("docs".to_string()).expect("docs");
2618
2619 assert!(
2621 !docs.rowids().contains(&1),
2622 "deleted row 1 should not be in row storage"
2623 );
2624 assert_eq!(docs.rowids().len(), 3, "should have 3 surviving rows");
2625
2626 assert_eq!(
2628 docs.hnsw_indexes[0].index.len(),
2629 3,
2630 "HNSW graph should have shed the deleted node"
2631 );
2632
2633 cleanup(&path);
2634 }
2635
2636 #[test]
2637 fn round_trip_survives_writes_after_load() {
2638 let path = tmp_path("after_load");
2639 save_database(&mut seed_db(), &path).unwrap();
2640
2641 {
2642 let mut db = open_database(&path, "test".to_string()).unwrap();
2643 process_command(
2644 "INSERT INTO users (name, age) VALUES ('carol', 40);",
2645 &mut db,
2646 )
2647 .unwrap();
2648 save_database(&mut db, &path).unwrap();
2649 } let db2 = open_database(&path, "test".to_string()).unwrap();
2652 let users = db2.get_table("users".to_string()).unwrap();
2653 assert_eq!(users.rowids().len(), 3);
2654
2655 cleanup(&path);
2656 }
2657
2658 #[test]
2659 fn open_rejects_garbage_file() {
2660 let path = tmp_path("bad");
2661 std::fs::write(&path, b"not a sqlrite database, just bytes").unwrap();
2662 let result = open_database(&path, "x".to_string());
2663 assert!(result.is_err());
2664 cleanup(&path);
2665 }
2666
2667 #[test]
2668 fn many_small_rows_spread_across_leaves() {
2669 let path = tmp_path("many_rows");
2670 let mut db = Database::new("big".to_string());
2671 process_command(
2672 "CREATE TABLE things (id INTEGER PRIMARY KEY, data TEXT);",
2673 &mut db,
2674 )
2675 .unwrap();
2676 for i in 0..200 {
2677 let body = "x".repeat(200);
2678 let q = format!("INSERT INTO things (data) VALUES ('row-{i}-{body}');");
2679 process_command(&q, &mut db).unwrap();
2680 }
2681 save_database(&mut db, &path).unwrap();
2682 let loaded = open_database(&path, "big".to_string()).unwrap();
2683 let things = loaded.get_table("things".to_string()).unwrap();
2684 assert_eq!(things.rowids().len(), 200);
2685 cleanup(&path);
2686 }
2687
2688 #[test]
2689 fn huge_row_goes_through_overflow() {
2690 let path = tmp_path("overflow_row");
2691 let mut db = Database::new("big".to_string());
2692 process_command(
2693 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2694 &mut db,
2695 )
2696 .unwrap();
2697 let body = "A".repeat(10_000);
2698 process_command(
2699 &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2700 &mut db,
2701 )
2702 .unwrap();
2703 save_database(&mut db, &path).unwrap();
2704
2705 let loaded = open_database(&path, "big".to_string()).unwrap();
2706 let docs = loaded.get_table("docs".to_string()).unwrap();
2707 let rowids = docs.rowids();
2708 assert_eq!(rowids.len(), 1);
2709 let stored = docs.get_value("body", rowids[0]);
2710 match stored {
2711 Some(Value::Text(s)) => assert_eq!(s.len(), 10_000),
2712 other => panic!("expected Text, got {other:?}"),
2713 }
2714 cleanup(&path);
2715 }
2716
2717 #[test]
2718 fn create_sql_synthesis_round_trips() {
2719 let mut db = Database::new("x".to_string());
2722 process_command(
2723 "CREATE TABLE t (id INTEGER PRIMARY KEY, tag TEXT UNIQUE, note TEXT NOT NULL);",
2724 &mut db,
2725 )
2726 .unwrap();
2727 let t = db.get_table("t".to_string()).unwrap();
2728 let sql = table_to_create_sql(t);
2729 let (name, cols) = parse_create_sql(&sql).unwrap();
2730 assert_eq!(name, "t");
2731 assert_eq!(cols.len(), 3);
2732 assert!(cols[0].is_pk);
2733 assert!(cols[1].is_unique);
2734 assert!(cols[2].not_null);
2735 }
2736
2737 #[test]
2738 fn sqlrite_master_is_not_exposed_as_a_user_table() {
2739 let path = tmp_path("no_master");
2741 save_database(&mut seed_db(), &path).unwrap();
2742 let loaded = open_database(&path, "x".to_string()).unwrap();
2743 assert!(!loaded.tables.contains_key(MASTER_TABLE_NAME));
2744 cleanup(&path);
2745 }
2746
2747 #[test]
2748 fn multi_leaf_table_produces_an_interior_root() {
2749 let path = tmp_path("multi_leaf_interior");
2755 let mut db = Database::new("big".to_string());
2756 process_command(
2757 "CREATE TABLE things (id INTEGER PRIMARY KEY, data TEXT);",
2758 &mut db,
2759 )
2760 .unwrap();
2761 for i in 0..200 {
2762 let body = "x".repeat(200);
2763 let q = format!("INSERT INTO things (data) VALUES ('row-{i}-{body}');");
2764 process_command(&q, &mut db).unwrap();
2765 }
2766 save_database(&mut db, &path).unwrap();
2767
2768 let loaded = open_database(&path, "big".to_string()).unwrap();
2770 let things = loaded.get_table("things".to_string()).unwrap();
2771 assert_eq!(things.rowids().len(), 200);
2772
2773 let pager = loaded
2776 .pager
2777 .as_ref()
2778 .expect("loaded DB should have a pager");
2779 let mut master = build_empty_master_table();
2784 load_table_rows(pager, &mut master, pager.header().schema_root_page).unwrap();
2785 let things_root = master
2786 .rowids()
2787 .into_iter()
2788 .find_map(|r| match master.get_value("name", r) {
2789 Some(Value::Text(s)) if s == "things" => match master.get_value("rootpage", r) {
2790 Some(Value::Integer(p)) => Some(p as u32),
2791 _ => None,
2792 },
2793 _ => None,
2794 })
2795 .expect("things should appear in sqlrite_master");
2796 let root_buf = pager.read_page(things_root).unwrap();
2797 assert_eq!(
2798 root_buf[0],
2799 PageType::InteriorNode as u8,
2800 "expected a multi-leaf table to have an interior root, got tag {}",
2801 root_buf[0]
2802 );
2803
2804 cleanup(&path);
2805 }
2806
2807 #[test]
2808 fn explicit_index_persists_across_save_and_open() {
2809 let path = tmp_path("idx_persist");
2810 let mut db = Database::new("idx".to_string());
2811 process_command(
2812 "CREATE TABLE users (id INTEGER PRIMARY KEY, tag TEXT);",
2813 &mut db,
2814 )
2815 .unwrap();
2816 for i in 1..=5 {
2817 let tag = if i % 2 == 0 { "odd" } else { "even" };
2818 process_command(
2819 &format!("INSERT INTO users (tag) VALUES ('{tag}');"),
2820 &mut db,
2821 )
2822 .unwrap();
2823 }
2824 process_command("CREATE INDEX users_tag_idx ON users (tag);", &mut db).unwrap();
2825 save_database(&mut db, &path).unwrap();
2826
2827 let loaded = open_database(&path, "idx".to_string()).unwrap();
2828 let users = loaded.get_table("users".to_string()).unwrap();
2829 let idx = users
2830 .index_by_name("users_tag_idx")
2831 .expect("explicit index should survive save/open");
2832 assert_eq!(idx.column_name, "tag");
2833 assert!(!idx.is_unique);
2834 let even_rowids = idx.lookup(&Value::Text("even".into()));
2837 let odd_rowids = idx.lookup(&Value::Text("odd".into()));
2838 assert_eq!(even_rowids.len(), 3);
2839 assert_eq!(odd_rowids.len(), 2);
2840
2841 cleanup(&path);
2842 }
2843
2844 #[test]
2845 fn auto_indexes_for_unique_columns_survive_save_open() {
2846 let path = tmp_path("auto_idx_persist");
2847 let mut db = Database::new("a".to_string());
2848 process_command(
2849 "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE);",
2850 &mut db,
2851 )
2852 .unwrap();
2853 process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
2854 process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
2855 save_database(&mut db, &path).unwrap();
2856
2857 let loaded = open_database(&path, "a".to_string()).unwrap();
2858 let users = loaded.get_table("users".to_string()).unwrap();
2859 let auto_name = SecondaryIndex::auto_name("users", "email");
2862 let idx = users
2863 .index_by_name(&auto_name)
2864 .expect("auto index should be restored");
2865 assert!(idx.is_unique);
2866 assert_eq!(idx.lookup(&Value::Text("a@x".into())).len(), 1);
2867 assert_eq!(idx.lookup(&Value::Text("b@x".into())).len(), 1);
2868
2869 cleanup(&path);
2870 }
2871
2872 #[test]
2883 fn secondary_index_with_interior_level_round_trips() {
2884 let path = tmp_path("sqlr1_wide_index");
2885 let mut db = Database::new("idx".to_string());
2886 db.source_path = Some(path.clone());
2887
2888 process_command(
2889 "CREATE TABLE bloat (id INTEGER PRIMARY KEY, payload TEXT);",
2890 &mut db,
2891 )
2892 .unwrap();
2893 process_command("BEGIN;", &mut db).unwrap();
2896 for i in 0..5000 {
2897 process_command(
2898 &format!("INSERT INTO bloat (payload) VALUES ('p-{i:08}');"),
2899 &mut db,
2900 )
2901 .unwrap();
2902 }
2903 process_command("COMMIT;", &mut db).unwrap();
2904
2905 process_command("CREATE INDEX idx_p ON bloat (payload);", &mut db).unwrap();
2907
2908 drop(db);
2912 let loaded = open_database(&path, "idx".to_string()).unwrap();
2913 let bloat = loaded.get_table("bloat".to_string()).unwrap();
2914 let idx = bloat
2915 .index_by_name("idx_p")
2916 .expect("idx_p should survive close/reopen");
2917 assert!(!idx.is_unique);
2918
2919 for &(probe_i, expected_rowid) in &[(0i64, 1i64), (2500, 2501), (4999, 5000)] {
2922 let value = Value::Text(format!("p-{probe_i:08}"));
2923 let hits = idx.lookup(&value);
2924 assert_eq!(
2925 hits,
2926 vec![expected_rowid],
2927 "lookup({value:?}) should yield rowid {expected_rowid}",
2928 );
2929 }
2930
2931 let pager = loaded.pager.as_ref().unwrap();
2935 let mut master = build_empty_master_table();
2936 load_table_rows(pager, &mut master, pager.header().schema_root_page).unwrap();
2937 let idx_root = master
2938 .rowids()
2939 .into_iter()
2940 .find_map(
2941 |r| match (master.get_value("name", r), master.get_value("type", r)) {
2942 (Some(Value::Text(name)), Some(Value::Text(kind)))
2943 if name == "idx_p" && kind == "index" =>
2944 {
2945 match master.get_value("rootpage", r) {
2946 Some(Value::Integer(p)) => Some(p as u32),
2947 _ => None,
2948 }
2949 }
2950 _ => None,
2951 },
2952 )
2953 .expect("idx_p should appear in sqlrite_master");
2954 let root_buf = pager.read_page(idx_root).unwrap();
2955 assert_eq!(
2956 root_buf[0],
2957 PageType::InteriorNode as u8,
2958 "5 000-entry index must have an interior root — without one this test wouldn't cover SQLR-1",
2959 );
2960 let leaf = find_leftmost_leaf(pager, idx_root).unwrap();
2961 let leaf_buf = pager.read_page(leaf).unwrap();
2962 assert_eq!(leaf_buf[0], PageType::TableLeaf as u8);
2963
2964 cleanup(&path);
2965 }
2966
2967 #[test]
2974 fn drop_then_recreate_wide_index_does_not_panic() {
2975 let path = tmp_path("sqlr1_drop_recreate");
2976 let mut db = Database::new("idx".to_string());
2977 db.source_path = Some(path.clone());
2978
2979 process_command(
2980 "CREATE TABLE bloat (id INTEGER PRIMARY KEY, payload TEXT);",
2981 &mut db,
2982 )
2983 .unwrap();
2984 process_command("BEGIN;", &mut db).unwrap();
2985 for i in 0..5000 {
2986 process_command(
2987 &format!("INSERT INTO bloat (payload) VALUES ('p-{i:08}');"),
2988 &mut db,
2989 )
2990 .unwrap();
2991 }
2992 process_command("COMMIT;", &mut db).unwrap();
2993
2994 process_command("CREATE INDEX idx_p ON bloat (payload);", &mut db).unwrap();
2995 process_command("DROP INDEX idx_p;", &mut db).unwrap();
2996 process_command("CREATE INDEX idx_p ON bloat (payload);", &mut db).unwrap();
2998
2999 drop(db);
3000 let loaded = open_database(&path, "idx".to_string()).unwrap();
3001 let bloat = loaded.get_table("bloat".to_string()).unwrap();
3002 let idx = bloat
3003 .index_by_name("idx_p")
3004 .expect("idx_p should survive drop+recreate+reopen");
3005 assert_eq!(
3006 idx.lookup(&Value::Text("p-00002500".into())),
3007 vec![2501],
3008 "post-recycle lookup must still resolve correctly",
3009 );
3010
3011 cleanup(&path);
3012 }
3013
3014 #[test]
3015 fn deep_tree_round_trips() {
3016 use crate::sql::db::table::Column as TableColumn;
3020
3021 let path = tmp_path("deep_tree");
3022 let mut db = Database::new("deep".to_string());
3023 let columns = vec![
3024 TableColumn::new("id".into(), "integer".into(), true, true, true),
3025 TableColumn::new("s".into(), "text".into(), false, true, false),
3026 ];
3027 let mut table = build_empty_table("t", columns, 0);
3028 for i in 1..=6_000i64 {
3032 let body = "q".repeat(900);
3033 table
3034 .restore_row(
3035 i,
3036 vec![
3037 Some(Value::Integer(i)),
3038 Some(Value::Text(format!("r-{i}-{body}"))),
3039 ],
3040 )
3041 .unwrap();
3042 }
3043 db.tables.insert("t".to_string(), table);
3044 save_database(&mut db, &path).unwrap();
3045
3046 let loaded = open_database(&path, "deep".to_string()).unwrap();
3047 let t = loaded.get_table("t".to_string()).unwrap();
3048 assert_eq!(t.rowids().len(), 6_000);
3049
3050 let pager = loaded.pager.as_ref().unwrap();
3053 let mut master = build_empty_master_table();
3054 load_table_rows(pager, &mut master, pager.header().schema_root_page).unwrap();
3055 let t_root = master
3056 .rowids()
3057 .into_iter()
3058 .find_map(|r| match master.get_value("name", r) {
3059 Some(Value::Text(s)) if s == "t" => match master.get_value("rootpage", r) {
3060 Some(Value::Integer(p)) => Some(p as u32),
3061 _ => None,
3062 },
3063 _ => None,
3064 })
3065 .expect("t in sqlrite_master");
3066 let root_buf = pager.read_page(t_root).unwrap();
3067 assert_eq!(root_buf[0], PageType::InteriorNode as u8);
3068 let root_payload: &[u8; PAYLOAD_PER_PAGE] =
3069 (&root_buf[PAGE_HEADER_SIZE..]).try_into().unwrap();
3070 let root_interior = InteriorPage::from_bytes(root_payload);
3071 let child = root_interior.leftmost_child().unwrap();
3072 let child_buf = pager.read_page(child).unwrap();
3073 assert_eq!(
3074 child_buf[0],
3075 PageType::InteriorNode as u8,
3076 "expected 3-level tree: root's leftmost child should also be InteriorNode",
3077 );
3078
3079 cleanup(&path);
3080 }
3081
3082 #[test]
3083 fn alter_rename_table_survives_save_and_reopen() {
3084 let path = tmp_path("alter_rename_table_roundtrip");
3085 let mut db = seed_db();
3086 save_database(&mut db, &path).expect("save");
3087
3088 process_command("ALTER TABLE users RENAME TO members;", &mut db).expect("rename");
3089 save_database(&mut db, &path).expect("save after rename");
3090
3091 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3092 assert!(!loaded.contains_table("users".to_string()));
3093 assert!(loaded.contains_table("members".to_string()));
3094 let members = loaded.get_table("members".to_string()).unwrap();
3095 assert_eq!(members.rowids().len(), 2, "rows should survive");
3096 assert!(
3098 members
3099 .index_by_name("sqlrite_autoindex_members_id")
3100 .is_some()
3101 );
3102 assert!(
3103 members
3104 .index_by_name("sqlrite_autoindex_members_name")
3105 .is_some()
3106 );
3107
3108 cleanup(&path);
3109 }
3110
3111 #[test]
3112 fn alter_rename_column_survives_save_and_reopen() {
3113 let path = tmp_path("alter_rename_col_roundtrip");
3114 let mut db = seed_db();
3115 save_database(&mut db, &path).expect("save");
3116
3117 process_command(
3118 "ALTER TABLE users RENAME COLUMN name TO full_name;",
3119 &mut db,
3120 )
3121 .expect("rename column");
3122 save_database(&mut db, &path).expect("save after rename");
3123
3124 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3125 let users = loaded.get_table("users".to_string()).unwrap();
3126 assert!(users.contains_column("full_name".to_string()));
3127 assert!(!users.contains_column("name".to_string()));
3128 let alice_rowid = users
3130 .rowids()
3131 .into_iter()
3132 .find(|r| users.get_value("full_name", *r) == Some(Value::Text("alice".to_string())))
3133 .expect("alice row should be findable under renamed column");
3134 assert_eq!(
3135 users.get_value("full_name", alice_rowid),
3136 Some(Value::Text("alice".to_string()))
3137 );
3138
3139 cleanup(&path);
3140 }
3141
3142 #[test]
3143 fn alter_add_column_with_default_survives_save_and_reopen() {
3144 let path = tmp_path("alter_add_default_roundtrip");
3145 let mut db = seed_db();
3146 save_database(&mut db, &path).expect("save");
3147
3148 process_command(
3149 "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
3150 &mut db,
3151 )
3152 .expect("add column");
3153 save_database(&mut db, &path).expect("save after add");
3154
3155 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3156 let users = loaded.get_table("users".to_string()).unwrap();
3157 assert!(users.contains_column("status".to_string()));
3158 for rowid in users.rowids() {
3159 assert_eq!(
3160 users.get_value("status", rowid),
3161 Some(Value::Text("active".to_string())),
3162 "backfilled default should round-trip for rowid {rowid}"
3163 );
3164 }
3165 let status_col = users
3168 .columns
3169 .iter()
3170 .find(|c| c.column_name == "status")
3171 .unwrap();
3172 assert_eq!(status_col.default, Some(Value::Text("active".to_string())));
3173
3174 cleanup(&path);
3175 }
3176
3177 #[test]
3178 fn alter_drop_column_survives_save_and_reopen() {
3179 let path = tmp_path("alter_drop_col_roundtrip");
3180 let mut db = seed_db();
3181 save_database(&mut db, &path).expect("save");
3182
3183 process_command("ALTER TABLE users DROP COLUMN age;", &mut db).expect("drop column");
3184 save_database(&mut db, &path).expect("save after drop");
3185
3186 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3187 let users = loaded.get_table("users".to_string()).unwrap();
3188 assert!(!users.contains_column("age".to_string()));
3189 assert!(users.contains_column("name".to_string()));
3190
3191 cleanup(&path);
3192 }
3193
3194 #[test]
3195 fn drop_table_survives_save_and_reopen() {
3196 let path = tmp_path("drop_table_roundtrip");
3197 let mut db = seed_db();
3198 save_database(&mut db, &path).expect("save");
3199
3200 {
3202 let loaded = open_database(&path, "t".to_string()).expect("open");
3203 assert!(loaded.contains_table("users".to_string()));
3204 assert!(loaded.contains_table("notes".to_string()));
3205 }
3206
3207 process_command("DROP TABLE users;", &mut db).expect("drop users");
3208 save_database(&mut db, &path).expect("save after drop");
3209
3210 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3211 assert!(
3212 !loaded.contains_table("users".to_string()),
3213 "dropped table should not resurface on reopen"
3214 );
3215 assert!(
3216 loaded.contains_table("notes".to_string()),
3217 "untouched table should survive"
3218 );
3219
3220 cleanup(&path);
3221 }
3222
3223 #[test]
3224 fn drop_index_survives_save_and_reopen() {
3225 let path = tmp_path("drop_index_roundtrip");
3226 let mut db = Database::new("t".to_string());
3227 process_command(
3228 "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3229 &mut db,
3230 )
3231 .unwrap();
3232 process_command("CREATE INDEX notes_body_idx ON notes (body);", &mut db).unwrap();
3233 save_database(&mut db, &path).expect("save");
3234
3235 process_command("DROP INDEX notes_body_idx;", &mut db).unwrap();
3236 save_database(&mut db, &path).expect("save after drop");
3237
3238 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3239 let notes = loaded.get_table("notes".to_string()).unwrap();
3240 assert!(
3241 notes.index_by_name("notes_body_idx").is_none(),
3242 "dropped index should not resurface on reopen"
3243 );
3244 assert!(notes.index_by_name("sqlrite_autoindex_notes_id").is_some());
3246
3247 cleanup(&path);
3248 }
3249
3250 #[test]
3251 fn default_clause_survives_save_and_reopen() {
3252 let path = tmp_path("default_roundtrip");
3253 let mut db = Database::new("t".to_string());
3254
3255 process_command(
3256 "CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active', score INTEGER DEFAULT 0);",
3257 &mut db,
3258 )
3259 .unwrap();
3260 save_database(&mut db, &path).expect("save");
3261
3262 let mut loaded = open_database(&path, "t".to_string()).expect("open");
3263
3264 let users = loaded.get_table("users".to_string()).expect("users table");
3266 let status_col = users
3267 .columns
3268 .iter()
3269 .find(|c| c.column_name == "status")
3270 .expect("status column");
3271 assert_eq!(
3272 status_col.default,
3273 Some(Value::Text("active".to_string())),
3274 "DEFAULT 'active' should round-trip"
3275 );
3276 let score_col = users
3277 .columns
3278 .iter()
3279 .find(|c| c.column_name == "score")
3280 .expect("score column");
3281 assert_eq!(
3282 score_col.default,
3283 Some(Value::Integer(0)),
3284 "DEFAULT 0 should round-trip"
3285 );
3286
3287 process_command("INSERT INTO users (id) VALUES (1);", &mut loaded).unwrap();
3290 let users = loaded.get_table("users".to_string()).unwrap();
3291 assert_eq!(
3292 users.get_value("status", 1),
3293 Some(Value::Text("active".to_string()))
3294 );
3295 assert_eq!(users.get_value("score", 1), Some(Value::Integer(0)));
3296
3297 cleanup(&path);
3298 }
3299
3300 #[test]
3309 fn drop_table_freelist_persists_pages_for_reuse() {
3310 let path = tmp_path("freelist_reuse");
3311 let mut db = seed_db();
3312 db.source_path = Some(path.clone());
3313 save_database(&mut db, &path).expect("save");
3314 let pages_two_tables = db.pager.as_ref().unwrap().header().page_count;
3315
3316 process_command("DROP TABLE users;", &mut db).expect("drop users");
3318 let pages_after_drop = db.pager.as_ref().unwrap().header().page_count;
3319 assert_eq!(
3320 pages_after_drop, pages_two_tables,
3321 "page_count should not shrink on drop — the freed pages persist on the freelist"
3322 );
3323 let head_after_drop = db.pager.as_ref().unwrap().header().freelist_head;
3324 assert!(
3325 head_after_drop != 0,
3326 "freelist_head must be non-zero after drop"
3327 );
3328
3329 process_command(
3331 "CREATE TABLE accounts (id INTEGER PRIMARY KEY, label TEXT NOT NULL UNIQUE);",
3332 &mut db,
3333 )
3334 .expect("create accounts");
3335 process_command("INSERT INTO accounts (label) VALUES ('a');", &mut db).unwrap();
3336 process_command("INSERT INTO accounts (label) VALUES ('b');", &mut db).unwrap();
3337 let pages_after_create = db.pager.as_ref().unwrap().header().page_count;
3338 assert!(
3339 pages_after_create <= pages_two_tables + 2,
3340 "creating a similar-sized table after a drop should mostly draw from the \
3341 freelist, not extend the file (got {pages_after_create} > {pages_two_tables} + 2)"
3342 );
3343
3344 cleanup(&path);
3345 }
3346
3347 #[test]
3349 fn drop_then_vacuum_shrinks_file() {
3350 let path = tmp_path("vacuum_shrinks");
3351 let mut db = seed_db();
3352 db.source_path = Some(path.clone());
3353 for i in 0..20 {
3355 process_command(
3356 &format!("INSERT INTO users (name, age) VALUES ('user{i}', {i});"),
3357 &mut db,
3358 )
3359 .unwrap();
3360 }
3361 save_database(&mut db, &path).expect("save");
3362
3363 process_command("DROP TABLE users;", &mut db).expect("drop");
3364 let size_before_vacuum = std::fs::metadata(&path).unwrap().len();
3365 let pages_before_vacuum = db.pager.as_ref().unwrap().header().page_count;
3366 let head_before = db.pager.as_ref().unwrap().header().freelist_head;
3367 assert!(head_before != 0, "drop should populate the freelist");
3368
3369 process_command("VACUUM;", &mut db).expect("vacuum");
3372
3373 let size_after = std::fs::metadata(&path).unwrap().len();
3374 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3375 let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3376 assert!(
3377 pages_after < pages_before_vacuum,
3378 "VACUUM must reduce page_count: was {pages_before_vacuum}, now {pages_after}"
3379 );
3380 assert_eq!(head_after, 0, "VACUUM must clear the freelist");
3381 assert!(
3382 size_after < size_before_vacuum,
3383 "VACUUM must shrink the file on disk: was {size_before_vacuum} bytes, now {size_after}"
3384 );
3385
3386 cleanup(&path);
3387 }
3388
3389 #[test]
3391 fn vacuum_round_trips_data() {
3392 let path = tmp_path("vacuum_round_trip");
3393 let mut db = seed_db();
3394 db.source_path = Some(path.clone());
3395 save_database(&mut db, &path).expect("save");
3396 process_command("VACUUM;", &mut db).expect("vacuum");
3397
3398 drop(db);
3400 let loaded = open_database(&path, "t".to_string()).expect("reopen after vacuum");
3401 assert!(loaded.contains_table("users".to_string()));
3402 assert!(loaded.contains_table("notes".to_string()));
3403 let users = loaded.get_table("users".to_string()).unwrap();
3404 assert_eq!(users.rowids().len(), 2);
3406
3407 cleanup(&path);
3408 }
3409
3410 #[test]
3414 fn freelist_format_version_promotion() {
3415 use crate::sql::pager::header::{FORMAT_VERSION_BASELINE, FORMAT_VERSION_V6};
3416 let path = tmp_path("v6_promotion");
3417 let mut db = seed_db();
3418 db.source_path = Some(path.clone());
3419 save_database(&mut db, &path).expect("save");
3420 let v_after_save = db.pager.as_ref().unwrap().header().format_version;
3421 assert_eq!(
3422 v_after_save, FORMAT_VERSION_BASELINE,
3423 "fresh DB without drops should stay at the baseline version"
3424 );
3425
3426 process_command("DROP TABLE users;", &mut db).expect("drop");
3427 let v_after_drop = db.pager.as_ref().unwrap().header().format_version;
3428 assert_eq!(
3429 v_after_drop, FORMAT_VERSION_V6,
3430 "first save with a non-empty freelist must promote to V6"
3431 );
3432
3433 process_command("VACUUM;", &mut db).expect("vacuum");
3434 let v_after_vacuum = db.pager.as_ref().unwrap().header().format_version;
3435 assert_eq!(
3436 v_after_vacuum, FORMAT_VERSION_V6,
3437 "VACUUM must not downgrade — V6 is a strict superset"
3438 );
3439
3440 cleanup(&path);
3441 }
3442
3443 #[test]
3447 fn freelist_round_trip_through_reopen() {
3448 let path = tmp_path("freelist_reopen");
3449 let pages_two_tables;
3450 {
3451 let mut db = seed_db();
3452 db.source_path = Some(path.clone());
3453 save_database(&mut db, &path).expect("save");
3454 pages_two_tables = db.pager.as_ref().unwrap().header().page_count;
3455 process_command("DROP TABLE users;", &mut db).expect("drop");
3456 let head = db.pager.as_ref().unwrap().header().freelist_head;
3457 assert!(head != 0, "drop must populate the freelist");
3458 }
3459
3460 let mut db = open_database(&path, "t".to_string()).expect("reopen");
3462 assert!(
3463 db.pager.as_ref().unwrap().header().freelist_head != 0,
3464 "freelist_head must survive close/reopen"
3465 );
3466
3467 process_command(
3468 "CREATE TABLE accounts (id INTEGER PRIMARY KEY, label TEXT NOT NULL UNIQUE);",
3469 &mut db,
3470 )
3471 .expect("create accounts");
3472 process_command("INSERT INTO accounts (label) VALUES ('reopened');", &mut db).unwrap();
3473 let pages_after_create = db.pager.as_ref().unwrap().header().page_count;
3474 assert!(
3475 pages_after_create <= pages_two_tables + 2,
3476 "post-reopen create should reuse freelist (got {pages_after_create} > \
3477 {pages_two_tables} + 2 — file extended instead of reusing)"
3478 );
3479
3480 cleanup(&path);
3481 }
3482
3483 #[test]
3486 fn vacuum_inside_transaction_is_rejected() {
3487 let path = tmp_path("vacuum_txn");
3488 let mut db = seed_db();
3489 db.source_path = Some(path.clone());
3490 save_database(&mut db, &path).expect("save");
3491
3492 process_command("BEGIN;", &mut db).expect("begin");
3493 let err = process_command("VACUUM;", &mut db).unwrap_err();
3494 assert!(
3495 format!("{err}").contains("VACUUM cannot run inside a transaction"),
3496 "expected in-transaction rejection, got: {err}"
3497 );
3498 process_command("ROLLBACK;", &mut db).unwrap();
3500 cleanup(&path);
3501 }
3502
3503 #[test]
3505 fn vacuum_on_in_memory_database_is_noop() {
3506 let mut db = Database::new("mem".to_string());
3507 process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap();
3508 let out = process_command("VACUUM;", &mut db).expect("vacuum no-op");
3509 assert!(
3510 out.to_lowercase().contains("no-op") || out.to_lowercase().contains("in-memory"),
3511 "expected no-op message for in-memory VACUUM, got: {out}"
3512 );
3513 }
3514
3515 #[test]
3520 fn unchanged_table_pages_skip_diff_after_unrelated_drop() {
3521 let path = tmp_path("diff_after_drop");
3526 let mut db = Database::new("t".to_string());
3527 db.source_path = Some(path.clone());
3528 process_command(
3529 "CREATE TABLE accounts (id INTEGER PRIMARY KEY, label TEXT);",
3530 &mut db,
3531 )
3532 .unwrap();
3533 process_command(
3534 "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3535 &mut db,
3536 )
3537 .unwrap();
3538 process_command(
3539 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
3540 &mut db,
3541 )
3542 .unwrap();
3543 for i in 0..5 {
3544 process_command(
3545 &format!("INSERT INTO accounts (label) VALUES ('a{i}');"),
3546 &mut db,
3547 )
3548 .unwrap();
3549 process_command(
3550 &format!("INSERT INTO notes (body) VALUES ('n{i}');"),
3551 &mut db,
3552 )
3553 .unwrap();
3554 process_command(
3555 &format!("INSERT INTO users (name) VALUES ('u{i}');"),
3556 &mut db,
3557 )
3558 .unwrap();
3559 }
3560 save_database(&mut db, &path).expect("baseline save");
3561
3562 let pager = db.pager.as_ref().unwrap();
3565 let acc_root = read_old_rootpages(pager, pager.header().schema_root_page)
3566 .unwrap()
3567 .get(&("table".to_string(), "accounts".to_string()))
3568 .copied()
3569 .unwrap();
3570 let users_root = read_old_rootpages(pager, pager.header().schema_root_page)
3571 .unwrap()
3572 .get(&("table".to_string(), "users".to_string()))
3573 .copied()
3574 .unwrap();
3575 let acc_bytes_before: Vec<u8> = pager.read_page(acc_root).unwrap().to_vec();
3576 let users_bytes_before: Vec<u8> = pager.read_page(users_root).unwrap().to_vec();
3577
3578 process_command("DROP TABLE notes;", &mut db).expect("drop notes");
3580
3581 let pager = db.pager.as_ref().unwrap();
3582 let acc_after = pager.read_page(acc_root).unwrap();
3585 let users_after = pager.read_page(users_root).unwrap();
3586 assert_eq!(
3587 &acc_after[..],
3588 &acc_bytes_before[..],
3589 "accounts root page must not be rewritten when an unrelated table is dropped"
3590 );
3591 assert_eq!(
3592 &users_after[..],
3593 &users_bytes_before[..],
3594 "users root page must not be rewritten when an unrelated table is dropped"
3595 );
3596
3597 cleanup(&path);
3598 }
3599
3600 fn auto_vacuum_setup(path: &std::path::Path) -> Database {
3608 let mut db = Database::new("av".to_string());
3609 db.source_path = Some(path.to_path_buf());
3610 process_command(
3611 "CREATE TABLE keep (id INTEGER PRIMARY KEY, n INTEGER);",
3612 &mut db,
3613 )
3614 .unwrap();
3615 process_command("INSERT INTO keep (n) VALUES (1);", &mut db).unwrap();
3616 process_command(
3617 "CREATE TABLE bloat (id INTEGER PRIMARY KEY, payload TEXT);",
3618 &mut db,
3619 )
3620 .unwrap();
3621 process_command("BEGIN;", &mut db).unwrap();
3624 for i in 0..5000 {
3625 process_command(
3626 &format!("INSERT INTO bloat (payload) VALUES ('p-{i:08}');"),
3627 &mut db,
3628 )
3629 .unwrap();
3630 }
3631 process_command("COMMIT;", &mut db).unwrap();
3632 db
3633 }
3634
3635 #[test]
3639 fn auto_vacuum_default_threshold_triggers_on_drop_table() {
3640 let path = tmp_path("av_default_drop_table");
3641 let mut db = auto_vacuum_setup(&path);
3642 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3644
3645 if let Some(p) = db.pager.as_mut() {
3650 let _ = p.checkpoint();
3651 }
3652 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3653 let size_before = std::fs::metadata(&path).unwrap().len();
3654 assert!(
3655 pages_before >= MIN_PAGES_FOR_AUTO_VACUUM,
3656 "setup should produce >= MIN_PAGES_FOR_AUTO_VACUUM ({MIN_PAGES_FOR_AUTO_VACUUM}) \
3657 pages so the floor doesn't suppress the trigger; got {pages_before}"
3658 );
3659
3660 process_command("DROP TABLE bloat;", &mut db).expect("drop");
3664
3665 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3666 let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3667 if let Some(p) = db.pager.as_mut() {
3671 let _ = p.checkpoint();
3672 }
3673 let size_after = std::fs::metadata(&path).unwrap().len();
3674
3675 assert!(
3676 pages_after < pages_before,
3677 "auto-VACUUM must reduce page_count: was {pages_before}, now {pages_after}"
3678 );
3679 assert_eq!(head_after, 0, "auto-VACUUM must clear the freelist");
3680 assert!(
3681 size_after < size_before,
3682 "auto-VACUUM must shrink the file on disk: was {size_before}, now {size_after}"
3683 );
3684
3685 cleanup(&path);
3686 }
3687
3688 #[test]
3692 fn auto_vacuum_disabled_keeps_file_at_hwm() {
3693 let path = tmp_path("av_disabled");
3694 let mut db = auto_vacuum_setup(&path);
3695 db.set_auto_vacuum_threshold(None).expect("disable");
3696 assert_eq!(db.auto_vacuum_threshold(), None);
3697
3698 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3699
3700 process_command("DROP TABLE bloat;", &mut db).expect("drop");
3701
3702 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3703 let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3704 assert_eq!(
3705 pages_after, pages_before,
3706 "with auto-VACUUM disabled, drop must keep page_count at the HWM"
3707 );
3708 assert!(
3709 head_after != 0,
3710 "drop must still populate the freelist (manual VACUUM would be needed to reclaim)"
3711 );
3712
3713 cleanup(&path);
3714 }
3715
3716 #[test]
3728 fn auto_vacuum_triggers_on_drop_index() {
3729 let path = tmp_path("av_drop_index");
3730 let mut db = auto_vacuum_setup(&path);
3731
3732 db.set_auto_vacuum_threshold(None).expect("disable");
3735 process_command("DROP TABLE bloat;", &mut db).expect("drop bloat");
3736 let pages_after_bloat_drop = db.pager.as_ref().unwrap().header().page_count;
3737 let head_after_bloat_drop = db.pager.as_ref().unwrap().header().freelist_head;
3738 assert!(
3739 head_after_bloat_drop != 0,
3740 "bloat drop must populate the freelist (else later index drop won't trip the threshold)"
3741 );
3742
3743 process_command("CREATE INDEX idx_keep_n ON keep (n);", &mut db).expect("create idx");
3747
3748 db.set_auto_vacuum_threshold(Some(0.25)).expect("re-arm");
3753 process_command("DROP INDEX idx_keep_n;", &mut db).expect("drop index");
3754
3755 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3756 let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3757 assert!(
3758 pages_after < pages_after_bloat_drop,
3759 "DROP INDEX should fire auto-VACUUM and reduce page_count: \
3760 was {pages_after_bloat_drop}, now {pages_after}"
3761 );
3762 assert_eq!(
3763 head_after, 0,
3764 "auto-VACUUM after DROP INDEX must clear the freelist"
3765 );
3766
3767 cleanup(&path);
3768 }
3769
3770 #[test]
3773 fn auto_vacuum_triggers_on_alter_drop_column() {
3774 let path = tmp_path("av_alter_drop_col");
3775 let mut db = auto_vacuum_setup(&path);
3776 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3777
3778 process_command("ALTER TABLE bloat DROP COLUMN payload;", &mut db).expect("alter drop");
3781
3782 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3783 assert!(
3784 pages_after < pages_before,
3785 "ALTER TABLE DROP COLUMN should fire auto-VACUUM and reduce page_count: \
3786 was {pages_before}, now {pages_after}"
3787 );
3788 assert_eq!(db.pager.as_ref().unwrap().header().freelist_head, 0);
3789
3790 cleanup(&path);
3791 }
3792
3793 #[test]
3796 fn auto_vacuum_skips_below_threshold() {
3797 let path = tmp_path("av_below_threshold");
3798 let mut db = auto_vacuum_setup(&path);
3799 db.set_auto_vacuum_threshold(Some(0.99)).expect("set");
3800
3801 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3802
3803 process_command("DROP TABLE bloat;", &mut db).expect("drop");
3804
3805 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3806 assert_eq!(
3807 pages_after, pages_before,
3808 "freelist ratio after a single drop is far below 0.99 — \
3809 page_count must stay at the HWM"
3810 );
3811 assert!(
3812 db.pager.as_ref().unwrap().header().freelist_head != 0,
3813 "drop must still populate the freelist"
3814 );
3815
3816 cleanup(&path);
3817 }
3818
3819 #[test]
3825 fn auto_vacuum_skips_inside_transaction() {
3826 let path = tmp_path("av_in_txn");
3827 let mut db = auto_vacuum_setup(&path);
3828 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3829
3830 process_command("BEGIN;", &mut db).expect("begin");
3831 process_command("DROP TABLE bloat;", &mut db).expect("drop in txn");
3832 let pages_mid = db.pager.as_ref().unwrap().header().page_count;
3836 assert_eq!(
3837 pages_mid, pages_before,
3838 "auto-VACUUM must not fire mid-transaction"
3839 );
3840
3841 process_command("ROLLBACK;", &mut db).expect("rollback");
3842 cleanup(&path);
3843 }
3844
3845 #[test]
3849 fn auto_vacuum_skips_under_min_pages_floor() {
3850 let path = tmp_path("av_under_floor");
3851 let mut db = seed_db(); db.source_path = Some(path.clone());
3853 save_database(&mut db, &path).expect("save");
3854 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3856 assert!(
3857 pages_before < MIN_PAGES_FOR_AUTO_VACUUM,
3858 "test setup is too large: floor would not apply (got {pages_before} pages, \
3859 floor is {MIN_PAGES_FOR_AUTO_VACUUM})"
3860 );
3861
3862 process_command("DROP TABLE users;", &mut db).expect("drop");
3863
3864 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3865 assert_eq!(
3866 pages_after, pages_before,
3867 "below MIN_PAGES_FOR_AUTO_VACUUM, drop must not trigger compaction"
3868 );
3869 assert!(
3870 db.pager.as_ref().unwrap().header().freelist_head != 0,
3871 "drop must still populate the freelist normally"
3872 );
3873
3874 cleanup(&path);
3875 }
3876
3877 #[test]
3880 fn set_auto_vacuum_threshold_rejects_out_of_range() {
3881 let mut db = Database::new("t".to_string());
3882 for bad in [-0.01_f32, 1.01, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
3883 let err = db.set_auto_vacuum_threshold(Some(bad)).unwrap_err();
3884 assert!(
3885 format!("{err}").contains("auto_vacuum_threshold"),
3886 "expected a typed range error for {bad}, got: {err}"
3887 );
3888 }
3889 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3891 db.set_auto_vacuum_threshold(Some(0.0)).unwrap();
3893 assert_eq!(db.auto_vacuum_threshold(), Some(0.0));
3894 db.set_auto_vacuum_threshold(Some(1.0)).unwrap();
3895 assert_eq!(db.auto_vacuum_threshold(), Some(1.0));
3896 db.set_auto_vacuum_threshold(None).unwrap();
3897 assert_eq!(db.auto_vacuum_threshold(), None);
3898 }
3899
3900 #[test]
3910 fn pragma_auto_vacuum_set_and_read_via_sql() {
3911 let mut db = Database::new("t".to_string());
3912
3913 let resp = process_command("PRAGMA auto_vacuum = 0.5;", &mut db).expect("set");
3914 assert!(
3915 resp.contains("PRAGMA"),
3916 "set form should produce a PRAGMA status, got: {resp}"
3917 );
3918 assert_eq!(db.auto_vacuum_threshold(), Some(0.5));
3919
3920 let resp = process_command("PRAGMA auto_vacuum;", &mut db).expect("read");
3922 assert!(resp.contains("1 row"), "expected a 1-row read, got: {resp}");
3923 }
3924
3925 #[test]
3930 fn pragma_auto_vacuum_off_disables_trigger() {
3931 for raw in ["OFF", "off", "NONE", "none", "'OFF'", "'NONE'"] {
3932 let mut db = Database::new("t".to_string());
3933 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3934
3935 let stmt = format!("PRAGMA auto_vacuum = {raw};");
3936 process_command(&stmt, &mut db)
3937 .unwrap_or_else(|e| panic!("`{stmt}` should disable: {e}"));
3938 assert_eq!(
3939 db.auto_vacuum_threshold(),
3940 None,
3941 "`{stmt}` should clear the threshold"
3942 );
3943 }
3944 }
3945
3946 #[test]
3950 fn pragma_auto_vacuum_rejects_out_of_range_via_sql() {
3951 let mut db = Database::new("t".to_string());
3952 for bad in ["-0.01", "1.01", "1.5"] {
3953 let stmt = format!("PRAGMA auto_vacuum = {bad};");
3954 let err = process_command(&stmt, &mut db).unwrap_err();
3955 assert!(
3956 format!("{err}").contains("auto_vacuum_threshold"),
3957 "expected range error for `{stmt}`, got: {err}"
3958 );
3959 }
3960 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3962 }
3963
3964 #[test]
3968 fn pragma_auto_vacuum_rejects_unknown_strings_via_sql() {
3969 let mut db = Database::new("t".to_string());
3970 let err = process_command("PRAGMA auto_vacuum = WAL;", &mut db).unwrap_err();
3971 assert!(
3972 format!("{err}").contains("OFF/NONE"),
3973 "expected OFF/NONE-style error, got: {err}"
3974 );
3975 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3977 }
3978
3979 #[test]
3984 fn pragma_unknown_returns_not_implemented() {
3985 let mut db = Database::new("t".to_string());
3986 let err = process_command("PRAGMA synchronous = NORMAL;", &mut db).unwrap_err();
3987 assert!(
3988 matches!(err, SQLRiteError::NotImplemented(_)),
3989 "unknown pragma must surface NotImplemented, got: {err:?}"
3990 );
3991 }
3992
3993 #[test]
3999 fn pragma_auto_vacuum_drives_real_trigger() {
4000 {
4002 let path = tmp_path("av_pragma_off");
4003 let mut db = auto_vacuum_setup(&path);
4004 process_command("PRAGMA auto_vacuum = OFF;", &mut db).expect("disable via PRAGMA");
4005 assert_eq!(db.auto_vacuum_threshold(), None);
4006
4007 let pages_before = db.pager.as_ref().unwrap().header().page_count;
4008 process_command("DROP TABLE bloat;", &mut db).expect("drop");
4009 let pages_after = db.pager.as_ref().unwrap().header().page_count;
4010 assert_eq!(
4011 pages_after, pages_before,
4012 "PRAGMA-driven OFF must keep page_count at the HWM"
4013 );
4014 cleanup(&path);
4015 }
4016
4017 {
4020 let path = tmp_path("av_pragma_high");
4021 let mut db = auto_vacuum_setup(&path);
4022 process_command("PRAGMA auto_vacuum = 0.99;", &mut db).expect("set high");
4023 assert_eq!(db.auto_vacuum_threshold(), Some(0.99));
4024
4025 let pages_before = db.pager.as_ref().unwrap().header().page_count;
4026 process_command("DROP TABLE bloat;", &mut db).expect("drop");
4027 let pages_after = db.pager.as_ref().unwrap().header().page_count;
4028 assert_eq!(
4029 pages_after, pages_before,
4030 "high PRAGMA threshold must suppress the trigger"
4031 );
4032 cleanup(&path);
4033 }
4034
4035 {
4038 let path = tmp_path("av_pragma_rearm");
4039 let mut db = auto_vacuum_setup(&path);
4040 process_command("PRAGMA auto_vacuum = OFF;", &mut db).unwrap();
4041 process_command("DROP TABLE bloat;", &mut db).unwrap();
4044 let pages_after_off_drop = db.pager.as_ref().unwrap().header().page_count;
4045 assert!(db.pager.as_ref().unwrap().header().freelist_head != 0);
4046
4047 process_command("PRAGMA auto_vacuum = 0.25;", &mut db).expect("re-arm");
4051 process_command("CREATE INDEX idx_keep_n ON keep (n);", &mut db).unwrap();
4052 process_command("DROP INDEX idx_keep_n;", &mut db).expect("drop index");
4053
4054 let pages_after_rearm = db.pager.as_ref().unwrap().header().page_count;
4055 assert!(
4056 pages_after_rearm < pages_after_off_drop,
4057 "re-armed PRAGMA must let auto-VACUUM fire: was {pages_after_off_drop}, \
4058 now {pages_after_rearm}"
4059 );
4060 assert_eq!(db.pager.as_ref().unwrap().header().freelist_head, 0);
4061 cleanup(&path);
4062 }
4063 }
4064
4065 #[test]
4068 fn vacuum_modifiers_are_rejected() {
4069 let path = tmp_path("vacuum_modifiers");
4070 let mut db = seed_db();
4071 db.source_path = Some(path.clone());
4072 save_database(&mut db, &path).expect("save");
4073 for stmt in ["VACUUM FULL;", "VACUUM users;"] {
4074 let err = process_command(stmt, &mut db).unwrap_err();
4075 assert!(
4076 format!("{err}").contains("VACUUM modifiers"),
4077 "expected modifier rejection for `{stmt}`, got: {err}"
4078 );
4079 }
4080 cleanup(&path);
4081 }
4082}