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) {
1255 use crate::sql::hnsw::HnswIndex;
1256
1257 for table in db.tables.values_mut() {
1258 let dirty: Vec<(String, String, DistanceMetric)> = table
1264 .hnsw_indexes
1265 .iter()
1266 .filter(|e| e.needs_rebuild)
1267 .map(|e| (e.name.clone(), e.column_name.clone(), e.metric))
1268 .collect();
1269 if dirty.is_empty() {
1270 continue;
1271 }
1272
1273 for (idx_name, col_name, metric) in dirty {
1274 let mut vectors: Vec<(i64, Vec<f32>)> = Vec::new();
1276 {
1277 let row_data = table.rows.lock().expect("rows mutex poisoned");
1278 if let Some(Row::Vector(map)) = row_data.get(&col_name) {
1279 for (id, v) in map.iter() {
1280 vectors.push((*id, v.clone()));
1281 }
1282 }
1283 }
1284 let snapshot: std::collections::HashMap<i64, Vec<f32>> =
1287 vectors.iter().cloned().collect();
1288
1289 let mut new_idx = HnswIndex::new(metric, 0xC0FFEE);
1290 vectors.sort_by_key(|(id, _)| *id);
1292 for (id, v) in &vectors {
1293 new_idx.insert(*id, v, |q| snapshot.get(&q).cloned().unwrap_or_default());
1294 }
1295
1296 if let Some(entry) = table.hnsw_indexes.iter_mut().find(|e| e.name == idx_name) {
1298 entry.index = new_idx;
1299 entry.needs_rebuild = false;
1300 }
1301 }
1302 }
1303}
1304
1305fn synthesize_hnsw_create_index_sql(
1310 index_name: &str,
1311 table_name: &str,
1312 column_name: &str,
1313 metric: DistanceMetric,
1314) -> String {
1315 if matches!(metric, DistanceMetric::L2) {
1316 format!("CREATE INDEX {index_name} ON {table_name} USING hnsw ({column_name})")
1317 } else {
1318 format!(
1319 "CREATE INDEX {index_name} ON {table_name} USING hnsw ({column_name}) WITH (metric = '{}')",
1320 metric.sql_name()
1321 )
1322 }
1323}
1324
1325fn rebuild_dirty_fts_indexes(db: &mut Database) {
1330 use crate::sql::fts::PostingList;
1331
1332 for table in db.tables.values_mut() {
1333 let dirty: Vec<(String, String)> = table
1334 .fts_indexes
1335 .iter()
1336 .filter(|e| e.needs_rebuild)
1337 .map(|e| (e.name.clone(), e.column_name.clone()))
1338 .collect();
1339 if dirty.is_empty() {
1340 continue;
1341 }
1342
1343 for (idx_name, col_name) in dirty {
1344 let mut docs: Vec<(i64, String)> = Vec::new();
1347 {
1348 let row_data = table.rows.lock().expect("rows mutex poisoned");
1349 if let Some(Row::Text(map)) = row_data.get(&col_name) {
1350 for (id, v) in map.iter() {
1351 if v != "Null" {
1357 docs.push((*id, v.clone()));
1358 }
1359 }
1360 }
1361 }
1362
1363 let mut new_idx = PostingList::new();
1364 docs.sort_by_key(|(id, _)| *id);
1369 for (id, text) in &docs {
1370 new_idx.insert(*id, text);
1371 }
1372
1373 if let Some(entry) = table.fts_indexes.iter_mut().find(|e| e.name == idx_name) {
1374 entry.index = new_idx;
1375 entry.needs_rebuild = false;
1376 }
1377 }
1378 }
1379}
1380
1381fn clone_datatype(dt: &DataType) -> DataType {
1383 match dt {
1384 DataType::Integer => DataType::Integer,
1385 DataType::Text => DataType::Text,
1386 DataType::Real => DataType::Real,
1387 DataType::Bool => DataType::Bool,
1388 DataType::Vector(dim) => DataType::Vector(*dim),
1389 DataType::Json => DataType::Json,
1390 DataType::None => DataType::None,
1391 DataType::Invalid => DataType::Invalid,
1392 }
1393}
1394
1395fn stage_index_btree(
1404 pager: &mut Pager,
1405 idx: &SecondaryIndex,
1406 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1407) -> Result<u32> {
1408 let leaves = stage_index_leaves(pager, idx, alloc)?;
1410 if leaves.len() == 1 {
1411 return Ok(leaves[0].0);
1412 }
1413 let mut level: Vec<(u32, i64)> = leaves;
1414 while level.len() > 1 {
1415 level = stage_interior_level(pager, &level, alloc)?;
1416 }
1417 Ok(level[0].0)
1418}
1419
1420fn stage_index_leaves(
1427 pager: &mut Pager,
1428 idx: &SecondaryIndex,
1429 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1430) -> Result<Vec<(u32, i64)>> {
1431 let mut leaves: Vec<(u32, i64)> = Vec::new();
1432 let mut current_leaf = TablePage::empty();
1433 let mut current_leaf_page = alloc.allocate();
1434 let mut current_max_rowid: Option<i64> = None;
1435
1436 let mut entries: Vec<(Value, i64)> = idx.iter_entries().collect();
1440 entries.sort_by_key(|(_, r)| *r);
1441
1442 for (value, rowid) in entries {
1443 let cell = IndexCell::new(rowid, value);
1444 let entry_bytes = cell.encode()?;
1445
1446 if !current_leaf.would_fit(entry_bytes.len()) {
1447 let next_leaf_page_num = alloc.allocate();
1448 emit_leaf(pager, current_leaf_page, ¤t_leaf, next_leaf_page_num);
1449 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1450 current_leaf = TablePage::empty();
1451 current_leaf_page = next_leaf_page_num;
1452
1453 if !current_leaf.would_fit(entry_bytes.len()) {
1454 return Err(SQLRiteError::Internal(format!(
1455 "index entry of {} bytes exceeds empty-page capacity {}",
1456 entry_bytes.len(),
1457 current_leaf.free_space()
1458 )));
1459 }
1460 }
1461 current_leaf.insert_entry(rowid, &entry_bytes)?;
1462 current_max_rowid = Some(rowid);
1463 }
1464
1465 emit_leaf(pager, current_leaf_page, ¤t_leaf, 0);
1466 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1467 Ok(leaves)
1468}
1469
1470fn stage_hnsw_btree(
1481 pager: &mut Pager,
1482 idx: &crate::sql::hnsw::HnswIndex,
1483 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1484) -> Result<u32> {
1485 let leaves = stage_hnsw_leaves(pager, idx, alloc)?;
1486 if leaves.len() == 1 {
1487 return Ok(leaves[0].0);
1488 }
1489 let mut level: Vec<(u32, i64)> = leaves;
1490 while level.len() > 1 {
1491 level = stage_interior_level(pager, &level, alloc)?;
1492 }
1493 Ok(level[0].0)
1494}
1495
1496fn stage_fts_btree(
1502 pager: &mut Pager,
1503 idx: &crate::sql::fts::PostingList,
1504 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1505) -> Result<u32> {
1506 let leaves = stage_fts_leaves(pager, idx, alloc)?;
1507 if leaves.len() == 1 {
1508 return Ok(leaves[0].0);
1509 }
1510 let mut level: Vec<(u32, i64)> = leaves;
1511 while level.len() > 1 {
1512 level = stage_interior_level(pager, &level, alloc)?;
1513 }
1514 Ok(level[0].0)
1515}
1516
1517fn stage_fts_leaves(
1524 pager: &mut Pager,
1525 idx: &crate::sql::fts::PostingList,
1526 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1527) -> Result<Vec<(u32, i64)>> {
1528 use crate::sql::pager::fts_cell::FtsPostingCell;
1529
1530 let mut leaves: Vec<(u32, i64)> = Vec::new();
1531 let mut current_leaf = TablePage::empty();
1532 let mut current_leaf_page = alloc.allocate();
1533 let mut current_max_rowid: Option<i64> = None;
1534
1535 let mut cell_id: i64 = 1;
1539 let mut cells: Vec<FtsPostingCell> = Vec::new();
1540 cells.push(FtsPostingCell::doc_lengths(
1541 cell_id,
1542 idx.serialize_doc_lengths(),
1543 ));
1544 for (term, entries) in idx.serialize_postings() {
1545 cell_id += 1;
1546 cells.push(FtsPostingCell::posting(cell_id, term, entries));
1547 }
1548
1549 for cell in cells {
1550 let entry_bytes = cell.encode()?;
1551
1552 if !current_leaf.would_fit(entry_bytes.len()) {
1553 let next_leaf_page_num = alloc.allocate();
1554 emit_leaf(pager, current_leaf_page, ¤t_leaf, next_leaf_page_num);
1555 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1556 current_leaf = TablePage::empty();
1557 current_leaf_page = next_leaf_page_num;
1558
1559 if !current_leaf.would_fit(entry_bytes.len()) {
1560 return Err(SQLRiteError::Internal(format!(
1565 "FTS posting cell {} of {} bytes exceeds empty-page capacity {} \
1566 (term too long or too many postings; overflow chaining is Phase 8.1)",
1567 cell.cell_id,
1568 entry_bytes.len(),
1569 current_leaf.free_space()
1570 )));
1571 }
1572 }
1573 current_leaf.insert_entry(cell.cell_id, &entry_bytes)?;
1574 current_max_rowid = Some(cell.cell_id);
1575 }
1576
1577 emit_leaf(pager, current_leaf_page, ¤t_leaf, 0);
1578 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1579 Ok(leaves)
1580}
1581
1582type FtsEntries = Vec<(i64, u32)>;
1585type FtsPostings = Vec<(String, FtsEntries)>;
1587
1588fn load_fts_postings(pager: &Pager, root_page: u32) -> Result<(FtsEntries, FtsPostings)> {
1593 use crate::sql::pager::fts_cell::FtsPostingCell;
1594
1595 let mut doc_lengths: Vec<(i64, u32)> = Vec::new();
1596 let mut postings: Vec<(String, Vec<(i64, u32)>)> = Vec::new();
1597 let mut saw_sidecar = false;
1598
1599 let first_leaf = find_leftmost_leaf(pager, root_page)?;
1600 let mut current = first_leaf;
1601 while current != 0 {
1602 let page_buf = pager
1603 .read_page(current)
1604 .ok_or_else(|| SQLRiteError::Internal(format!("missing FTS leaf page {current}")))?;
1605 if page_buf[0] != PageType::TableLeaf as u8 {
1606 return Err(SQLRiteError::Internal(format!(
1607 "page {current} tagged {} but expected TableLeaf (FTS)",
1608 page_buf[0]
1609 )));
1610 }
1611 let next_leaf = u32::from_le_bytes(page_buf[1..5].try_into().unwrap());
1612 let payload: &[u8; PAYLOAD_PER_PAGE] = (&page_buf[PAGE_HEADER_SIZE..])
1613 .try_into()
1614 .map_err(|_| SQLRiteError::Internal("FTS leaf payload size".to_string()))?;
1615 let leaf = TablePage::from_bytes(payload);
1616 for slot in 0..leaf.slot_count() {
1617 let offset = leaf.slot_offset_raw(slot)?;
1618 let (cell, _) = FtsPostingCell::decode(leaf.as_bytes(), offset)?;
1619 if cell.is_doc_lengths() {
1620 if saw_sidecar {
1621 return Err(SQLRiteError::Internal(
1622 "FTS index has more than one doc-lengths sidecar cell".to_string(),
1623 ));
1624 }
1625 saw_sidecar = true;
1626 doc_lengths = cell.entries;
1627 } else {
1628 postings.push((cell.term, cell.entries));
1629 }
1630 }
1631 current = next_leaf;
1632 }
1633
1634 if !saw_sidecar {
1635 return Err(SQLRiteError::Internal(
1636 "FTS index missing doc-lengths sidecar cell — corrupt or truncated tree".to_string(),
1637 ));
1638 }
1639 Ok((doc_lengths, postings))
1640}
1641
1642fn stage_hnsw_leaves(
1646 pager: &mut Pager,
1647 idx: &crate::sql::hnsw::HnswIndex,
1648 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1649) -> Result<Vec<(u32, i64)>> {
1650 use crate::sql::pager::hnsw_cell::HnswNodeCell;
1651
1652 let mut leaves: Vec<(u32, i64)> = Vec::new();
1653 let mut current_leaf = TablePage::empty();
1654 let mut current_leaf_page = alloc.allocate();
1655 let mut current_max_rowid: Option<i64> = None;
1656
1657 let serialized = idx.serialize_nodes();
1658
1659 for (node_id, layers) in serialized {
1664 let cell = HnswNodeCell::new(node_id, layers);
1665 let entry_bytes = cell.encode()?;
1666
1667 if !current_leaf.would_fit(entry_bytes.len()) {
1668 let next_leaf_page_num = alloc.allocate();
1669 emit_leaf(pager, current_leaf_page, ¤t_leaf, next_leaf_page_num);
1670 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1671 current_leaf = TablePage::empty();
1672 current_leaf_page = next_leaf_page_num;
1673
1674 if !current_leaf.would_fit(entry_bytes.len()) {
1675 return Err(SQLRiteError::Internal(format!(
1676 "HNSW node {node_id} cell of {} bytes exceeds empty-page capacity {}",
1677 entry_bytes.len(),
1678 current_leaf.free_space()
1679 )));
1680 }
1681 }
1682 current_leaf.insert_entry(node_id, &entry_bytes)?;
1683 current_max_rowid = Some(node_id);
1684 }
1685
1686 emit_leaf(pager, current_leaf_page, ¤t_leaf, 0);
1687 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1688 Ok(leaves)
1689}
1690
1691fn load_table_rows(pager: &Pager, table: &mut Table, root_page: u32) -> Result<()> {
1692 let first_leaf = find_leftmost_leaf(pager, root_page)?;
1693 let mut current = first_leaf;
1694 while current != 0 {
1695 let page_buf = pager
1696 .read_page(current)
1697 .ok_or_else(|| SQLRiteError::Internal(format!("missing leaf page {current}")))?;
1698 if page_buf[0] != PageType::TableLeaf as u8 {
1699 return Err(SQLRiteError::Internal(format!(
1700 "page {current} tagged {} but expected TableLeaf",
1701 page_buf[0]
1702 )));
1703 }
1704 let next_leaf = u32::from_le_bytes(page_buf[1..5].try_into().unwrap());
1705 let payload: &[u8; PAYLOAD_PER_PAGE] = (&page_buf[PAGE_HEADER_SIZE..])
1706 .try_into()
1707 .map_err(|_| SQLRiteError::Internal("leaf payload slice size".to_string()))?;
1708 let leaf = TablePage::from_bytes(payload);
1709
1710 for slot in 0..leaf.slot_count() {
1711 let entry = leaf.entry_at(slot)?;
1712 let cell = match entry {
1713 PagedEntry::Local(c) => c,
1714 PagedEntry::Overflow(r) => {
1715 let body_bytes =
1716 read_overflow_chain(pager, r.first_overflow_page, r.total_body_len)?;
1717 let (c, _) = Cell::decode(&body_bytes, 0)?;
1718 c
1719 }
1720 };
1721 table.restore_row(cell.rowid, cell.values)?;
1722 }
1723 current = next_leaf;
1724 }
1725 Ok(())
1726}
1727
1728fn collect_pages_for_btree(
1739 pager: &Pager,
1740 root_page: u32,
1741 follow_overflow: bool,
1742) -> Result<Vec<u32>> {
1743 if root_page == 0 {
1744 return Ok(Vec::new());
1745 }
1746 let mut pages: Vec<u32> = Vec::new();
1747 let mut stack: Vec<u32> = vec![root_page];
1748
1749 while let Some(p) = stack.pop() {
1750 let buf = pager.read_page(p).ok_or_else(|| {
1751 SQLRiteError::Internal(format!(
1752 "collect_pages: missing page {p} (rooted at {root_page})"
1753 ))
1754 })?;
1755 pages.push(p);
1756 match buf[0] {
1757 t if t == PageType::InteriorNode as u8 => {
1758 let payload: &[u8; PAYLOAD_PER_PAGE] =
1759 (&buf[PAGE_HEADER_SIZE..]).try_into().map_err(|_| {
1760 SQLRiteError::Internal("interior payload slice size".to_string())
1761 })?;
1762 let interior = InteriorPage::from_bytes(payload);
1763 for slot in 0..interior.slot_count() {
1765 let cell = interior.cell_at(slot)?;
1766 stack.push(cell.child_page);
1767 }
1768 stack.push(interior.rightmost_child());
1769 }
1770 t if t == PageType::TableLeaf as u8 => {
1771 if follow_overflow {
1772 let payload: &[u8; PAYLOAD_PER_PAGE] =
1773 (&buf[PAGE_HEADER_SIZE..]).try_into().map_err(|_| {
1774 SQLRiteError::Internal("leaf payload slice size".to_string())
1775 })?;
1776 let leaf = TablePage::from_bytes(payload);
1777 for slot in 0..leaf.slot_count() {
1778 match leaf.entry_at(slot)? {
1779 PagedEntry::Local(_) => {}
1780 PagedEntry::Overflow(r) => {
1781 let mut cur = r.first_overflow_page;
1782 while cur != 0 {
1783 pages.push(cur);
1784 let ob = pager.read_page(cur).ok_or_else(|| {
1785 SQLRiteError::Internal(format!(
1786 "collect_pages: missing overflow page {cur}"
1787 ))
1788 })?;
1789 if ob[0] != PageType::Overflow as u8 {
1790 return Err(SQLRiteError::Internal(format!(
1791 "collect_pages: page {cur} expected Overflow, got tag {}",
1792 ob[0]
1793 )));
1794 }
1795 cur = u32::from_le_bytes(ob[1..5].try_into().unwrap());
1796 }
1797 }
1798 }
1799 }
1800 }
1801 }
1802 other => {
1803 return Err(SQLRiteError::Internal(format!(
1804 "collect_pages: unexpected page type {other} at page {p}"
1805 )));
1806 }
1807 }
1808 }
1809 Ok(pages)
1810}
1811
1812fn read_old_rootpages(pager: &Pager, schema_root: u32) -> Result<HashMap<(String, String), u32>> {
1822 let mut out: HashMap<(String, String), u32> = HashMap::new();
1823 if schema_root == 0 {
1824 return Ok(out);
1825 }
1826 let mut master = build_empty_master_table();
1827 load_table_rows(pager, &mut master, schema_root)?;
1828 for rowid in master.rowids() {
1829 let kind = take_text(&master, "type", rowid)?;
1830 let name = take_text(&master, "name", rowid)?;
1831 let rootpage = take_integer(&master, "rootpage", rowid)? as u32;
1832 out.insert((kind, name), rootpage);
1833 }
1834 Ok(out)
1835}
1836
1837fn find_leftmost_leaf(pager: &Pager, root_page: u32) -> Result<u32> {
1841 let mut current = root_page;
1842 loop {
1843 let page_buf = pager.read_page(current).ok_or_else(|| {
1844 SQLRiteError::Internal(format!("missing page {current} during tree descent"))
1845 })?;
1846 match page_buf[0] {
1847 t if t == PageType::TableLeaf as u8 => return Ok(current),
1848 t if t == PageType::InteriorNode as u8 => {
1849 let payload: &[u8; PAYLOAD_PER_PAGE] =
1850 (&page_buf[PAGE_HEADER_SIZE..]).try_into().map_err(|_| {
1851 SQLRiteError::Internal("interior payload slice size".to_string())
1852 })?;
1853 let interior = InteriorPage::from_bytes(payload);
1854 current = interior.leftmost_child()?;
1855 }
1856 other => {
1857 return Err(SQLRiteError::Internal(format!(
1858 "unexpected page type {other} during tree descent at page {current}"
1859 )));
1860 }
1861 }
1862 }
1863}
1864
1865fn stage_table_btree(
1876 pager: &mut Pager,
1877 table: &Table,
1878 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1879) -> Result<u32> {
1880 let leaves = stage_leaves(pager, table, alloc)?;
1881 if leaves.len() == 1 {
1882 return Ok(leaves[0].0);
1883 }
1884 let mut level: Vec<(u32, i64)> = leaves;
1885 while level.len() > 1 {
1886 level = stage_interior_level(pager, &level, alloc)?;
1887 }
1888 Ok(level[0].0)
1889}
1890
1891fn stage_leaves(
1895 pager: &mut Pager,
1896 table: &Table,
1897 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1898) -> Result<Vec<(u32, i64)>> {
1899 let mut leaves: Vec<(u32, i64)> = Vec::new();
1900 let mut current_leaf = TablePage::empty();
1901 let mut current_leaf_page = alloc.allocate();
1902 let mut current_max_rowid: Option<i64> = None;
1903
1904 for rowid in table.rowids() {
1905 let entry_bytes = build_row_entry(pager, table, rowid, alloc)?;
1906
1907 if !current_leaf.would_fit(entry_bytes.len()) {
1908 let next_leaf_page_num = alloc.allocate();
1912 emit_leaf(pager, current_leaf_page, ¤t_leaf, next_leaf_page_num);
1913 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1914 current_leaf = TablePage::empty();
1915 current_leaf_page = next_leaf_page_num;
1916 if !current_leaf.would_fit(entry_bytes.len()) {
1920 return Err(SQLRiteError::Internal(format!(
1921 "entry of {} bytes exceeds empty-page capacity {}",
1922 entry_bytes.len(),
1923 current_leaf.free_space()
1924 )));
1925 }
1926 }
1927 current_leaf.insert_entry(rowid, &entry_bytes)?;
1928 current_max_rowid = Some(rowid);
1929 }
1930
1931 emit_leaf(pager, current_leaf_page, ¤t_leaf, 0);
1933 leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1934 Ok(leaves)
1935}
1936
1937fn build_row_entry(
1942 pager: &mut Pager,
1943 table: &Table,
1944 rowid: i64,
1945 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1946) -> Result<Vec<u8>> {
1947 let values = table.extract_row(rowid);
1948 let local_cell = Cell::new(rowid, values);
1949 let local_bytes = local_cell.encode()?;
1950 if local_bytes.len() > OVERFLOW_THRESHOLD {
1951 let overflow_start = write_overflow_chain(pager, &local_bytes, alloc)?;
1952 Ok(OverflowRef {
1953 rowid,
1954 total_body_len: local_bytes.len() as u64,
1955 first_overflow_page: overflow_start,
1956 }
1957 .encode())
1958 } else {
1959 Ok(local_bytes)
1960 }
1961}
1962
1963fn stage_interior_level(
1968 pager: &mut Pager,
1969 children: &[(u32, i64)],
1970 alloc: &mut crate::sql::pager::allocator::PageAllocator,
1971) -> Result<Vec<(u32, i64)>> {
1972 let mut next_level: Vec<(u32, i64)> = Vec::new();
1973 let mut idx = 0usize;
1974
1975 while idx < children.len() {
1976 let interior_page_num = alloc.allocate();
1977
1978 let (mut rightmost_child_page, mut rightmost_child_max) = children[idx];
1983 idx += 1;
1984 let mut interior = InteriorPage::empty(rightmost_child_page);
1985
1986 while idx < children.len() {
1987 let new_divider_cell = InteriorCell {
1988 divider_rowid: rightmost_child_max,
1989 child_page: rightmost_child_page,
1990 };
1991 let new_divider_bytes = new_divider_cell.encode();
1992 if !interior.would_fit(new_divider_bytes.len()) {
1993 break;
1994 }
1995 interior.insert_divider(rightmost_child_max, rightmost_child_page)?;
1996 let (next_child_page, next_child_max) = children[idx];
1997 interior.set_rightmost_child(next_child_page);
1998 rightmost_child_page = next_child_page;
1999 rightmost_child_max = next_child_max;
2000 idx += 1;
2001 }
2002
2003 emit_interior(pager, interior_page_num, &interior);
2004 next_level.push((interior_page_num, rightmost_child_max));
2005 }
2006
2007 Ok(next_level)
2008}
2009
2010fn emit_leaf(pager: &mut Pager, page_num: u32, leaf: &TablePage, next_leaf: u32) {
2012 let mut buf = [0u8; PAGE_SIZE];
2013 buf[0] = PageType::TableLeaf as u8;
2014 buf[1..5].copy_from_slice(&next_leaf.to_le_bytes());
2015 buf[5..7].copy_from_slice(&0u16.to_le_bytes());
2018 buf[PAGE_HEADER_SIZE..].copy_from_slice(leaf.as_bytes());
2019 pager.stage_page(page_num, buf);
2020}
2021
2022fn emit_interior(pager: &mut Pager, page_num: u32, interior: &InteriorPage) {
2026 let mut buf = [0u8; PAGE_SIZE];
2027 buf[0] = PageType::InteriorNode as u8;
2028 buf[1..5].copy_from_slice(&0u32.to_le_bytes());
2029 buf[5..7].copy_from_slice(&0u16.to_le_bytes());
2030 buf[PAGE_HEADER_SIZE..].copy_from_slice(interior.as_bytes());
2031 pager.stage_page(page_num, buf);
2032}
2033
2034#[cfg(test)]
2035mod tests {
2036 use super::*;
2037 use crate::sql::pager::freelist::MIN_PAGES_FOR_AUTO_VACUUM;
2038 use crate::sql::process_command;
2039
2040 fn seed_db() -> Database {
2041 let mut db = Database::new("test".to_string());
2042 process_command(
2043 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, age INTEGER);",
2044 &mut db,
2045 )
2046 .unwrap();
2047 process_command(
2048 "INSERT INTO users (name, age) VALUES ('alice', 30);",
2049 &mut db,
2050 )
2051 .unwrap();
2052 process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db).unwrap();
2053 process_command(
2054 "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2055 &mut db,
2056 )
2057 .unwrap();
2058 process_command("INSERT INTO notes (body) VALUES ('hello');", &mut db).unwrap();
2059 db
2060 }
2061
2062 fn tmp_path(name: &str) -> std::path::PathBuf {
2063 let mut p = std::env::temp_dir();
2064 let pid = std::process::id();
2065 let nanos = std::time::SystemTime::now()
2066 .duration_since(std::time::UNIX_EPOCH)
2067 .map(|d| d.as_nanos())
2068 .unwrap_or(0);
2069 p.push(format!("sqlrite-{pid}-{nanos}-{name}.sqlrite"));
2070 p
2071 }
2072
2073 fn cleanup(path: &std::path::Path) {
2076 let _ = std::fs::remove_file(path);
2077 let mut wal = path.as_os_str().to_owned();
2078 wal.push("-wal");
2079 let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
2080 }
2081
2082 #[test]
2083 fn round_trip_preserves_schema_and_data() {
2084 let path = tmp_path("roundtrip");
2085 let mut db = seed_db();
2086 save_database(&mut db, &path).expect("save");
2087
2088 let loaded = open_database(&path, "test".to_string()).expect("open");
2089 assert_eq!(loaded.tables.len(), 2);
2090
2091 let users = loaded.get_table("users".to_string()).expect("users table");
2092 assert_eq!(users.columns.len(), 3);
2093 let rowids = users.rowids();
2094 assert_eq!(rowids.len(), 2);
2095 let names: Vec<String> = rowids
2096 .iter()
2097 .filter_map(|r| match users.get_value("name", *r) {
2098 Some(Value::Text(s)) => Some(s),
2099 _ => None,
2100 })
2101 .collect();
2102 assert!(names.contains(&"alice".to_string()));
2103 assert!(names.contains(&"bob".to_string()));
2104
2105 let notes = loaded.get_table("notes".to_string()).expect("notes table");
2106 assert_eq!(notes.rowids().len(), 1);
2107
2108 cleanup(&path);
2109 }
2110
2111 #[test]
2116 fn round_trip_preserves_vector_column() {
2117 let path = tmp_path("vec_roundtrip");
2118
2119 {
2121 let mut db = Database::new("test".to_string());
2122 process_command(
2123 "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
2124 &mut db,
2125 )
2126 .unwrap();
2127 process_command(
2128 "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
2129 &mut db,
2130 )
2131 .unwrap();
2132 process_command(
2133 "INSERT INTO docs (embedding) VALUES ([1.5, -2.0, 3.5]);",
2134 &mut db,
2135 )
2136 .unwrap();
2137 save_database(&mut db, &path).expect("save");
2138 } let loaded = open_database(&path, "test".to_string()).expect("open");
2142 let docs = loaded.get_table("docs".to_string()).expect("docs table");
2143
2144 let embedding_col = docs
2146 .columns
2147 .iter()
2148 .find(|c| c.column_name == "embedding")
2149 .expect("embedding column");
2150 assert!(
2151 matches!(embedding_col.datatype, DataType::Vector(3)),
2152 "expected DataType::Vector(3) after round-trip, got {:?}",
2153 embedding_col.datatype
2154 );
2155
2156 let mut rows: Vec<Vec<f32>> = docs
2158 .rowids()
2159 .iter()
2160 .filter_map(|r| match docs.get_value("embedding", *r) {
2161 Some(Value::Vector(v)) => Some(v),
2162 _ => None,
2163 })
2164 .collect();
2165 rows.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
2166 assert_eq!(rows.len(), 2);
2167 assert_eq!(rows[0], vec![0.1f32, 0.2, 0.3]);
2168 assert_eq!(rows[1], vec![1.5f32, -2.0, 3.5]);
2169
2170 cleanup(&path);
2171 }
2172
2173 #[test]
2174 fn round_trip_preserves_json_column() {
2175 let path = tmp_path("json_roundtrip");
2180
2181 {
2182 let mut db = Database::new("test".to_string());
2183 process_command(
2184 "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
2185 &mut db,
2186 )
2187 .unwrap();
2188 process_command(
2189 r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "tags": ["rust","sql"]}');"#,
2190 &mut db,
2191 )
2192 .unwrap();
2193 save_database(&mut db, &path).expect("save");
2194 }
2195
2196 let mut loaded = open_database(&path, "test".to_string()).expect("open");
2197 let docs = loaded.get_table("docs".to_string()).expect("docs");
2198
2199 let payload_col = docs
2201 .columns
2202 .iter()
2203 .find(|c| c.column_name == "payload")
2204 .unwrap();
2205 assert!(
2206 matches!(payload_col.datatype, DataType::Json),
2207 "expected DataType::Json, got {:?}",
2208 payload_col.datatype
2209 );
2210
2211 let resp = process_command(
2214 r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
2215 &mut loaded,
2216 )
2217 .expect("select via json_extract after reopen");
2218 assert!(resp.contains("1 row returned"), "got: {resp}");
2219
2220 cleanup(&path);
2221 }
2222
2223 #[test]
2224 fn round_trip_rebuilds_hnsw_index_from_create_sql() {
2225 let path = tmp_path("hnsw_roundtrip");
2230
2231 {
2233 let mut db = Database::new("test".to_string());
2234 process_command(
2235 "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2236 &mut db,
2237 )
2238 .unwrap();
2239 for v in &[
2240 "[1.0, 0.0]",
2241 "[2.0, 0.0]",
2242 "[0.0, 3.0]",
2243 "[1.0, 4.0]",
2244 "[10.0, 10.0]",
2245 ] {
2246 process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2247 }
2248 process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2249 save_database(&mut db, &path).expect("save");
2250 } let mut loaded = open_database(&path, "test".to_string()).expect("open");
2255 {
2256 let table = loaded.get_table("docs".to_string()).expect("docs");
2257 assert_eq!(table.hnsw_indexes.len(), 1, "HNSW index should reattach");
2258 let entry = &table.hnsw_indexes[0];
2259 assert_eq!(entry.name, "ix_e");
2260 assert_eq!(entry.column_name, "e");
2261 assert_eq!(entry.index.len(), 5, "loaded graph should hold all 5 rows");
2262 assert!(
2263 !entry.needs_rebuild,
2264 "fresh load should not be marked dirty"
2265 );
2266 }
2267
2268 let resp = process_command(
2271 "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
2272 &mut loaded,
2273 )
2274 .unwrap();
2275 assert!(resp.contains("3 rows returned"), "got: {resp}");
2276
2277 cleanup(&path);
2278 }
2279
2280 #[test]
2285 fn round_trip_preserves_hnsw_cosine_metric() {
2286 use crate::sql::hnsw::DistanceMetric;
2287 let path = tmp_path("hnsw_metric_roundtrip");
2288
2289 {
2290 let mut db = Database::new("test".to_string());
2291 process_command(
2292 "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2293 &mut db,
2294 )
2295 .unwrap();
2296 for v in &["[1.0, 0.0]", "[0.0, 1.0]", "[0.7071, 0.7071]"] {
2297 process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2298 }
2299 process_command(
2300 "CREATE INDEX ix_cos ON docs USING hnsw (e) WITH (metric = 'cosine');",
2301 &mut db,
2302 )
2303 .unwrap();
2304 save_database(&mut db, &path).expect("save");
2305 }
2306
2307 let mut loaded = open_database(&path, "test".to_string()).expect("open");
2308 {
2309 let table = loaded.get_table("docs".to_string()).expect("docs");
2310 assert_eq!(table.hnsw_indexes.len(), 1);
2311 assert_eq!(
2312 table.hnsw_indexes[0].metric,
2313 DistanceMetric::Cosine,
2314 "metric should round-trip through CREATE INDEX SQL"
2315 );
2316 assert_eq!(table.hnsw_indexes[0].index.distance, DistanceMetric::Cosine);
2317 }
2318
2319 let resp = process_command(
2323 "SELECT id FROM docs ORDER BY vec_distance_cosine(e, [1.0, 0.0]) ASC LIMIT 1;",
2324 &mut loaded,
2325 )
2326 .unwrap();
2327 assert!(resp.contains("1 row returned"), "got: {resp}");
2328
2329 cleanup(&path);
2330 }
2331
2332 #[test]
2333 fn round_trip_rebuilds_fts_index_from_create_sql() {
2334 let path = tmp_path("fts_roundtrip");
2339
2340 {
2341 let mut db = Database::new("test".to_string());
2342 process_command(
2343 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2344 &mut db,
2345 )
2346 .unwrap();
2347 for body in &[
2348 "rust embedded database",
2349 "rust web framework",
2350 "go embedded systems",
2351 "python web framework",
2352 "rust rust embedded power",
2353 ] {
2354 process_command(
2355 &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2356 &mut db,
2357 )
2358 .unwrap();
2359 }
2360 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2361 save_database(&mut db, &path).expect("save");
2362 } let mut loaded = open_database(&path, "test".to_string()).expect("open");
2365 {
2366 let table = loaded.get_table("docs".to_string()).expect("docs");
2367 assert_eq!(table.fts_indexes.len(), 1, "FTS index should reattach");
2368 let entry = &table.fts_indexes[0];
2369 assert_eq!(entry.name, "ix_body");
2370 assert_eq!(entry.column_name, "body");
2371 assert_eq!(
2372 entry.index.len(),
2373 5,
2374 "rebuilt posting list should hold all 5 rows"
2375 );
2376 assert!(!entry.needs_rebuild);
2377 }
2378
2379 let resp = process_command(
2382 "SELECT id FROM docs WHERE fts_match(body, 'rust');",
2383 &mut loaded,
2384 )
2385 .unwrap();
2386 assert!(resp.contains("3 rows returned"), "got: {resp}");
2387
2388 cleanup(&path);
2389 }
2390
2391 #[test]
2392 fn delete_then_save_then_reopen_excludes_deleted_node_from_fts() {
2393 let path = tmp_path("fts_delete_rebuild");
2398 let mut db = Database::new("test".to_string());
2399 process_command(
2400 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2401 &mut db,
2402 )
2403 .unwrap();
2404 for body in &[
2405 "rust embedded",
2406 "rust framework",
2407 "go embedded",
2408 "python web",
2409 ] {
2410 process_command(
2411 &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2412 &mut db,
2413 )
2414 .unwrap();
2415 }
2416 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2417
2418 process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
2420 save_database(&mut db, &path).expect("save");
2421 drop(db);
2422
2423 let mut loaded = open_database(&path, "test".to_string()).expect("open");
2424 let resp = process_command(
2425 "SELECT id FROM docs WHERE fts_match(body, 'rust');",
2426 &mut loaded,
2427 )
2428 .unwrap();
2429 assert!(resp.contains("1 row returned"), "got: {resp}");
2432
2433 cleanup(&path);
2434 }
2435
2436 #[test]
2437 fn fts_roundtrip_uses_persistence_path_not_replay() {
2438 let path = tmp_path("fts_persistence_path");
2443
2444 {
2445 let mut db = Database::new("test".to_string());
2446 process_command(
2447 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2448 &mut db,
2449 )
2450 .unwrap();
2451 process_command(
2452 "INSERT INTO docs (body) VALUES ('rust embedded database');",
2453 &mut db,
2454 )
2455 .unwrap();
2456 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2457 save_database(&mut db, &path).expect("save");
2458 }
2459
2460 let pager = Pager::open(&path).expect("open pager");
2462 let mut master = build_empty_master_table();
2463 load_table_rows(&pager, &mut master, pager.header().schema_root_page).unwrap();
2464 let mut found_rootpage: Option<u32> = None;
2465 for rowid in master.rowids() {
2466 let name = take_text(&master, "name", rowid).unwrap();
2467 if name == "ix_body" {
2468 let rp = take_integer(&master, "rootpage", rowid).unwrap();
2469 found_rootpage = Some(rp as u32);
2470 }
2471 }
2472 let rootpage = found_rootpage.expect("ix_body row in sqlrite_master");
2473 assert!(
2474 rootpage != 0,
2475 "Phase 8c FTS save should set rootpage != 0; got {rootpage}"
2476 );
2477
2478 cleanup(&path);
2479 }
2480
2481 #[test]
2482 fn save_without_fts_keeps_format_v4() {
2483 use crate::sql::pager::header::FORMAT_VERSION_V4;
2487
2488 let path = tmp_path("fts_no_bump");
2489 let mut db = Database::new("test".to_string());
2490 process_command(
2491 "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
2492 &mut db,
2493 )
2494 .unwrap();
2495 process_command("INSERT INTO t (n) VALUES (1);", &mut db).unwrap();
2496 save_database(&mut db, &path).unwrap();
2497 drop(db);
2498
2499 let pager = Pager::open(&path).expect("open");
2500 assert_eq!(
2501 pager.header().format_version,
2502 FORMAT_VERSION_V4,
2503 "no-FTS save should keep v4"
2504 );
2505 cleanup(&path);
2506 }
2507
2508 #[test]
2509 fn save_with_fts_bumps_to_v5() {
2510 use crate::sql::pager::header::FORMAT_VERSION_V5;
2514
2515 let path = tmp_path("fts_bump_v5");
2516 let mut db = Database::new("test".to_string());
2517 process_command(
2518 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2519 &mut db,
2520 )
2521 .unwrap();
2522 process_command("INSERT INTO docs (body) VALUES ('hello');", &mut db).unwrap();
2523 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2524 save_database(&mut db, &path).unwrap();
2525 drop(db);
2526
2527 let pager = Pager::open(&path).expect("open");
2528 assert_eq!(
2529 pager.header().format_version,
2530 FORMAT_VERSION_V5,
2531 "FTS save should promote to v5"
2532 );
2533 cleanup(&path);
2534 }
2535
2536 #[test]
2537 fn fts_persistence_handles_empty_and_zero_token_docs() {
2538 let path = tmp_path("fts_edges");
2544
2545 {
2546 let mut db = Database::new("test".to_string());
2547 process_command(
2548 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2549 &mut db,
2550 )
2551 .unwrap();
2552 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2553 process_command("INSERT INTO docs (body) VALUES ('rust embedded');", &mut db).unwrap();
2556 process_command("INSERT INTO docs (body) VALUES ('!!!---???');", &mut db).unwrap();
2557 process_command("INSERT INTO docs (body) VALUES ('go embedded');", &mut db).unwrap();
2558 save_database(&mut db, &path).unwrap();
2559 }
2560
2561 let loaded = open_database(&path, "test".to_string()).expect("open");
2562 let table = loaded.get_table("docs".to_string()).unwrap();
2563 let entry = &table.fts_indexes[0];
2564 assert_eq!(entry.index.len(), 3);
2567 let res = entry
2569 .index
2570 .query("embedded", &crate::sql::fts::Bm25Params::default());
2571 assert_eq!(res.len(), 2);
2572
2573 cleanup(&path);
2574 }
2575
2576 #[test]
2577 fn fts_persistence_round_trips_large_corpus() {
2578 let path = tmp_path("fts_large_corpus");
2582
2583 let mut expected_terms: std::collections::BTreeSet<String> =
2584 std::collections::BTreeSet::new();
2585 {
2586 let mut db = Database::new("test".to_string());
2587 process_command(
2588 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2589 &mut db,
2590 )
2591 .unwrap();
2592 process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2593 for i in 0..500 {
2596 let term = format!("term{i:04}");
2597 process_command(
2598 &format!("INSERT INTO docs (body) VALUES ('{term}');"),
2599 &mut db,
2600 )
2601 .unwrap();
2602 expected_terms.insert(term);
2603 }
2604 save_database(&mut db, &path).unwrap();
2605 }
2606
2607 let loaded = open_database(&path, "test".to_string()).expect("open");
2608 let table = loaded.get_table("docs".to_string()).unwrap();
2609 let entry = &table.fts_indexes[0];
2610 assert_eq!(entry.index.len(), 500);
2611
2612 for &i in &[0_i64, 137, 248, 391, 499] {
2615 let term = format!("term{i:04}");
2616 let res = entry
2617 .index
2618 .query(&term, &crate::sql::fts::Bm25Params::default());
2619 assert_eq!(res.len(), 1, "term {term} should match exactly 1 row");
2620 assert_eq!(res[0].0, i + 1);
2623 }
2624
2625 cleanup(&path);
2626 }
2627
2628 #[test]
2629 fn delete_then_save_then_reopen_excludes_deleted_node_from_hnsw() {
2630 let path = tmp_path("hnsw_delete_rebuild");
2635 let mut db = Database::new("test".to_string());
2636 process_command(
2637 "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2638 &mut db,
2639 )
2640 .unwrap();
2641 for v in &["[1.0, 0.0]", "[2.0, 0.0]", "[3.0, 0.0]", "[4.0, 0.0]"] {
2642 process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2643 }
2644 process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2645
2646 process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
2648 let dirty_before_save = db.tables["docs"].hnsw_indexes[0].needs_rebuild;
2650 assert!(dirty_before_save, "DELETE should mark dirty");
2651
2652 save_database(&mut db, &path).expect("save");
2653 let dirty_after_save = db.tables["docs"].hnsw_indexes[0].needs_rebuild;
2655 assert!(!dirty_after_save, "save should clear dirty");
2656 drop(db);
2657
2658 let loaded = open_database(&path, "test".to_string()).expect("open");
2661 let docs = loaded.get_table("docs".to_string()).expect("docs");
2662
2663 assert!(
2665 !docs.rowids().contains(&1),
2666 "deleted row 1 should not be in row storage"
2667 );
2668 assert_eq!(docs.rowids().len(), 3, "should have 3 surviving rows");
2669
2670 assert_eq!(
2672 docs.hnsw_indexes[0].index.len(),
2673 3,
2674 "HNSW graph should have shed the deleted node"
2675 );
2676
2677 cleanup(&path);
2678 }
2679
2680 #[test]
2681 fn round_trip_survives_writes_after_load() {
2682 let path = tmp_path("after_load");
2683 save_database(&mut seed_db(), &path).unwrap();
2684
2685 {
2686 let mut db = open_database(&path, "test".to_string()).unwrap();
2687 process_command(
2688 "INSERT INTO users (name, age) VALUES ('carol', 40);",
2689 &mut db,
2690 )
2691 .unwrap();
2692 save_database(&mut db, &path).unwrap();
2693 } let db2 = open_database(&path, "test".to_string()).unwrap();
2696 let users = db2.get_table("users".to_string()).unwrap();
2697 assert_eq!(users.rowids().len(), 3);
2698
2699 cleanup(&path);
2700 }
2701
2702 #[test]
2703 fn open_rejects_garbage_file() {
2704 let path = tmp_path("bad");
2705 std::fs::write(&path, b"not a sqlrite database, just bytes").unwrap();
2706 let result = open_database(&path, "x".to_string());
2707 assert!(result.is_err());
2708 cleanup(&path);
2709 }
2710
2711 #[test]
2712 fn many_small_rows_spread_across_leaves() {
2713 let path = tmp_path("many_rows");
2714 let mut db = Database::new("big".to_string());
2715 process_command(
2716 "CREATE TABLE things (id INTEGER PRIMARY KEY, data TEXT);",
2717 &mut db,
2718 )
2719 .unwrap();
2720 for i in 0..200 {
2721 let body = "x".repeat(200);
2722 let q = format!("INSERT INTO things (data) VALUES ('row-{i}-{body}');");
2723 process_command(&q, &mut db).unwrap();
2724 }
2725 save_database(&mut db, &path).unwrap();
2726 let loaded = open_database(&path, "big".to_string()).unwrap();
2727 let things = loaded.get_table("things".to_string()).unwrap();
2728 assert_eq!(things.rowids().len(), 200);
2729 cleanup(&path);
2730 }
2731
2732 #[test]
2733 fn huge_row_goes_through_overflow() {
2734 let path = tmp_path("overflow_row");
2735 let mut db = Database::new("big".to_string());
2736 process_command(
2737 "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2738 &mut db,
2739 )
2740 .unwrap();
2741 let body = "A".repeat(10_000);
2742 process_command(
2743 &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2744 &mut db,
2745 )
2746 .unwrap();
2747 save_database(&mut db, &path).unwrap();
2748
2749 let loaded = open_database(&path, "big".to_string()).unwrap();
2750 let docs = loaded.get_table("docs".to_string()).unwrap();
2751 let rowids = docs.rowids();
2752 assert_eq!(rowids.len(), 1);
2753 let stored = docs.get_value("body", rowids[0]);
2754 match stored {
2755 Some(Value::Text(s)) => assert_eq!(s.len(), 10_000),
2756 other => panic!("expected Text, got {other:?}"),
2757 }
2758 cleanup(&path);
2759 }
2760
2761 #[test]
2762 fn create_sql_synthesis_round_trips() {
2763 let mut db = Database::new("x".to_string());
2766 process_command(
2767 "CREATE TABLE t (id INTEGER PRIMARY KEY, tag TEXT UNIQUE, note TEXT NOT NULL);",
2768 &mut db,
2769 )
2770 .unwrap();
2771 let t = db.get_table("t".to_string()).unwrap();
2772 let sql = table_to_create_sql(t);
2773 let (name, cols) = parse_create_sql(&sql).unwrap();
2774 assert_eq!(name, "t");
2775 assert_eq!(cols.len(), 3);
2776 assert!(cols[0].is_pk);
2777 assert!(cols[1].is_unique);
2778 assert!(cols[2].not_null);
2779 }
2780
2781 #[test]
2782 fn sqlrite_master_is_not_exposed_as_a_user_table() {
2783 let path = tmp_path("no_master");
2785 save_database(&mut seed_db(), &path).unwrap();
2786 let loaded = open_database(&path, "x".to_string()).unwrap();
2787 assert!(!loaded.tables.contains_key(MASTER_TABLE_NAME));
2788 cleanup(&path);
2789 }
2790
2791 #[test]
2792 fn multi_leaf_table_produces_an_interior_root() {
2793 let path = tmp_path("multi_leaf_interior");
2799 let mut db = Database::new("big".to_string());
2800 process_command(
2801 "CREATE TABLE things (id INTEGER PRIMARY KEY, data TEXT);",
2802 &mut db,
2803 )
2804 .unwrap();
2805 for i in 0..200 {
2806 let body = "x".repeat(200);
2807 let q = format!("INSERT INTO things (data) VALUES ('row-{i}-{body}');");
2808 process_command(&q, &mut db).unwrap();
2809 }
2810 save_database(&mut db, &path).unwrap();
2811
2812 let loaded = open_database(&path, "big".to_string()).unwrap();
2814 let things = loaded.get_table("things".to_string()).unwrap();
2815 assert_eq!(things.rowids().len(), 200);
2816
2817 let pager = loaded
2820 .pager
2821 .as_ref()
2822 .expect("loaded DB should have a pager");
2823 let mut master = build_empty_master_table();
2828 load_table_rows(pager, &mut master, pager.header().schema_root_page).unwrap();
2829 let things_root = master
2830 .rowids()
2831 .into_iter()
2832 .find_map(|r| match master.get_value("name", r) {
2833 Some(Value::Text(s)) if s == "things" => match master.get_value("rootpage", r) {
2834 Some(Value::Integer(p)) => Some(p as u32),
2835 _ => None,
2836 },
2837 _ => None,
2838 })
2839 .expect("things should appear in sqlrite_master");
2840 let root_buf = pager.read_page(things_root).unwrap();
2841 assert_eq!(
2842 root_buf[0],
2843 PageType::InteriorNode as u8,
2844 "expected a multi-leaf table to have an interior root, got tag {}",
2845 root_buf[0]
2846 );
2847
2848 cleanup(&path);
2849 }
2850
2851 #[test]
2852 fn explicit_index_persists_across_save_and_open() {
2853 let path = tmp_path("idx_persist");
2854 let mut db = Database::new("idx".to_string());
2855 process_command(
2856 "CREATE TABLE users (id INTEGER PRIMARY KEY, tag TEXT);",
2857 &mut db,
2858 )
2859 .unwrap();
2860 for i in 1..=5 {
2861 let tag = if i % 2 == 0 { "odd" } else { "even" };
2862 process_command(
2863 &format!("INSERT INTO users (tag) VALUES ('{tag}');"),
2864 &mut db,
2865 )
2866 .unwrap();
2867 }
2868 process_command("CREATE INDEX users_tag_idx ON users (tag);", &mut db).unwrap();
2869 save_database(&mut db, &path).unwrap();
2870
2871 let loaded = open_database(&path, "idx".to_string()).unwrap();
2872 let users = loaded.get_table("users".to_string()).unwrap();
2873 let idx = users
2874 .index_by_name("users_tag_idx")
2875 .expect("explicit index should survive save/open");
2876 assert_eq!(idx.column_name, "tag");
2877 assert!(!idx.is_unique);
2878 let even_rowids = idx.lookup(&Value::Text("even".into()));
2881 let odd_rowids = idx.lookup(&Value::Text("odd".into()));
2882 assert_eq!(even_rowids.len(), 3);
2883 assert_eq!(odd_rowids.len(), 2);
2884
2885 cleanup(&path);
2886 }
2887
2888 #[test]
2889 fn auto_indexes_for_unique_columns_survive_save_open() {
2890 let path = tmp_path("auto_idx_persist");
2891 let mut db = Database::new("a".to_string());
2892 process_command(
2893 "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE);",
2894 &mut db,
2895 )
2896 .unwrap();
2897 process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
2898 process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
2899 save_database(&mut db, &path).unwrap();
2900
2901 let loaded = open_database(&path, "a".to_string()).unwrap();
2902 let users = loaded.get_table("users".to_string()).unwrap();
2903 let auto_name = SecondaryIndex::auto_name("users", "email");
2906 let idx = users
2907 .index_by_name(&auto_name)
2908 .expect("auto index should be restored");
2909 assert!(idx.is_unique);
2910 assert_eq!(idx.lookup(&Value::Text("a@x".into())).len(), 1);
2911 assert_eq!(idx.lookup(&Value::Text("b@x".into())).len(), 1);
2912
2913 cleanup(&path);
2914 }
2915
2916 #[test]
2927 fn secondary_index_with_interior_level_round_trips() {
2928 let path = tmp_path("sqlr1_wide_index");
2929 let mut db = Database::new("idx".to_string());
2930 db.source_path = Some(path.clone());
2931
2932 process_command(
2933 "CREATE TABLE bloat (id INTEGER PRIMARY KEY, payload TEXT);",
2934 &mut db,
2935 )
2936 .unwrap();
2937 process_command("BEGIN;", &mut db).unwrap();
2940 for i in 0..5000 {
2941 process_command(
2942 &format!("INSERT INTO bloat (payload) VALUES ('p-{i:08}');"),
2943 &mut db,
2944 )
2945 .unwrap();
2946 }
2947 process_command("COMMIT;", &mut db).unwrap();
2948
2949 process_command("CREATE INDEX idx_p ON bloat (payload);", &mut db).unwrap();
2951
2952 drop(db);
2956 let loaded = open_database(&path, "idx".to_string()).unwrap();
2957 let bloat = loaded.get_table("bloat".to_string()).unwrap();
2958 let idx = bloat
2959 .index_by_name("idx_p")
2960 .expect("idx_p should survive close/reopen");
2961 assert!(!idx.is_unique);
2962
2963 for &(probe_i, expected_rowid) in &[(0i64, 1i64), (2500, 2501), (4999, 5000)] {
2966 let value = Value::Text(format!("p-{probe_i:08}"));
2967 let hits = idx.lookup(&value);
2968 assert_eq!(
2969 hits,
2970 vec![expected_rowid],
2971 "lookup({value:?}) should yield rowid {expected_rowid}",
2972 );
2973 }
2974
2975 let pager = loaded.pager.as_ref().unwrap();
2979 let mut master = build_empty_master_table();
2980 load_table_rows(pager, &mut master, pager.header().schema_root_page).unwrap();
2981 let idx_root = master
2982 .rowids()
2983 .into_iter()
2984 .find_map(
2985 |r| match (master.get_value("name", r), master.get_value("type", r)) {
2986 (Some(Value::Text(name)), Some(Value::Text(kind)))
2987 if name == "idx_p" && kind == "index" =>
2988 {
2989 match master.get_value("rootpage", r) {
2990 Some(Value::Integer(p)) => Some(p as u32),
2991 _ => None,
2992 }
2993 }
2994 _ => None,
2995 },
2996 )
2997 .expect("idx_p should appear in sqlrite_master");
2998 let root_buf = pager.read_page(idx_root).unwrap();
2999 assert_eq!(
3000 root_buf[0],
3001 PageType::InteriorNode as u8,
3002 "5 000-entry index must have an interior root — without one this test wouldn't cover SQLR-1",
3003 );
3004 let leaf = find_leftmost_leaf(pager, idx_root).unwrap();
3005 let leaf_buf = pager.read_page(leaf).unwrap();
3006 assert_eq!(leaf_buf[0], PageType::TableLeaf as u8);
3007
3008 cleanup(&path);
3009 }
3010
3011 #[test]
3018 fn drop_then_recreate_wide_index_does_not_panic() {
3019 let path = tmp_path("sqlr1_drop_recreate");
3020 let mut db = Database::new("idx".to_string());
3021 db.source_path = Some(path.clone());
3022
3023 process_command(
3024 "CREATE TABLE bloat (id INTEGER PRIMARY KEY, payload TEXT);",
3025 &mut db,
3026 )
3027 .unwrap();
3028 process_command("BEGIN;", &mut db).unwrap();
3029 for i in 0..5000 {
3030 process_command(
3031 &format!("INSERT INTO bloat (payload) VALUES ('p-{i:08}');"),
3032 &mut db,
3033 )
3034 .unwrap();
3035 }
3036 process_command("COMMIT;", &mut db).unwrap();
3037
3038 process_command("CREATE INDEX idx_p ON bloat (payload);", &mut db).unwrap();
3039 process_command("DROP INDEX idx_p;", &mut db).unwrap();
3040 process_command("CREATE INDEX idx_p ON bloat (payload);", &mut db).unwrap();
3042
3043 drop(db);
3044 let loaded = open_database(&path, "idx".to_string()).unwrap();
3045 let bloat = loaded.get_table("bloat".to_string()).unwrap();
3046 let idx = bloat
3047 .index_by_name("idx_p")
3048 .expect("idx_p should survive drop+recreate+reopen");
3049 assert_eq!(
3050 idx.lookup(&Value::Text("p-00002500".into())),
3051 vec![2501],
3052 "post-recycle lookup must still resolve correctly",
3053 );
3054
3055 cleanup(&path);
3056 }
3057
3058 #[test]
3059 fn deep_tree_round_trips() {
3060 use crate::sql::db::table::Column as TableColumn;
3064
3065 let path = tmp_path("deep_tree");
3066 let mut db = Database::new("deep".to_string());
3067 let columns = vec![
3068 TableColumn::new("id".into(), "integer".into(), true, true, true),
3069 TableColumn::new("s".into(), "text".into(), false, true, false),
3070 ];
3071 let mut table = build_empty_table("t", columns, 0);
3072 for i in 1..=6_000i64 {
3076 let body = "q".repeat(900);
3077 table
3078 .restore_row(
3079 i,
3080 vec![
3081 Some(Value::Integer(i)),
3082 Some(Value::Text(format!("r-{i}-{body}"))),
3083 ],
3084 )
3085 .unwrap();
3086 }
3087 db.tables.insert("t".to_string(), table);
3088 save_database(&mut db, &path).unwrap();
3089
3090 let loaded = open_database(&path, "deep".to_string()).unwrap();
3091 let t = loaded.get_table("t".to_string()).unwrap();
3092 assert_eq!(t.rowids().len(), 6_000);
3093
3094 let pager = loaded.pager.as_ref().unwrap();
3097 let mut master = build_empty_master_table();
3098 load_table_rows(pager, &mut master, pager.header().schema_root_page).unwrap();
3099 let t_root = master
3100 .rowids()
3101 .into_iter()
3102 .find_map(|r| match master.get_value("name", r) {
3103 Some(Value::Text(s)) if s == "t" => match master.get_value("rootpage", r) {
3104 Some(Value::Integer(p)) => Some(p as u32),
3105 _ => None,
3106 },
3107 _ => None,
3108 })
3109 .expect("t in sqlrite_master");
3110 let root_buf = pager.read_page(t_root).unwrap();
3111 assert_eq!(root_buf[0], PageType::InteriorNode as u8);
3112 let root_payload: &[u8; PAYLOAD_PER_PAGE] =
3113 (&root_buf[PAGE_HEADER_SIZE..]).try_into().unwrap();
3114 let root_interior = InteriorPage::from_bytes(root_payload);
3115 let child = root_interior.leftmost_child().unwrap();
3116 let child_buf = pager.read_page(child).unwrap();
3117 assert_eq!(
3118 child_buf[0],
3119 PageType::InteriorNode as u8,
3120 "expected 3-level tree: root's leftmost child should also be InteriorNode",
3121 );
3122
3123 cleanup(&path);
3124 }
3125
3126 #[test]
3127 fn alter_rename_table_survives_save_and_reopen() {
3128 let path = tmp_path("alter_rename_table_roundtrip");
3129 let mut db = seed_db();
3130 save_database(&mut db, &path).expect("save");
3131
3132 process_command("ALTER TABLE users RENAME TO members;", &mut db).expect("rename");
3133 save_database(&mut db, &path).expect("save after rename");
3134
3135 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3136 assert!(!loaded.contains_table("users".to_string()));
3137 assert!(loaded.contains_table("members".to_string()));
3138 let members = loaded.get_table("members".to_string()).unwrap();
3139 assert_eq!(members.rowids().len(), 2, "rows should survive");
3140 assert!(
3142 members
3143 .index_by_name("sqlrite_autoindex_members_id")
3144 .is_some()
3145 );
3146 assert!(
3147 members
3148 .index_by_name("sqlrite_autoindex_members_name")
3149 .is_some()
3150 );
3151
3152 cleanup(&path);
3153 }
3154
3155 #[test]
3156 fn alter_rename_column_survives_save_and_reopen() {
3157 let path = tmp_path("alter_rename_col_roundtrip");
3158 let mut db = seed_db();
3159 save_database(&mut db, &path).expect("save");
3160
3161 process_command(
3162 "ALTER TABLE users RENAME COLUMN name TO full_name;",
3163 &mut db,
3164 )
3165 .expect("rename column");
3166 save_database(&mut db, &path).expect("save after rename");
3167
3168 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3169 let users = loaded.get_table("users".to_string()).unwrap();
3170 assert!(users.contains_column("full_name".to_string()));
3171 assert!(!users.contains_column("name".to_string()));
3172 let alice_rowid = users
3174 .rowids()
3175 .into_iter()
3176 .find(|r| users.get_value("full_name", *r) == Some(Value::Text("alice".to_string())))
3177 .expect("alice row should be findable under renamed column");
3178 assert_eq!(
3179 users.get_value("full_name", alice_rowid),
3180 Some(Value::Text("alice".to_string()))
3181 );
3182
3183 cleanup(&path);
3184 }
3185
3186 #[test]
3187 fn alter_add_column_with_default_survives_save_and_reopen() {
3188 let path = tmp_path("alter_add_default_roundtrip");
3189 let mut db = seed_db();
3190 save_database(&mut db, &path).expect("save");
3191
3192 process_command(
3193 "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
3194 &mut db,
3195 )
3196 .expect("add column");
3197 save_database(&mut db, &path).expect("save after add");
3198
3199 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3200 let users = loaded.get_table("users".to_string()).unwrap();
3201 assert!(users.contains_column("status".to_string()));
3202 for rowid in users.rowids() {
3203 assert_eq!(
3204 users.get_value("status", rowid),
3205 Some(Value::Text("active".to_string())),
3206 "backfilled default should round-trip for rowid {rowid}"
3207 );
3208 }
3209 let status_col = users
3212 .columns
3213 .iter()
3214 .find(|c| c.column_name == "status")
3215 .unwrap();
3216 assert_eq!(status_col.default, Some(Value::Text("active".to_string())));
3217
3218 cleanup(&path);
3219 }
3220
3221 #[test]
3222 fn alter_drop_column_survives_save_and_reopen() {
3223 let path = tmp_path("alter_drop_col_roundtrip");
3224 let mut db = seed_db();
3225 save_database(&mut db, &path).expect("save");
3226
3227 process_command("ALTER TABLE users DROP COLUMN age;", &mut db).expect("drop column");
3228 save_database(&mut db, &path).expect("save after drop");
3229
3230 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3231 let users = loaded.get_table("users".to_string()).unwrap();
3232 assert!(!users.contains_column("age".to_string()));
3233 assert!(users.contains_column("name".to_string()));
3234
3235 cleanup(&path);
3236 }
3237
3238 #[test]
3239 fn drop_table_survives_save_and_reopen() {
3240 let path = tmp_path("drop_table_roundtrip");
3241 let mut db = seed_db();
3242 save_database(&mut db, &path).expect("save");
3243
3244 {
3246 let loaded = open_database(&path, "t".to_string()).expect("open");
3247 assert!(loaded.contains_table("users".to_string()));
3248 assert!(loaded.contains_table("notes".to_string()));
3249 }
3250
3251 process_command("DROP TABLE users;", &mut db).expect("drop users");
3252 save_database(&mut db, &path).expect("save after drop");
3253
3254 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3255 assert!(
3256 !loaded.contains_table("users".to_string()),
3257 "dropped table should not resurface on reopen"
3258 );
3259 assert!(
3260 loaded.contains_table("notes".to_string()),
3261 "untouched table should survive"
3262 );
3263
3264 cleanup(&path);
3265 }
3266
3267 #[test]
3268 fn drop_index_survives_save_and_reopen() {
3269 let path = tmp_path("drop_index_roundtrip");
3270 let mut db = Database::new("t".to_string());
3271 process_command(
3272 "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3273 &mut db,
3274 )
3275 .unwrap();
3276 process_command("CREATE INDEX notes_body_idx ON notes (body);", &mut db).unwrap();
3277 save_database(&mut db, &path).expect("save");
3278
3279 process_command("DROP INDEX notes_body_idx;", &mut db).unwrap();
3280 save_database(&mut db, &path).expect("save after drop");
3281
3282 let loaded = open_database(&path, "t".to_string()).expect("reopen");
3283 let notes = loaded.get_table("notes".to_string()).unwrap();
3284 assert!(
3285 notes.index_by_name("notes_body_idx").is_none(),
3286 "dropped index should not resurface on reopen"
3287 );
3288 assert!(notes.index_by_name("sqlrite_autoindex_notes_id").is_some());
3290
3291 cleanup(&path);
3292 }
3293
3294 #[test]
3295 fn default_clause_survives_save_and_reopen() {
3296 let path = tmp_path("default_roundtrip");
3297 let mut db = Database::new("t".to_string());
3298
3299 process_command(
3300 "CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active', score INTEGER DEFAULT 0);",
3301 &mut db,
3302 )
3303 .unwrap();
3304 save_database(&mut db, &path).expect("save");
3305
3306 let mut loaded = open_database(&path, "t".to_string()).expect("open");
3307
3308 let users = loaded.get_table("users".to_string()).expect("users table");
3310 let status_col = users
3311 .columns
3312 .iter()
3313 .find(|c| c.column_name == "status")
3314 .expect("status column");
3315 assert_eq!(
3316 status_col.default,
3317 Some(Value::Text("active".to_string())),
3318 "DEFAULT 'active' should round-trip"
3319 );
3320 let score_col = users
3321 .columns
3322 .iter()
3323 .find(|c| c.column_name == "score")
3324 .expect("score column");
3325 assert_eq!(
3326 score_col.default,
3327 Some(Value::Integer(0)),
3328 "DEFAULT 0 should round-trip"
3329 );
3330
3331 process_command("INSERT INTO users (id) VALUES (1);", &mut loaded).unwrap();
3334 let users = loaded.get_table("users".to_string()).unwrap();
3335 assert_eq!(
3336 users.get_value("status", 1),
3337 Some(Value::Text("active".to_string()))
3338 );
3339 assert_eq!(users.get_value("score", 1), Some(Value::Integer(0)));
3340
3341 cleanup(&path);
3342 }
3343
3344 #[test]
3353 fn drop_table_freelist_persists_pages_for_reuse() {
3354 let path = tmp_path("freelist_reuse");
3355 let mut db = seed_db();
3356 db.source_path = Some(path.clone());
3357 save_database(&mut db, &path).expect("save");
3358 let pages_two_tables = db.pager.as_ref().unwrap().header().page_count;
3359
3360 process_command("DROP TABLE users;", &mut db).expect("drop users");
3362 let pages_after_drop = db.pager.as_ref().unwrap().header().page_count;
3363 assert_eq!(
3364 pages_after_drop, pages_two_tables,
3365 "page_count should not shrink on drop — the freed pages persist on the freelist"
3366 );
3367 let head_after_drop = db.pager.as_ref().unwrap().header().freelist_head;
3368 assert!(
3369 head_after_drop != 0,
3370 "freelist_head must be non-zero after drop"
3371 );
3372
3373 process_command(
3375 "CREATE TABLE accounts (id INTEGER PRIMARY KEY, label TEXT NOT NULL UNIQUE);",
3376 &mut db,
3377 )
3378 .expect("create accounts");
3379 process_command("INSERT INTO accounts (label) VALUES ('a');", &mut db).unwrap();
3380 process_command("INSERT INTO accounts (label) VALUES ('b');", &mut db).unwrap();
3381 let pages_after_create = db.pager.as_ref().unwrap().header().page_count;
3382 assert!(
3383 pages_after_create <= pages_two_tables + 2,
3384 "creating a similar-sized table after a drop should mostly draw from the \
3385 freelist, not extend the file (got {pages_after_create} > {pages_two_tables} + 2)"
3386 );
3387
3388 cleanup(&path);
3389 }
3390
3391 #[test]
3393 fn drop_then_vacuum_shrinks_file() {
3394 let path = tmp_path("vacuum_shrinks");
3395 let mut db = seed_db();
3396 db.source_path = Some(path.clone());
3397 for i in 0..20 {
3399 process_command(
3400 &format!("INSERT INTO users (name, age) VALUES ('user{i}', {i});"),
3401 &mut db,
3402 )
3403 .unwrap();
3404 }
3405 save_database(&mut db, &path).expect("save");
3406
3407 process_command("DROP TABLE users;", &mut db).expect("drop");
3408 let size_before_vacuum = std::fs::metadata(&path).unwrap().len();
3409 let pages_before_vacuum = db.pager.as_ref().unwrap().header().page_count;
3410 let head_before = db.pager.as_ref().unwrap().header().freelist_head;
3411 assert!(head_before != 0, "drop should populate the freelist");
3412
3413 process_command("VACUUM;", &mut db).expect("vacuum");
3416
3417 let size_after = std::fs::metadata(&path).unwrap().len();
3418 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3419 let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3420 assert!(
3421 pages_after < pages_before_vacuum,
3422 "VACUUM must reduce page_count: was {pages_before_vacuum}, now {pages_after}"
3423 );
3424 assert_eq!(head_after, 0, "VACUUM must clear the freelist");
3425 assert!(
3426 size_after < size_before_vacuum,
3427 "VACUUM must shrink the file on disk: was {size_before_vacuum} bytes, now {size_after}"
3428 );
3429
3430 cleanup(&path);
3431 }
3432
3433 #[test]
3435 fn vacuum_round_trips_data() {
3436 let path = tmp_path("vacuum_round_trip");
3437 let mut db = seed_db();
3438 db.source_path = Some(path.clone());
3439 save_database(&mut db, &path).expect("save");
3440 process_command("VACUUM;", &mut db).expect("vacuum");
3441
3442 drop(db);
3444 let loaded = open_database(&path, "t".to_string()).expect("reopen after vacuum");
3445 assert!(loaded.contains_table("users".to_string()));
3446 assert!(loaded.contains_table("notes".to_string()));
3447 let users = loaded.get_table("users".to_string()).unwrap();
3448 assert_eq!(users.rowids().len(), 2);
3450
3451 cleanup(&path);
3452 }
3453
3454 #[test]
3458 fn freelist_format_version_promotion() {
3459 use crate::sql::pager::header::{FORMAT_VERSION_BASELINE, FORMAT_VERSION_V6};
3460 let path = tmp_path("v6_promotion");
3461 let mut db = seed_db();
3462 db.source_path = Some(path.clone());
3463 save_database(&mut db, &path).expect("save");
3464 let v_after_save = db.pager.as_ref().unwrap().header().format_version;
3465 assert_eq!(
3466 v_after_save, FORMAT_VERSION_BASELINE,
3467 "fresh DB without drops should stay at the baseline version"
3468 );
3469
3470 process_command("DROP TABLE users;", &mut db).expect("drop");
3471 let v_after_drop = db.pager.as_ref().unwrap().header().format_version;
3472 assert_eq!(
3473 v_after_drop, FORMAT_VERSION_V6,
3474 "first save with a non-empty freelist must promote to V6"
3475 );
3476
3477 process_command("VACUUM;", &mut db).expect("vacuum");
3478 let v_after_vacuum = db.pager.as_ref().unwrap().header().format_version;
3479 assert_eq!(
3480 v_after_vacuum, FORMAT_VERSION_V6,
3481 "VACUUM must not downgrade — V6 is a strict superset"
3482 );
3483
3484 cleanup(&path);
3485 }
3486
3487 #[test]
3491 fn freelist_round_trip_through_reopen() {
3492 let path = tmp_path("freelist_reopen");
3493 let pages_two_tables;
3494 {
3495 let mut db = seed_db();
3496 db.source_path = Some(path.clone());
3497 save_database(&mut db, &path).expect("save");
3498 pages_two_tables = db.pager.as_ref().unwrap().header().page_count;
3499 process_command("DROP TABLE users;", &mut db).expect("drop");
3500 let head = db.pager.as_ref().unwrap().header().freelist_head;
3501 assert!(head != 0, "drop must populate the freelist");
3502 }
3503
3504 let mut db = open_database(&path, "t".to_string()).expect("reopen");
3506 assert!(
3507 db.pager.as_ref().unwrap().header().freelist_head != 0,
3508 "freelist_head must survive close/reopen"
3509 );
3510
3511 process_command(
3512 "CREATE TABLE accounts (id INTEGER PRIMARY KEY, label TEXT NOT NULL UNIQUE);",
3513 &mut db,
3514 )
3515 .expect("create accounts");
3516 process_command("INSERT INTO accounts (label) VALUES ('reopened');", &mut db).unwrap();
3517 let pages_after_create = db.pager.as_ref().unwrap().header().page_count;
3518 assert!(
3519 pages_after_create <= pages_two_tables + 2,
3520 "post-reopen create should reuse freelist (got {pages_after_create} > \
3521 {pages_two_tables} + 2 — file extended instead of reusing)"
3522 );
3523
3524 cleanup(&path);
3525 }
3526
3527 #[test]
3530 fn vacuum_inside_transaction_is_rejected() {
3531 let path = tmp_path("vacuum_txn");
3532 let mut db = seed_db();
3533 db.source_path = Some(path.clone());
3534 save_database(&mut db, &path).expect("save");
3535
3536 process_command("BEGIN;", &mut db).expect("begin");
3537 let err = process_command("VACUUM;", &mut db).unwrap_err();
3538 assert!(
3539 format!("{err}").contains("VACUUM cannot run inside a transaction"),
3540 "expected in-transaction rejection, got: {err}"
3541 );
3542 process_command("ROLLBACK;", &mut db).unwrap();
3544 cleanup(&path);
3545 }
3546
3547 #[test]
3549 fn vacuum_on_in_memory_database_is_noop() {
3550 let mut db = Database::new("mem".to_string());
3551 process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap();
3552 let out = process_command("VACUUM;", &mut db).expect("vacuum no-op");
3553 assert!(
3554 out.to_lowercase().contains("no-op") || out.to_lowercase().contains("in-memory"),
3555 "expected no-op message for in-memory VACUUM, got: {out}"
3556 );
3557 }
3558
3559 #[test]
3564 fn unchanged_table_pages_skip_diff_after_unrelated_drop() {
3565 let path = tmp_path("diff_after_drop");
3570 let mut db = Database::new("t".to_string());
3571 db.source_path = Some(path.clone());
3572 process_command(
3573 "CREATE TABLE accounts (id INTEGER PRIMARY KEY, label TEXT);",
3574 &mut db,
3575 )
3576 .unwrap();
3577 process_command(
3578 "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3579 &mut db,
3580 )
3581 .unwrap();
3582 process_command(
3583 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
3584 &mut db,
3585 )
3586 .unwrap();
3587 for i in 0..5 {
3588 process_command(
3589 &format!("INSERT INTO accounts (label) VALUES ('a{i}');"),
3590 &mut db,
3591 )
3592 .unwrap();
3593 process_command(
3594 &format!("INSERT INTO notes (body) VALUES ('n{i}');"),
3595 &mut db,
3596 )
3597 .unwrap();
3598 process_command(
3599 &format!("INSERT INTO users (name) VALUES ('u{i}');"),
3600 &mut db,
3601 )
3602 .unwrap();
3603 }
3604 save_database(&mut db, &path).expect("baseline save");
3605
3606 let pager = db.pager.as_ref().unwrap();
3609 let acc_root = read_old_rootpages(pager, pager.header().schema_root_page)
3610 .unwrap()
3611 .get(&("table".to_string(), "accounts".to_string()))
3612 .copied()
3613 .unwrap();
3614 let users_root = read_old_rootpages(pager, pager.header().schema_root_page)
3615 .unwrap()
3616 .get(&("table".to_string(), "users".to_string()))
3617 .copied()
3618 .unwrap();
3619 let acc_bytes_before: Vec<u8> = pager.read_page(acc_root).unwrap().to_vec();
3620 let users_bytes_before: Vec<u8> = pager.read_page(users_root).unwrap().to_vec();
3621
3622 process_command("DROP TABLE notes;", &mut db).expect("drop notes");
3624
3625 let pager = db.pager.as_ref().unwrap();
3626 let acc_after = pager.read_page(acc_root).unwrap();
3629 let users_after = pager.read_page(users_root).unwrap();
3630 assert_eq!(
3631 &acc_after[..],
3632 &acc_bytes_before[..],
3633 "accounts root page must not be rewritten when an unrelated table is dropped"
3634 );
3635 assert_eq!(
3636 &users_after[..],
3637 &users_bytes_before[..],
3638 "users root page must not be rewritten when an unrelated table is dropped"
3639 );
3640
3641 cleanup(&path);
3642 }
3643
3644 fn auto_vacuum_setup(path: &std::path::Path) -> Database {
3652 let mut db = Database::new("av".to_string());
3653 db.source_path = Some(path.to_path_buf());
3654 process_command(
3655 "CREATE TABLE keep (id INTEGER PRIMARY KEY, n INTEGER);",
3656 &mut db,
3657 )
3658 .unwrap();
3659 process_command("INSERT INTO keep (n) VALUES (1);", &mut db).unwrap();
3660 process_command(
3661 "CREATE TABLE bloat (id INTEGER PRIMARY KEY, payload TEXT);",
3662 &mut db,
3663 )
3664 .unwrap();
3665 process_command("BEGIN;", &mut db).unwrap();
3668 for i in 0..5000 {
3669 process_command(
3670 &format!("INSERT INTO bloat (payload) VALUES ('p-{i:08}');"),
3671 &mut db,
3672 )
3673 .unwrap();
3674 }
3675 process_command("COMMIT;", &mut db).unwrap();
3676 db
3677 }
3678
3679 #[test]
3683 fn auto_vacuum_default_threshold_triggers_on_drop_table() {
3684 let path = tmp_path("av_default_drop_table");
3685 let mut db = auto_vacuum_setup(&path);
3686 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3688
3689 if let Some(p) = db.pager.as_mut() {
3694 let _ = p.checkpoint();
3695 }
3696 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3697 let size_before = std::fs::metadata(&path).unwrap().len();
3698 assert!(
3699 pages_before >= MIN_PAGES_FOR_AUTO_VACUUM,
3700 "setup should produce >= MIN_PAGES_FOR_AUTO_VACUUM ({MIN_PAGES_FOR_AUTO_VACUUM}) \
3701 pages so the floor doesn't suppress the trigger; got {pages_before}"
3702 );
3703
3704 process_command("DROP TABLE bloat;", &mut db).expect("drop");
3708
3709 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3710 let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3711 if let Some(p) = db.pager.as_mut() {
3715 let _ = p.checkpoint();
3716 }
3717 let size_after = std::fs::metadata(&path).unwrap().len();
3718
3719 assert!(
3720 pages_after < pages_before,
3721 "auto-VACUUM must reduce page_count: was {pages_before}, now {pages_after}"
3722 );
3723 assert_eq!(head_after, 0, "auto-VACUUM must clear the freelist");
3724 assert!(
3725 size_after < size_before,
3726 "auto-VACUUM must shrink the file on disk: was {size_before}, now {size_after}"
3727 );
3728
3729 cleanup(&path);
3730 }
3731
3732 #[test]
3736 fn auto_vacuum_disabled_keeps_file_at_hwm() {
3737 let path = tmp_path("av_disabled");
3738 let mut db = auto_vacuum_setup(&path);
3739 db.set_auto_vacuum_threshold(None).expect("disable");
3740 assert_eq!(db.auto_vacuum_threshold(), None);
3741
3742 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3743
3744 process_command("DROP TABLE bloat;", &mut db).expect("drop");
3745
3746 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3747 let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3748 assert_eq!(
3749 pages_after, pages_before,
3750 "with auto-VACUUM disabled, drop must keep page_count at the HWM"
3751 );
3752 assert!(
3753 head_after != 0,
3754 "drop must still populate the freelist (manual VACUUM would be needed to reclaim)"
3755 );
3756
3757 cleanup(&path);
3758 }
3759
3760 #[test]
3772 fn auto_vacuum_triggers_on_drop_index() {
3773 let path = tmp_path("av_drop_index");
3774 let mut db = auto_vacuum_setup(&path);
3775
3776 db.set_auto_vacuum_threshold(None).expect("disable");
3779 process_command("DROP TABLE bloat;", &mut db).expect("drop bloat");
3780 let pages_after_bloat_drop = db.pager.as_ref().unwrap().header().page_count;
3781 let head_after_bloat_drop = db.pager.as_ref().unwrap().header().freelist_head;
3782 assert!(
3783 head_after_bloat_drop != 0,
3784 "bloat drop must populate the freelist (else later index drop won't trip the threshold)"
3785 );
3786
3787 process_command("CREATE INDEX idx_keep_n ON keep (n);", &mut db).expect("create idx");
3791
3792 db.set_auto_vacuum_threshold(Some(0.25)).expect("re-arm");
3797 process_command("DROP INDEX idx_keep_n;", &mut db).expect("drop index");
3798
3799 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3800 let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3801 assert!(
3802 pages_after < pages_after_bloat_drop,
3803 "DROP INDEX should fire auto-VACUUM and reduce page_count: \
3804 was {pages_after_bloat_drop}, now {pages_after}"
3805 );
3806 assert_eq!(
3807 head_after, 0,
3808 "auto-VACUUM after DROP INDEX must clear the freelist"
3809 );
3810
3811 cleanup(&path);
3812 }
3813
3814 #[test]
3817 fn auto_vacuum_triggers_on_alter_drop_column() {
3818 let path = tmp_path("av_alter_drop_col");
3819 let mut db = auto_vacuum_setup(&path);
3820 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3821
3822 process_command("ALTER TABLE bloat DROP COLUMN payload;", &mut db).expect("alter drop");
3825
3826 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3827 assert!(
3828 pages_after < pages_before,
3829 "ALTER TABLE DROP COLUMN should fire auto-VACUUM and reduce page_count: \
3830 was {pages_before}, now {pages_after}"
3831 );
3832 assert_eq!(db.pager.as_ref().unwrap().header().freelist_head, 0);
3833
3834 cleanup(&path);
3835 }
3836
3837 #[test]
3840 fn auto_vacuum_skips_below_threshold() {
3841 let path = tmp_path("av_below_threshold");
3842 let mut db = auto_vacuum_setup(&path);
3843 db.set_auto_vacuum_threshold(Some(0.99)).expect("set");
3844
3845 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3846
3847 process_command("DROP TABLE bloat;", &mut db).expect("drop");
3848
3849 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3850 assert_eq!(
3851 pages_after, pages_before,
3852 "freelist ratio after a single drop is far below 0.99 — \
3853 page_count must stay at the HWM"
3854 );
3855 assert!(
3856 db.pager.as_ref().unwrap().header().freelist_head != 0,
3857 "drop must still populate the freelist"
3858 );
3859
3860 cleanup(&path);
3861 }
3862
3863 #[test]
3869 fn auto_vacuum_skips_inside_transaction() {
3870 let path = tmp_path("av_in_txn");
3871 let mut db = auto_vacuum_setup(&path);
3872 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3873
3874 process_command("BEGIN;", &mut db).expect("begin");
3875 process_command("DROP TABLE bloat;", &mut db).expect("drop in txn");
3876 let pages_mid = db.pager.as_ref().unwrap().header().page_count;
3880 assert_eq!(
3881 pages_mid, pages_before,
3882 "auto-VACUUM must not fire mid-transaction"
3883 );
3884
3885 process_command("ROLLBACK;", &mut db).expect("rollback");
3886 cleanup(&path);
3887 }
3888
3889 #[test]
3893 fn auto_vacuum_skips_under_min_pages_floor() {
3894 let path = tmp_path("av_under_floor");
3895 let mut db = seed_db(); db.source_path = Some(path.clone());
3897 save_database(&mut db, &path).expect("save");
3898 let pages_before = db.pager.as_ref().unwrap().header().page_count;
3900 assert!(
3901 pages_before < MIN_PAGES_FOR_AUTO_VACUUM,
3902 "test setup is too large: floor would not apply (got {pages_before} pages, \
3903 floor is {MIN_PAGES_FOR_AUTO_VACUUM})"
3904 );
3905
3906 process_command("DROP TABLE users;", &mut db).expect("drop");
3907
3908 let pages_after = db.pager.as_ref().unwrap().header().page_count;
3909 assert_eq!(
3910 pages_after, pages_before,
3911 "below MIN_PAGES_FOR_AUTO_VACUUM, drop must not trigger compaction"
3912 );
3913 assert!(
3914 db.pager.as_ref().unwrap().header().freelist_head != 0,
3915 "drop must still populate the freelist normally"
3916 );
3917
3918 cleanup(&path);
3919 }
3920
3921 #[test]
3924 fn set_auto_vacuum_threshold_rejects_out_of_range() {
3925 let mut db = Database::new("t".to_string());
3926 for bad in [-0.01_f32, 1.01, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
3927 let err = db.set_auto_vacuum_threshold(Some(bad)).unwrap_err();
3928 assert!(
3929 format!("{err}").contains("auto_vacuum_threshold"),
3930 "expected a typed range error for {bad}, got: {err}"
3931 );
3932 }
3933 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3935 db.set_auto_vacuum_threshold(Some(0.0)).unwrap();
3937 assert_eq!(db.auto_vacuum_threshold(), Some(0.0));
3938 db.set_auto_vacuum_threshold(Some(1.0)).unwrap();
3939 assert_eq!(db.auto_vacuum_threshold(), Some(1.0));
3940 db.set_auto_vacuum_threshold(None).unwrap();
3941 assert_eq!(db.auto_vacuum_threshold(), None);
3942 }
3943
3944 #[test]
3954 fn pragma_auto_vacuum_set_and_read_via_sql() {
3955 let mut db = Database::new("t".to_string());
3956
3957 let resp = process_command("PRAGMA auto_vacuum = 0.5;", &mut db).expect("set");
3958 assert!(
3959 resp.contains("PRAGMA"),
3960 "set form should produce a PRAGMA status, got: {resp}"
3961 );
3962 assert_eq!(db.auto_vacuum_threshold(), Some(0.5));
3963
3964 let resp = process_command("PRAGMA auto_vacuum;", &mut db).expect("read");
3966 assert!(resp.contains("1 row"), "expected a 1-row read, got: {resp}");
3967 }
3968
3969 #[test]
3974 fn pragma_auto_vacuum_off_disables_trigger() {
3975 for raw in ["OFF", "off", "NONE", "none", "'OFF'", "'NONE'"] {
3976 let mut db = Database::new("t".to_string());
3977 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3978
3979 let stmt = format!("PRAGMA auto_vacuum = {raw};");
3980 process_command(&stmt, &mut db)
3981 .unwrap_or_else(|e| panic!("`{stmt}` should disable: {e}"));
3982 assert_eq!(
3983 db.auto_vacuum_threshold(),
3984 None,
3985 "`{stmt}` should clear the threshold"
3986 );
3987 }
3988 }
3989
3990 #[test]
3994 fn pragma_auto_vacuum_rejects_out_of_range_via_sql() {
3995 let mut db = Database::new("t".to_string());
3996 for bad in ["-0.01", "1.01", "1.5"] {
3997 let stmt = format!("PRAGMA auto_vacuum = {bad};");
3998 let err = process_command(&stmt, &mut db).unwrap_err();
3999 assert!(
4000 format!("{err}").contains("auto_vacuum_threshold"),
4001 "expected range error for `{stmt}`, got: {err}"
4002 );
4003 }
4004 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
4006 }
4007
4008 #[test]
4012 fn pragma_auto_vacuum_rejects_unknown_strings_via_sql() {
4013 let mut db = Database::new("t".to_string());
4014 let err = process_command("PRAGMA auto_vacuum = WAL;", &mut db).unwrap_err();
4015 assert!(
4016 format!("{err}").contains("OFF/NONE"),
4017 "expected OFF/NONE-style error, got: {err}"
4018 );
4019 assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
4021 }
4022
4023 #[test]
4028 fn pragma_unknown_returns_not_implemented() {
4029 let mut db = Database::new("t".to_string());
4030 let err = process_command("PRAGMA synchronous = NORMAL;", &mut db).unwrap_err();
4031 assert!(
4032 matches!(err, SQLRiteError::NotImplemented(_)),
4033 "unknown pragma must surface NotImplemented, got: {err:?}"
4034 );
4035 }
4036
4037 #[test]
4043 fn pragma_auto_vacuum_drives_real_trigger() {
4044 {
4046 let path = tmp_path("av_pragma_off");
4047 let mut db = auto_vacuum_setup(&path);
4048 process_command("PRAGMA auto_vacuum = OFF;", &mut db).expect("disable via PRAGMA");
4049 assert_eq!(db.auto_vacuum_threshold(), None);
4050
4051 let pages_before = db.pager.as_ref().unwrap().header().page_count;
4052 process_command("DROP TABLE bloat;", &mut db).expect("drop");
4053 let pages_after = db.pager.as_ref().unwrap().header().page_count;
4054 assert_eq!(
4055 pages_after, pages_before,
4056 "PRAGMA-driven OFF must keep page_count at the HWM"
4057 );
4058 cleanup(&path);
4059 }
4060
4061 {
4064 let path = tmp_path("av_pragma_high");
4065 let mut db = auto_vacuum_setup(&path);
4066 process_command("PRAGMA auto_vacuum = 0.99;", &mut db).expect("set high");
4067 assert_eq!(db.auto_vacuum_threshold(), Some(0.99));
4068
4069 let pages_before = db.pager.as_ref().unwrap().header().page_count;
4070 process_command("DROP TABLE bloat;", &mut db).expect("drop");
4071 let pages_after = db.pager.as_ref().unwrap().header().page_count;
4072 assert_eq!(
4073 pages_after, pages_before,
4074 "high PRAGMA threshold must suppress the trigger"
4075 );
4076 cleanup(&path);
4077 }
4078
4079 {
4082 let path = tmp_path("av_pragma_rearm");
4083 let mut db = auto_vacuum_setup(&path);
4084 process_command("PRAGMA auto_vacuum = OFF;", &mut db).unwrap();
4085 process_command("DROP TABLE bloat;", &mut db).unwrap();
4088 let pages_after_off_drop = db.pager.as_ref().unwrap().header().page_count;
4089 assert!(db.pager.as_ref().unwrap().header().freelist_head != 0);
4090
4091 process_command("PRAGMA auto_vacuum = 0.25;", &mut db).expect("re-arm");
4095 process_command("CREATE INDEX idx_keep_n ON keep (n);", &mut db).unwrap();
4096 process_command("DROP INDEX idx_keep_n;", &mut db).expect("drop index");
4097
4098 let pages_after_rearm = db.pager.as_ref().unwrap().header().page_count;
4099 assert!(
4100 pages_after_rearm < pages_after_off_drop,
4101 "re-armed PRAGMA must let auto-VACUUM fire: was {pages_after_off_drop}, \
4102 now {pages_after_rearm}"
4103 );
4104 assert_eq!(db.pager.as_ref().unwrap().header().freelist_head, 0);
4105 cleanup(&path);
4106 }
4107 }
4108
4109 #[test]
4112 fn vacuum_modifiers_are_rejected() {
4113 let path = tmp_path("vacuum_modifiers");
4114 let mut db = seed_db();
4115 db.source_path = Some(path.clone());
4116 save_database(&mut db, &path).expect("save");
4117 for stmt in ["VACUUM FULL;", "VACUUM users;"] {
4118 let err = process_command(stmt, &mut db).unwrap_err();
4119 assert!(
4120 format!("{err}").contains("VACUUM modifiers"),
4121 "expected modifier rejection for `{stmt}`, got: {err}"
4122 );
4123 }
4124 cleanup(&path);
4125 }
4126}