• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joaoh82 / rust_sqlite / 25282951421

03 May 2026 03:19PM UTC coverage: 58.704% (+0.9%) from 57.852%
25282951421

Pull #79

github

web-flow
Merge d0008c717 into 500c08833
Pull Request #79: feat(fts): 8b — SQL surface for full-text search

247 of 284 new or added lines in 4 files covered. (86.97%)

6053 of 10311 relevant lines covered (58.7%)

1.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.97
/src/sql/pager/mod.rs
1
//! On-disk persistence for a `Database`, using fixed-size paged files.
2
//!
3
//! The file is a sequence of 4 KiB pages. Page 0 holds the header
4
//! (magic, version, page count, schema-root pointer). Every other page carries
5
//! a small per-page header (type tag + next-page pointer + payload length)
6
//! followed by a payload of up to 4089 bytes.
7
//!
8
//! **Storage strategy (format version 2, Phase 3c.5).**
9
//!
10
//! - Each `Table`'s rows live as **cells** in a chain of `TableLeaf` pages.
11
//!   Cell layout and slot directory are in `cell.rs` / `table_page.rs`;
12
//!   cells that exceed the inline threshold spill into an overflow chain
13
//!   via `overflow.rs`.
14
//! - The schema catalog is itself a regular table named `sqlrite_master`,
15
//!   with one row per user table:
16
//!       `(name TEXT PRIMARY KEY, sql TEXT NOT NULL,
17
//!         rootpage INTEGER NOT NULL, last_rowid INTEGER NOT NULL)`
18
//!   This is the SQLite-style approach: the schema of `sqlrite_master`
19
//!   itself is hardcoded into the engine so the open path can bootstrap.
20
//! - Page 0's `schema_root_page` field points at the first leaf of
21
//!   `sqlrite_master`.
22
//!
23
//! **Format version.** Version 2 is not compatible with files produced by
24
//! earlier commits. Opening a v1 file returns a clean error — users on
25
//! old files have to regenerate them from CREATE/INSERT, as there's no
26
//! production data to migrate yet.
27

28
// Data-layer modules. Not every helper in these modules is used by save/open
29
// yet — some exist for tests, some for future maintenance operations.
30
// Module-level #[allow(dead_code)] keeps the build quiet without dotting
31
// the modules with per-item attributes.
32
#[allow(dead_code)]
33
pub mod cell;
34
pub mod file;
35
pub mod header;
36
#[allow(dead_code)]
37
pub mod hnsw_cell;
38
#[allow(dead_code)]
39
pub mod index_cell;
40
#[allow(dead_code)]
41
pub mod interior_page;
42
pub mod overflow;
43
pub mod page;
44
pub mod pager;
45
#[allow(dead_code)]
46
pub mod table_page;
47
#[allow(dead_code)]
48
pub mod varint;
49
#[allow(dead_code)]
50
pub mod wal;
51

52
use std::collections::{BTreeMap, HashMap};
53
use std::path::Path;
54
use std::sync::{Arc, Mutex};
55

56
use sqlparser::dialect::SQLiteDialect;
57
use sqlparser::parser::Parser;
58

59
use crate::error::{Result, SQLRiteError};
60
use crate::sql::db::database::Database;
61
use crate::sql::db::secondary_index::{IndexOrigin, SecondaryIndex};
62
use crate::sql::db::table::{Column, DataType, Row, Table, Value};
63
use crate::sql::pager::cell::Cell;
64
use crate::sql::pager::header::DbHeader;
65
use crate::sql::pager::index_cell::IndexCell;
66
use crate::sql::pager::interior_page::{InteriorCell, InteriorPage};
67
use crate::sql::pager::overflow::{
68
    OVERFLOW_THRESHOLD, OverflowRef, PagedEntry, read_overflow_chain, write_overflow_chain,
69
};
70
use crate::sql::pager::page::{PAGE_HEADER_SIZE, PAGE_SIZE, PAYLOAD_PER_PAGE, PageType};
71
use crate::sql::pager::pager::Pager;
72
use crate::sql::pager::table_page::TablePage;
73
use crate::sql::parser::create::CreateQuery;
74

75
// Re-export so callers can spell `sql::pager::AccessMode` without
76
// reaching into the `pager::pager::pager` submodule path.
77
pub use crate::sql::pager::pager::AccessMode;
78

79
/// Name of the internal catalog table. Reserved — user CREATEs of this
80
/// name must be rejected upstream.
81
pub const MASTER_TABLE_NAME: &str = "sqlrite_master";
82

83
/// Opens a database file in read-write mode. Shorthand for
84
/// [`open_database_with_mode`] with [`AccessMode::ReadWrite`].
85
pub fn open_database(path: &Path, db_name: String) -> Result<Database> {
2✔
86
    open_database_with_mode(path, db_name, AccessMode::ReadWrite)
2✔
87
}
88

89
/// Opens a database file in read-only mode. Acquires a shared OS-level
90
/// advisory lock, so other read-only openers coexist but any writer is
91
/// excluded. Attempts to mutate the returned `Database` (e.g. an
92
/// `INSERT`, or a `save_database` call against it) bottom out in a
93
/// `cannot commit: database is opened read-only` error from the Pager.
94
pub fn open_database_read_only(path: &Path, db_name: String) -> Result<Database> {
1✔
95
    open_database_with_mode(path, db_name, AccessMode::ReadOnly)
1✔
96
}
97

98
/// Opens a database file and reconstructs the in-memory `Database`,
99
/// leaving the long-lived `Pager` attached for subsequent auto-save
100
/// (read-write) or consistent-snapshot reads (read-only).
101
pub fn open_database_with_mode(path: &Path, db_name: String, mode: AccessMode) -> Result<Database> {
2✔
102
    let pager = Pager::open_with_mode(path, mode)?;
5✔
103

104
    // 1. Load sqlrite_master from the tree at header.schema_root_page.
105
    let mut master = build_empty_master_table();
2✔
106
    load_table_rows(&pager, &mut master, pager.header().schema_root_page)?;
4✔
107

108
    // 2. Two passes over master rows: first build every user table, then
109
    //    attach secondary indexes. Indexes need their base table to exist
110
    //    before we can populate them. Auto-indexes are created at table
111
    //    build time so we only have to load explicit indexes from disk
112
    //    (but we also reload the auto-index CONTENT because Table::new
113
    //    built it empty).
114
    let mut db = Database::new(db_name);
2✔
115
    let mut index_rows: Vec<IndexCatalogRow> = Vec::new();
2✔
116

117
    for rowid in master.rowids() {
6✔
118
        let ty = take_text(&master, "type", rowid)?;
4✔
119
        let name = take_text(&master, "name", rowid)?;
4✔
120
        let sql = take_text(&master, "sql", rowid)?;
4✔
121
        let rootpage = take_integer(&master, "rootpage", rowid)? as u32;
4✔
122
        let last_rowid = take_integer(&master, "last_rowid", rowid)?;
2✔
123

124
        match ty.as_str() {
2✔
125
            "table" => {
2✔
126
                let (parsed_name, columns) = parse_create_sql(&sql)?;
4✔
127
                if parsed_name != name {
4✔
128
                    return Err(SQLRiteError::Internal(format!(
×
129
                        "sqlrite_master row '{name}' carries SQL for '{parsed_name}' — corrupt catalog?"
130
                    )));
131
                }
132
                let mut table = build_empty_table(&name, columns, last_rowid);
4✔
133
                if rootpage != 0 {
2✔
134
                    load_table_rows(&pager, &mut table, rootpage)?;
4✔
135
                }
136
                if last_rowid > table.last_rowid {
2✔
137
                    table.last_rowid = last_rowid;
×
138
                }
139
                db.tables.insert(name, table);
4✔
140
            }
141
            "index" => {
4✔
142
                index_rows.push(IndexCatalogRow {
4✔
143
                    name,
2✔
144
                    sql,
2✔
145
                    rootpage,
146
                });
147
            }
148
            other => {
×
149
                return Err(SQLRiteError::Internal(format!(
×
150
                    "sqlrite_master row '{name}' has unknown type '{other}'"
151
                )));
152
            }
153
        }
154
    }
155

156
    // Second pass: attach each index to its table. HNSW indexes
157
    // (Phase 7d.2) take a different code path because their persisted
158
    // form is just the CREATE INDEX SQL — the graph itself isn't
159
    // persisted yet (Phase 7d.3). Detect HNSW via the SQL's USING clause
160
    // and route to a graph-rebuild instead of the B-Tree-cell load.
161
    //
162
    // Phase 8b — same shape for FTS indexes. The posting lists aren't
163
    // persisted yet (Phase 8c), so we replay the CREATE INDEX SQL on
164
    // open and let `execute_create_index` walk current rows.
165
    for row in index_rows {
6✔
166
        if create_index_sql_uses_hnsw(&row.sql) {
4✔
167
            rebuild_hnsw_index(&mut db, &pager, &row)?;
2✔
168
        } else if create_index_sql_uses_fts(&row.sql) {
4✔
169
            rebuild_fts_index(&mut db, &row)?;
2✔
170
        } else {
171
            attach_index(&mut db, &pager, row)?;
4✔
172
        }
173
    }
174

175
    db.source_path = Some(path.to_path_buf());
2✔
176
    db.pager = Some(pager);
2✔
177
    Ok(db)
2✔
178
}
179

180
/// Catalog row for a secondary index — deferred until after every table is
181
/// loaded so the index's base table exists by the time we populate it.
182
struct IndexCatalogRow {
183
    name: String,
184
    sql: String,
185
    rootpage: u32,
186
}
187

188
/// Persists `db` to disk. Same diff-commit behavior as before: only pages
189
/// whose bytes actually changed get written.
190
pub fn save_database(db: &mut Database, path: &Path) -> Result<()> {
2✔
191
    // Phase 7d.3 — rebuild any HNSW index that DELETE / UPDATE-on-vector
192
    // marked dirty. Done up front under the &mut Database borrow we
193
    // already hold, before the immutable iteration loops below need
194
    // their own borrow.
195
    rebuild_dirty_hnsw_indexes(db);
2✔
196
    // Phase 8b — same drill for FTS indexes flagged by DELETE / UPDATE.
197
    rebuild_dirty_fts_indexes(db);
2✔
198

199
    let same_path = db.source_path.as_deref() == Some(path);
2✔
200
    let mut pager = if same_path {
2✔
201
        match db.pager.take() {
2✔
202
            Some(p) => p,
2✔
203
            None if path.exists() => Pager::open(path)?,
4✔
204
            None => Pager::create(path)?,
2✔
205
        }
206
    } else if path.exists() {
2✔
207
        Pager::open(path)?
×
208
    } else {
209
        Pager::create(path)?
2✔
210
    };
211

212
    pager.clear_staged();
2✔
213

214
    // Page 0 is the header; payload pages start at 1.
215
    let mut next_free_page: u32 = 1;
2✔
216

217
    // 1. Stage each user table's B-Tree, collecting master-row info.
218
    //    `kind` is "table" or "index" — master has one row per each.
219
    let mut master_rows: Vec<CatalogEntry> = Vec::new();
2✔
220

221
    let mut table_names: Vec<&String> = db.tables.keys().collect();
4✔
222
    table_names.sort();
4✔
223
    for name in table_names {
4✔
224
        if name == MASTER_TABLE_NAME {
4✔
225
            return Err(SQLRiteError::Internal(format!(
×
226
                "user table cannot be named '{MASTER_TABLE_NAME}' (reserved)"
227
            )));
228
        }
229
        let table = &db.tables[name];
4✔
230
        let (rootpage, new_next) = stage_table_btree(&mut pager, table, next_free_page)?;
2✔
231
        next_free_page = new_next;
2✔
232
        master_rows.push(CatalogEntry {
2✔
233
            kind: "table".into(),
2✔
234
            name: name.clone(),
2✔
235
            sql: table_to_create_sql(table),
2✔
236
            rootpage,
237
            last_rowid: table.last_rowid,
2✔
238
        });
239
    }
240

241
    // 2. Stage each secondary index's B-Tree. Indexes persist in a
242
    //    deterministic order: sorted by (owning_table, index_name).
243
    let mut index_entries: Vec<(&Table, &SecondaryIndex)> = Vec::new();
2✔
244
    for table in db.tables.values() {
4✔
245
        for idx in &table.secondary_indexes {
4✔
246
            index_entries.push((table, idx));
2✔
247
        }
248
    }
249
    index_entries
2✔
250
        .sort_by(|(ta, ia), (tb, ib)| ta.tb_name.cmp(&tb.tb_name).then(ia.name.cmp(&ib.name)));
4✔
251
    for (_table, idx) in index_entries {
4✔
252
        let (rootpage, new_next) = stage_index_btree(&mut pager, idx, next_free_page)?;
4✔
253
        next_free_page = new_next;
2✔
254
        master_rows.push(CatalogEntry {
2✔
255
            kind: "index".into(),
2✔
256
            name: idx.name.clone(),
2✔
257
            sql: idx.synthesized_sql(),
2✔
258
            rootpage,
259
            last_rowid: 0,
260
        });
261
    }
262

263
    // 2b. Phase 7d.3: persist HNSW indexes as their own cell-encoded
264
    //     page trees, with the rootpage recorded in sqlrite_master.
265
    //     Reopen loads the graph back from cells (fast, exact match)
266
    //     instead of rebuilding from rows.
267
    //
268
    //     Dirty indexes (set by DELETE / UPDATE-on-vector-col) are
269
    //     rebuilt from current rows BEFORE staging, so the on-disk
270
    //     graph reflects the current row set.
271
    let mut hnsw_entries: Vec<(&Table, &crate::sql::db::table::HnswIndexEntry)> = Vec::new();
2✔
272
    for table in db.tables.values() {
4✔
273
        for entry in &table.hnsw_indexes {
4✔
274
            hnsw_entries.push((table, entry));
1✔
275
        }
276
    }
277
    hnsw_entries
2✔
278
        .sort_by(|(ta, ea), (tb, eb)| ta.tb_name.cmp(&tb.tb_name).then(ea.name.cmp(&eb.name)));
2✔
279
    for (table, entry) in hnsw_entries {
4✔
280
        let (rootpage, new_next) = stage_hnsw_btree(&mut pager, &entry.index, next_free_page)?;
2✔
281
        next_free_page = new_next;
1✔
282
        master_rows.push(CatalogEntry {
1✔
283
            kind: "index".into(),
1✔
284
            name: entry.name.clone(),
1✔
285
            sql: format!(
2✔
286
                "CREATE INDEX {} ON {} USING hnsw ({})",
287
                entry.name, table.tb_name, entry.column_name
288
            ),
289
            rootpage,
290
            last_rowid: 0,
291
        });
292
    }
293

294
    // 2c. Phase 8b — persist a sqlrite_master entry for each FTS index
295
    //     so it's replayed on open. The posting list itself isn't on
296
    //     disk yet (Phase 8c) — `rootpage = 0` signals replay-from-rows
297
    //     to `rebuild_fts_index`. Mirrors HNSW's pre-7d.3 shape.
298
    let mut fts_entries: Vec<(&Table, &crate::sql::db::table::FtsIndexEntry)> = Vec::new();
2✔
299
    for table in db.tables.values() {
4✔
300
        for entry in &table.fts_indexes {
4✔
301
            fts_entries.push((table, entry));
1✔
302
        }
303
    }
304
    fts_entries
2✔
305
        .sort_by(|(ta, ea), (tb, eb)| ta.tb_name.cmp(&tb.tb_name).then(ea.name.cmp(&eb.name)));
2✔
306
    for (table, entry) in fts_entries {
4✔
307
        master_rows.push(CatalogEntry {
1✔
308
            kind: "index".into(),
1✔
309
            name: entry.name.clone(),
1✔
310
            sql: format!(
2✔
311
                "CREATE INDEX {} ON {} USING fts ({})",
312
                entry.name, table.tb_name, entry.column_name
313
            ),
314
            rootpage: 0,
315
            last_rowid: 0,
316
        });
317
    }
318

319
    // 3. Build an in-memory sqlrite_master with one row per table or index,
320
    //    then stage it via the same tree-build path.
321
    let mut master = build_empty_master_table();
2✔
322
    for (i, entry) in master_rows.into_iter().enumerate() {
8✔
323
        let rowid = (i as i64) + 1;
4✔
324
        master.restore_row(
2✔
325
            rowid,
326
            vec![
4✔
327
                Some(Value::Text(entry.kind)),
2✔
328
                Some(Value::Text(entry.name)),
2✔
329
                Some(Value::Text(entry.sql)),
2✔
330
                Some(Value::Integer(entry.rootpage as i64)),
2✔
331
                Some(Value::Integer(entry.last_rowid)),
2✔
332
            ],
333
        )?;
334
    }
335
    let (master_root, master_next) = stage_table_btree(&mut pager, &master, next_free_page)?;
2✔
336
    next_free_page = master_next;
2✔
337

338
    pager.commit(DbHeader {
2✔
339
        page_count: next_free_page,
2✔
340
        schema_root_page: master_root,
341
    })?;
342

343
    if same_path {
4✔
344
        db.pager = Some(pager);
2✔
345
    }
346
    Ok(())
2✔
347
}
348

349
/// Build material for a single row in sqlrite_master.
350
struct CatalogEntry {
351
    kind: String, // "table" or "index"
352
    name: String,
353
    sql: String,
354
    rootpage: u32,
355
    last_rowid: i64,
356
}
357

358
// -------------------------------------------------------------------------
359
// sqlrite_master — hardcoded catalog table schema
360

361
fn build_empty_master_table() -> Table {
3✔
362
    // Phase 3e: `type` is the first column, matching SQLite's convention.
363
    // It distinguishes `'table'` rows from `'index'` rows.
364
    let columns = vec![
4✔
365
        Column::new("type".into(), "text".into(), false, true, false),
4✔
366
        Column::new("name".into(), "text".into(), true, true, true),
4✔
367
        Column::new("sql".into(), "text".into(), false, true, false),
4✔
368
        Column::new("rootpage".into(), "integer".into(), false, true, false),
4✔
369
        Column::new("last_rowid".into(), "integer".into(), false, true, false),
4✔
370
    ];
371
    build_empty_table(MASTER_TABLE_NAME, columns, 0)
2✔
372
}
373

374
/// Reads a required Text column from a known-good catalog row.
375
fn take_text(table: &Table, col: &str, rowid: i64) -> Result<String> {
2✔
376
    match table.get_value(col, rowid) {
2✔
377
        Some(Value::Text(s)) => Ok(s),
2✔
378
        other => Err(SQLRiteError::Internal(format!(
×
379
            "sqlrite_master column '{col}' at rowid {rowid}: expected Text, got {other:?}"
380
        ))),
381
    }
382
}
383

384
/// Reads a required Integer column from a known-good catalog row.
385
fn take_integer(table: &Table, col: &str, rowid: i64) -> Result<i64> {
2✔
386
    match table.get_value(col, rowid) {
2✔
387
        Some(Value::Integer(v)) => Ok(v),
2✔
388
        other => Err(SQLRiteError::Internal(format!(
×
389
            "sqlrite_master column '{col}' at rowid {rowid}: expected Integer, got {other:?}"
390
        ))),
391
    }
392
}
393

394
// -------------------------------------------------------------------------
395
// CREATE-TABLE SQL synthesis and re-parsing
396

397
/// Synthesizes a CREATE TABLE SQL string that recreates the table's schema.
398
/// Deterministic: same schema → same SQL, so diffing commits stay stable.
399
fn table_to_create_sql(table: &Table) -> String {
2✔
400
    let mut parts = Vec::with_capacity(table.columns.len());
2✔
401
    for c in &table.columns {
4✔
402
        // Render the SQL type literally so the round-trip through
403
        // CREATE TABLE re-parsing recreates the same schema. Vector
404
        // carries its dimension inline.
405
        let ty: String = match &c.datatype {
2✔
406
            DataType::Integer => "INTEGER".to_string(),
4✔
407
            DataType::Text => "TEXT".to_string(),
4✔
408
            DataType::Real => "REAL".to_string(),
×
409
            DataType::Bool => "BOOLEAN".to_string(),
×
410
            DataType::Vector(dim) => format!("VECTOR({dim})"),
2✔
411
            DataType::Json => "JSON".to_string(),
2✔
412
            DataType::None | DataType::Invalid => "TEXT".to_string(),
×
413
        };
414
        let mut piece = format!("{} {}", c.column_name, ty);
4✔
415
        if c.is_pk {
2✔
416
            piece.push_str(" PRIMARY KEY");
4✔
417
        } else {
418
            if c.is_unique {
2✔
419
                piece.push_str(" UNIQUE");
2✔
420
            }
421
            if c.not_null {
2✔
422
                piece.push_str(" NOT NULL");
2✔
423
            }
424
        }
425
        parts.push(piece);
2✔
426
    }
427
    format!("CREATE TABLE {} ({});", table.tb_name, parts.join(", "))
2✔
428
}
429

430
/// Reverses `table_to_create_sql`: feeds the SQL back through `sqlparser`
431
/// and produces our internal column list. Returns `(table_name, columns)`.
432
fn parse_create_sql(sql: &str) -> Result<(String, Vec<Column>)> {
2✔
433
    let dialect = SQLiteDialect {};
2✔
434
    let mut ast = Parser::parse_sql(&dialect, sql).map_err(SQLRiteError::from)?;
2✔
435
    let stmt = ast.pop().ok_or_else(|| {
4✔
436
        SQLRiteError::Internal("sqlrite_master row held an empty SQL string".to_string())
×
437
    })?;
438
    let create = CreateQuery::new(&stmt)?;
4✔
439
    let columns = create
2✔
440
        .columns
441
        .into_iter()
442
        .map(|pc| Column::new(pc.name, pc.datatype, pc.is_pk, pc.not_null, pc.is_unique))
6✔
443
        .collect();
444
    Ok((create.table_name, columns))
2✔
445
}
446

447
// -------------------------------------------------------------------------
448
// In-memory table (re)construction
449

450
/// Builds an empty in-memory `Table` given the declared columns.
451
fn build_empty_table(name: &str, columns: Vec<Column>, last_rowid: i64) -> Table {
2✔
452
    let rows: Arc<Mutex<HashMap<String, Row>>> = Arc::new(Mutex::new(HashMap::new()));
4✔
453
    let mut secondary_indexes: Vec<SecondaryIndex> = Vec::new();
2✔
454
    {
455
        let mut map = rows.lock().expect("rows mutex poisoned");
4✔
456
        for col in &columns {
6✔
457
            // Mirror the dispatch in `Table::new` so the reconstructed
458
            // table has the same shape it'd have if it were built fresh
459
            // from SQL. Phase 7a adds the Vector arm — without it,
460
            // VECTOR columns silently restore as Row::None and every
461
            // restore_row hits a "storage None vs value Some(Vector(...))"
462
            // type mismatch.
463
            let row = match &col.datatype {
2✔
464
                DataType::Integer => Row::Integer(BTreeMap::new()),
4✔
465
                DataType::Text => Row::Text(BTreeMap::new()),
4✔
466
                DataType::Real => Row::Real(BTreeMap::new()),
×
467
                DataType::Bool => Row::Bool(BTreeMap::new()),
×
468
                DataType::Vector(_dim) => Row::Vector(BTreeMap::new()),
2✔
469
                // JSON columns reuse Text storage — see Table::new and
470
                // Phase 7e's scope-correction note.
471
                DataType::Json => Row::Text(BTreeMap::new()),
2✔
472
                DataType::None | DataType::Invalid => Row::None,
×
473
            };
474
            map.insert(col.column_name.clone(), row);
4✔
475

476
            // Auto-create UNIQUE/PK indexes so the restored table has the
477
            // same shape Table::new would have built from fresh SQL.
478
            if (col.is_pk || col.is_unique)
2✔
479
                && matches!(col.datatype, DataType::Integer | DataType::Text)
2✔
480
            {
481
                if let Ok(idx) = SecondaryIndex::new(
482
                    SecondaryIndex::auto_name(name, &col.column_name),
2✔
483
                    name.to_string(),
4✔
484
                    col.column_name.clone(),
2✔
485
                    &col.datatype,
486
                    true,
487
                    IndexOrigin::Auto,
488
                ) {
489
                    secondary_indexes.push(idx);
2✔
490
                }
491
            }
492
        }
493
    }
494

495
    let primary_key = columns
4✔
496
        .iter()
497
        .find(|c| c.is_pk)
6✔
498
        .map(|c| c.column_name.clone())
6✔
499
        .unwrap_or_else(|| "-1".to_string());
2✔
500

501
    Table {
502
        tb_name: name.to_string(),
2✔
503
        columns,
504
        rows,
505
        secondary_indexes,
506
        // HNSW indexes (Phase 7d.2) are reconstructed on open by re-
507
        // executing each `CREATE INDEX … USING hnsw` SQL stored in
508
        // `sqlrite_master`. This builder produces the empty shell;
509
        // `replay_create_index_for_hnsw` (in this same module) walks
510
        // sqlrite_master after every table is loaded and rebuilds the
511
        // graph from current row data. Persistence of the graph itself
512
        // (avoiding the on-open rebuild cost) is Phase 7d.3.
513
        hnsw_indexes: Vec::new(),
2✔
514
        // FTS indexes (Phase 8b) follow the same pattern — the
515
        // CREATE INDEX … USING fts SQL is the source of truth on open
516
        // and the in-memory posting list gets rebuilt from current
517
        // rows. Cell-encoded persistence of the postings is Phase 8c.
518
        fts_indexes: Vec::new(),
2✔
519
        last_rowid,
520
        primary_key,
521
    }
522
}
523

524
// -------------------------------------------------------------------------
525
// Leaf-chain read / write
526

527
/// Walks a table's B-Tree from `root_page`, following the leftmost-child
528
/// chain down to the first leaf, then iterating leaves via their sibling
529
/// `next_page` pointers. Every cell is decoded and replayed into `table`.
530
///
531
/// Open-path note: we eagerly materialize the entire table into `Table`'s
532
/// in-memory maps. Phase 5 will introduce a `Cursor` that hits the pager
533
/// on demand so queries can stream through the tree without a full upfront
534
/// load.
535
/// Re-parses `CREATE INDEX` SQL from sqlrite_master and restores the
536
/// index on its base table by walking the tree of index cells at
537
/// `rootpage`. The base table is expected to already be in `db.tables`.
538
fn attach_index(db: &mut Database, pager: &Pager, row: IndexCatalogRow) -> Result<()> {
2✔
539
    let (table_name, column_name, is_unique) = parse_create_index_sql(&row.sql)?;
4✔
540

541
    let table = db.get_table_mut(table_name.clone()).map_err(|_| {
4✔
542
        SQLRiteError::Internal(format!(
×
543
            "index '{}' references unknown table '{table_name}' (sqlrite_master out of sync?)",
544
            row.name
545
        ))
546
    })?;
547
    let datatype = table
6✔
548
        .columns
549
        .iter()
2✔
550
        .find(|c| c.column_name == column_name)
6✔
551
        .map(|c| clone_datatype(&c.datatype))
6✔
552
        .ok_or_else(|| {
2✔
553
            SQLRiteError::Internal(format!(
×
554
                "index '{}' references unknown column '{column_name}' on '{table_name}'",
555
                row.name
556
            ))
557
        })?;
558

559
    // An auto-index on this column may already exist (built by
560
    // build_empty_table for UNIQUE/PK columns). If the names match, reuse
561
    // the slot instead of adding a duplicate entry.
562
    let existing_slot = table
6✔
563
        .secondary_indexes
564
        .iter()
565
        .position(|i| i.name == row.name);
6✔
566
    let idx = match existing_slot {
2✔
567
        Some(i) => {
2✔
568
            // Drain any entries that may have been populated during table
569
            // restore_row calls — we're about to repopulate from the
570
            // persisted tree.
571
            table.secondary_indexes.remove(i)
4✔
572
        }
573
        None => SecondaryIndex::new(
2✔
574
            row.name.clone(),
1✔
575
            table_name.clone(),
2✔
576
            column_name.clone(),
1✔
577
            &datatype,
578
            is_unique,
579
            IndexOrigin::Explicit,
580
        )?,
581
    };
582
    let mut idx = idx;
2✔
583
    // Wipe any stale entries from the auto path so the load is idempotent.
584
    let is_unique_flag = idx.is_unique;
2✔
585
    let origin = idx.origin;
2✔
586
    idx = SecondaryIndex::new(
6✔
587
        idx.name,
2✔
588
        idx.table_name,
2✔
589
        idx.column_name,
2✔
590
        &datatype,
591
        is_unique_flag,
592
        origin,
593
    )?;
594

595
    // Populate from the index tree's cells.
596
    load_index_rows(pager, &mut idx, row.rootpage)?;
2✔
597

598
    table.secondary_indexes.push(idx);
2✔
599
    Ok(())
2✔
600
}
601

602
/// Walks the leaves of an index B-Tree rooted at `root_page` and inserts
603
/// every `(value, rowid)` pair into `idx`.
604
fn load_index_rows(pager: &Pager, idx: &mut SecondaryIndex, root_page: u32) -> Result<()> {
2✔
605
    if root_page == 0 {
2✔
606
        return Ok(());
×
607
    }
608
    let first_leaf = find_leftmost_leaf(pager, root_page)?;
2✔
609
    let mut current = first_leaf;
2✔
610
    while current != 0 {
2✔
611
        let page_buf = pager
2✔
612
            .read_page(current)
2✔
613
            .ok_or_else(|| SQLRiteError::Internal(format!("missing index leaf page {current}")))?;
2✔
614
        if page_buf[0] != PageType::TableLeaf as u8 {
2✔
615
            return Err(SQLRiteError::Internal(format!(
×
616
                "page {current} tagged {} but expected TableLeaf (index)",
617
                page_buf[0]
618
            )));
619
        }
620
        let next_leaf = u32::from_le_bytes(page_buf[1..5].try_into().unwrap());
2✔
621
        let payload: &[u8; PAYLOAD_PER_PAGE] = (&page_buf[PAGE_HEADER_SIZE..])
4✔
622
            .try_into()
2✔
623
            .map_err(|_| SQLRiteError::Internal("index leaf payload size".to_string()))?;
2✔
624
        let leaf = TablePage::from_bytes(payload);
2✔
625

626
        for slot in 0..leaf.slot_count() {
4✔
627
            // Slots on an index page hold KIND_INDEX cells; decode directly.
628
            let offset = leaf.slot_offset_raw(slot)?;
4✔
629
            let (ic, _) = IndexCell::decode(leaf.as_bytes(), offset)?;
2✔
630
            idx.insert(&ic.value, ic.rowid)?;
4✔
631
        }
632
        current = next_leaf;
2✔
633
    }
634
    Ok(())
2✔
635
}
636

637
/// Minimal recognizer for the synthesized-or-user `CREATE INDEX` SQL we
638
/// store in sqlrite_master. Returns `(table_name, column_name, is_unique)`.
639
///
640
/// Uses sqlparser so user-supplied SQL with extra whitespace, case, etc.
641
/// still works; the only shape we accept is single-column indexes.
642
fn parse_create_index_sql(sql: &str) -> Result<(String, String, bool)> {
2✔
643
    use sqlparser::ast::{CreateIndex, Expr, Statement};
644

645
    let dialect = SQLiteDialect {};
2✔
646
    let mut ast = Parser::parse_sql(&dialect, sql).map_err(SQLRiteError::from)?;
2✔
647
    let Some(Statement::CreateIndex(CreateIndex {
4✔
648
        table_name,
2✔
649
        columns,
2✔
650
        unique,
2✔
651
        ..
652
    })) = ast.pop()
6✔
653
    else {
654
        return Err(SQLRiteError::Internal(format!(
×
655
            "sqlrite_master index row's SQL isn't a CREATE INDEX: {sql}"
656
        )));
657
    };
658
    if columns.len() != 1 {
4✔
659
        return Err(SQLRiteError::NotImplemented(
×
660
            "multi-column indexes aren't supported yet".to_string(),
×
661
        ));
662
    }
663
    let col = match &columns[0].column.expr {
4✔
664
        Expr::Identifier(ident) => ident.value.clone(),
4✔
665
        Expr::CompoundIdentifier(parts) => {
×
666
            parts.last().map(|p| p.value.clone()).unwrap_or_default()
×
667
        }
668
        other => {
×
669
            return Err(SQLRiteError::Internal(format!(
×
670
                "unsupported indexed column expression: {other:?}"
671
            )));
672
        }
673
    };
674
    Ok((table_name.to_string(), col, unique))
4✔
675
}
676

677
/// True iff a CREATE INDEX SQL string uses `USING hnsw` (case-insensitive).
678
/// Used by the open path to route HNSW indexes to the graph-rebuild path
679
/// instead of the standard B-Tree cell-load. Pre-Phase-7d.2 indexes
680
/// don't have a USING clause, so they all return false and continue
681
/// taking the existing path.
682
fn create_index_sql_uses_hnsw(sql: &str) -> bool {
2✔
683
    use sqlparser::ast::{CreateIndex, IndexType, Statement};
684

685
    let dialect = SQLiteDialect {};
2✔
686
    let Ok(mut ast) = Parser::parse_sql(&dialect, sql) else {
4✔
687
        return false;
×
688
    };
689
    let Some(Statement::CreateIndex(CreateIndex { using, .. })) = ast.pop() else {
6✔
690
        return false;
×
691
    };
692
    matches!(using, Some(IndexType::Custom(ident)) if ident.value.eq_ignore_ascii_case("hnsw"))
3✔
693
}
694

695
/// Phase 8b — peeks at a CREATE INDEX SQL to detect `USING fts(...)`.
696
/// Mirrors [`create_index_sql_uses_hnsw`].
697
fn create_index_sql_uses_fts(sql: &str) -> bool {
2✔
698
    use sqlparser::ast::{CreateIndex, IndexType, Statement};
699

700
    let dialect = SQLiteDialect {};
2✔
701
    let Ok(mut ast) = Parser::parse_sql(&dialect, sql) else {
4✔
NEW
702
        return false;
×
703
    };
704
    let Some(Statement::CreateIndex(CreateIndex { using, .. })) = ast.pop() else {
6✔
NEW
705
        return false;
×
706
    };
707
    matches!(using, Some(IndexType::Custom(ident)) if ident.value.eq_ignore_ascii_case("fts"))
3✔
708
}
709

710
/// Phase 8b — replays a `CREATE INDEX … USING fts(...)` statement on
711
/// database open to rebuild its in-memory `PostingList` from current
712
/// rows. Mirrors the `rootpage == 0` arm of [`rebuild_hnsw_index`].
713
/// Persistence of the posting lists themselves is Phase 8c.
714
fn rebuild_fts_index(db: &mut Database, row: &IndexCatalogRow) -> Result<()> {
1✔
715
    use crate::sql::executor::execute_create_index;
716
    use sqlparser::ast::Statement;
717

718
    let dialect = SQLiteDialect {};
1✔
719
    let mut ast = Parser::parse_sql(&dialect, &row.sql).map_err(SQLRiteError::from)?;
1✔
720
    let Some(stmt @ Statement::CreateIndex(_)) = ast.pop() else {
3✔
NEW
721
        return Err(SQLRiteError::Internal(format!(
×
722
            "sqlrite_master FTS row's SQL isn't a CREATE INDEX: {}",
723
            row.sql
724
        )));
725
    };
726
    execute_create_index(&stmt, db)?;
2✔
727
    Ok(())
1✔
728
}
729

730
/// Loads (or rebuilds) an HNSW index on database open. Two paths:
731
///
732
///   - **rootpage != 0** (Phase 7d.3 default): the graph is persisted
733
///     as cell-encoded pages. Read every node directly via
734
///     `load_hnsw_nodes` and reconstruct the index — fast, zero
735
///     algorithm runs, exact bit-for-bit reproduction of what was saved.
736
///
737
///   - **rootpage == 0** (compatibility): no on-disk graph, e.g. for
738
///     files saved by Phase 7d.2 before persistence landed. Replay the
739
///     CREATE INDEX SQL through `execute_create_index`, which walks the
740
///     table's current rows and populates a fresh graph. Slower but
741
///     correctness-equivalent on the first save with the new code.
742
fn rebuild_hnsw_index(db: &mut Database, pager: &Pager, row: &IndexCatalogRow) -> Result<()> {
1✔
743
    use crate::sql::db::table::HnswIndexEntry;
744
    use crate::sql::executor::execute_create_index;
745
    use crate::sql::hnsw::{DistanceMetric, HnswIndex};
746
    use sqlparser::ast::Statement;
747

748
    let dialect = SQLiteDialect {};
1✔
749
    let mut ast = Parser::parse_sql(&dialect, &row.sql).map_err(SQLRiteError::from)?;
1✔
750
    let Some(stmt @ Statement::CreateIndex(_)) = ast.pop() else {
3✔
751
        return Err(SQLRiteError::Internal(format!(
×
752
            "sqlrite_master HNSW row's SQL isn't a CREATE INDEX: {}",
753
            row.sql
754
        )));
755
    };
756

757
    if row.rootpage == 0 {
1✔
758
        // Compatibility path — no persisted graph; walk current rows.
759
        execute_create_index(&stmt, db)?;
×
760
        return Ok(());
×
761
    }
762

763
    // Persistence path — read the cell tree, deserialize.
764
    let nodes = load_hnsw_nodes(pager, row.rootpage)?;
2✔
765
    let index = HnswIndex::from_persisted_nodes(DistanceMetric::L2, 0xC0FFEE, nodes);
2✔
766

767
    // Parse the CREATE INDEX to know which table + column to attach to
768
    // — same shape as the row-walk path; we just don't execute it.
769
    let (tbl_name, col_name) = parse_hnsw_create_index_sql(&row.sql)?;
2✔
770
    let table_mut = db.get_table_mut(tbl_name.clone()).map_err(|_| {
3✔
771
        SQLRiteError::Internal(format!(
×
772
            "HNSW index '{}' references unknown table '{tbl_name}'",
773
            row.name
774
        ))
775
    })?;
776
    table_mut.hnsw_indexes.push(HnswIndexEntry {
2✔
777
        name: row.name.clone(),
1✔
778
        column_name: col_name,
1✔
779
        index,
1✔
780
        needs_rebuild: false,
781
    });
782
    Ok(())
1✔
783
}
784

785
/// Phase 7d.3 — Phase-7d.3-side helper: walk every leaf in the HNSW
786
/// page tree at `root_page` and decode each cell as a node. Returns
787
/// the (node_id, layers) tuples in slot-order (already ascending by
788
/// node_id since they were staged that way). The caller hands them to
789
/// `HnswIndex::from_persisted_nodes`.
790
fn load_hnsw_nodes(pager: &Pager, root_page: u32) -> Result<Vec<(i64, Vec<Vec<i64>>)>> {
1✔
791
    use crate::sql::pager::hnsw_cell::HnswNodeCell;
792

793
    let mut nodes: Vec<(i64, Vec<Vec<i64>>)> = Vec::new();
1✔
794
    let first_leaf = find_leftmost_leaf(pager, root_page)?;
2✔
795
    let mut current = first_leaf;
1✔
796
    while current != 0 {
1✔
797
        let page_buf = pager
1✔
798
            .read_page(current)
1✔
799
            .ok_or_else(|| SQLRiteError::Internal(format!("missing HNSW leaf page {current}")))?;
1✔
800
        if page_buf[0] != PageType::TableLeaf as u8 {
1✔
801
            return Err(SQLRiteError::Internal(format!(
×
802
                "page {current} tagged {} but expected TableLeaf (HNSW)",
803
                page_buf[0]
×
804
            )));
805
        }
806
        let next_leaf = u32::from_le_bytes(page_buf[1..5].try_into().unwrap());
2✔
807
        let payload: &[u8; PAYLOAD_PER_PAGE] = (&page_buf[PAGE_HEADER_SIZE..])
2✔
808
            .try_into()
1✔
809
            .map_err(|_| SQLRiteError::Internal("HNSW leaf payload size".to_string()))?;
1✔
810
        let leaf = TablePage::from_bytes(payload);
1✔
811
        for slot in 0..leaf.slot_count() {
3✔
812
            let offset = leaf.slot_offset_raw(slot)?;
2✔
813
            let (cell, _) = HnswNodeCell::decode(leaf.as_bytes(), offset)?;
1✔
814
            nodes.push((cell.node_id, cell.layers));
1✔
815
        }
816
        current = next_leaf;
1✔
817
    }
818
    Ok(nodes)
1✔
819
}
820

821
/// Pulls (table_name, column_name) out of a `CREATE INDEX … USING hnsw (col)`
822
/// SQL string. Used by the persistence path on open to know where to
823
/// attach the loaded graph. Same shape as `parse_create_index_sql` for
824
/// regular indexes — only the assertion differs (we don't care about
825
/// UNIQUE for HNSW).
826
fn parse_hnsw_create_index_sql(sql: &str) -> Result<(String, String)> {
1✔
827
    use sqlparser::ast::{CreateIndex, Expr, Statement};
828

829
    let dialect = SQLiteDialect {};
1✔
830
    let mut ast = Parser::parse_sql(&dialect, sql).map_err(SQLRiteError::from)?;
1✔
831
    let Some(Statement::CreateIndex(CreateIndex {
2✔
832
        table_name,
1✔
833
        columns,
1✔
834
        ..
835
    })) = ast.pop()
3✔
836
    else {
837
        return Err(SQLRiteError::Internal(format!(
×
838
            "sqlrite_master HNSW row's SQL isn't a CREATE INDEX: {sql}"
839
        )));
840
    };
841
    if columns.len() != 1 {
2✔
842
        return Err(SQLRiteError::NotImplemented(
×
843
            "multi-column HNSW indexes aren't supported yet".to_string(),
×
844
        ));
845
    }
846
    let col = match &columns[0].column.expr {
2✔
847
        Expr::Identifier(ident) => ident.value.clone(),
2✔
848
        Expr::CompoundIdentifier(parts) => {
×
849
            parts.last().map(|p| p.value.clone()).unwrap_or_default()
×
850
        }
851
        other => {
×
852
            return Err(SQLRiteError::Internal(format!(
×
853
                "unsupported HNSW indexed column expression: {other:?}"
854
            )));
855
        }
856
    };
857
    Ok((table_name.to_string(), col))
2✔
858
}
859

860
/// Phase 7d.3 — rebuilds in-place any HnswIndexEntry whose
861
/// `needs_rebuild` flag is set (DELETE / UPDATE-on-vector marked it).
862
/// Walks the table's current Vec<f32> column storage and runs the
863
/// HNSW algorithm fresh. Called at the top of `save_database` before
864
/// any immutable borrows of `db` start.
865
///
866
/// Cost: O(N · ef_construction · log N) per dirty index. Fine for
867
/// small tables, expensive for ≥100k-row tables — matches the
868
/// trade-off SQLite makes for FTS5: dirtying-and-rebuilding is the
869
/// MVP, more sophisticated incremental delete strategies (soft-delete
870
/// + tombstones, neighbor reconnection) are future polish.
871
fn rebuild_dirty_hnsw_indexes(db: &mut Database) {
2✔
872
    use crate::sql::hnsw::{DistanceMetric, HnswIndex};
873

874
    for table in db.tables.values_mut() {
5✔
875
        // Snapshot which (index_name, column) pairs need rebuilding,
876
        // before we go grabbing column data — keeps the borrow
877
        // structure simple.
878
        let dirty: Vec<(String, String)> = table
4✔
879
            .hnsw_indexes
880
            .iter()
881
            .filter(|e| e.needs_rebuild)
4✔
882
            .map(|e| (e.name.clone(), e.column_name.clone()))
4✔
883
            .collect();
884
        if dirty.is_empty() {
4✔
885
            continue;
886
        }
887

888
        for (idx_name, col_name) in dirty {
3✔
889
            // Snapshot every (rowid, vec) for this column.
890
            let mut vectors: Vec<(i64, Vec<f32>)> = Vec::new();
1✔
891
            {
892
                let row_data = table.rows.lock().expect("rows mutex poisoned");
2✔
893
                if let Some(Row::Vector(map)) = row_data.get(&col_name) {
3✔
894
                    for (id, v) in map.iter() {
1✔
895
                        vectors.push((*id, v.clone()));
1✔
896
                    }
897
                }
898
            }
899
            // Pre-build a HashMap for the get_vec closure so we don't
900
            // pay O(N) lookup per insert call.
901
            let snapshot: std::collections::HashMap<i64, Vec<f32>> =
1✔
902
                vectors.iter().cloned().collect();
903

904
            let mut new_idx = HnswIndex::new(DistanceMetric::L2, 0xC0FFEE);
2✔
905
            // Sort by id so the rebuild is deterministic across runs.
906
            vectors.sort_by_key(|(id, _)| *id);
4✔
907
            for (id, v) in &vectors {
1✔
908
                new_idx.insert(*id, v, |q| snapshot.get(&q).cloned().unwrap_or_default());
4✔
909
            }
910

911
            // Replace the entry's index + clear the dirty flag.
912
            if let Some(entry) = table.hnsw_indexes.iter_mut().find(|e| e.name == idx_name) {
4✔
913
                entry.index = new_idx;
1✔
914
                entry.needs_rebuild = false;
1✔
915
            }
916
        }
917
    }
918
}
919

920
/// Phase 8b — rebuild every FTS index a DELETE / UPDATE-on-text-col
921
/// marked dirty. Mirrors [`rebuild_dirty_hnsw_indexes`]; runs at save
922
/// time under `&mut Database`. Cheap on a clean DB (the `dirty` snapshot
923
/// is empty so the per-table loop short-circuits).
924
fn rebuild_dirty_fts_indexes(db: &mut Database) {
2✔
925
    use crate::sql::fts::PostingList;
926

927
    for table in db.tables.values_mut() {
5✔
928
        let dirty: Vec<(String, String)> = table
4✔
929
            .fts_indexes
930
            .iter()
931
            .filter(|e| e.needs_rebuild)
4✔
932
            .map(|e| (e.name.clone(), e.column_name.clone()))
4✔
933
            .collect();
934
        if dirty.is_empty() {
4✔
935
            continue;
936
        }
937

938
        for (idx_name, col_name) in dirty {
3✔
939
            // Snapshot every (rowid, text) pair for this column under
940
            // the row mutex, then drop the lock before re-tokenizing.
941
            let mut docs: Vec<(i64, String)> = Vec::new();
1✔
942
            {
943
                let row_data = table.rows.lock().expect("rows mutex poisoned");
2✔
944
                if let Some(Row::Text(map)) = row_data.get(&col_name) {
3✔
945
                    for (id, v) in map.iter() {
1✔
946
                        // "Null" sentinel is the parser's
947
                        // null-marker for TEXT cells; skip those —
948
                        // they'd round-trip as the literal string
949
                        // "Null" otherwise. Aligns with insert_row's
950
                        // typed_value gate.
951
                        if v != "Null" {
1✔
952
                            docs.push((*id, v.clone()));
1✔
953
                        }
954
                    }
955
                }
956
            }
957

958
            let mut new_idx = PostingList::new();
1✔
959
            // Sort by id so the rebuild is deterministic across runs
960
            // (the BTreeMap inside PostingList is order-stable, but
961
            // doc-length aggregation order doesn't matter — sorting
962
            // here is purely for reproducibility on inspection).
963
            docs.sort_by_key(|(id, _)| *id);
4✔
964
            for (id, text) in &docs {
1✔
965
                new_idx.insert(*id, text);
2✔
966
            }
967

968
            if let Some(entry) = table.fts_indexes.iter_mut().find(|e| e.name == idx_name) {
4✔
969
                entry.index = new_idx;
1✔
970
                entry.needs_rebuild = false;
1✔
971
            }
972
        }
973
    }
974
}
975

976
/// Cheap clone helper — `DataType` doesn't derive `Clone` elsewhere.
977
fn clone_datatype(dt: &DataType) -> DataType {
2✔
978
    match dt {
2✔
979
        DataType::Integer => DataType::Integer,
2✔
980
        DataType::Text => DataType::Text,
1✔
981
        DataType::Real => DataType::Real,
×
982
        DataType::Bool => DataType::Bool,
×
983
        DataType::Vector(dim) => DataType::Vector(*dim),
×
984
        DataType::Json => DataType::Json,
×
985
        DataType::None => DataType::None,
×
986
        DataType::Invalid => DataType::Invalid,
×
987
    }
988
}
989

990
/// Stages an index's B-Tree at `start_page`. Each leaf cell is a
991
/// `KIND_INDEX` entry carrying `(original_rowid, value)`. Returns
992
/// `(root_page, next_free_page)`.
993
///
994
/// The tree's shape matches a regular table's — leaves chained via
995
/// `next_page`, optional interior layer above. `Cell::peek_rowid` works
996
/// uniformly for index cells (same prefix as local cells), so the
997
/// existing slot directory and binary search carry over.
998
fn stage_index_btree(
2✔
999
    pager: &mut Pager,
1000
    idx: &SecondaryIndex,
1001
    start_page: u32,
1002
) -> Result<(u32, u32)> {
1003
    // Build the leaves.
1004
    let (leaves, mut next_free_page) = stage_index_leaves(pager, idx, start_page)?;
2✔
1005
    if leaves.len() == 1 {
4✔
1006
        return Ok((leaves[0].0, next_free_page));
4✔
1007
    }
1008
    let mut level: Vec<(u32, i64)> = leaves;
1✔
1009
    while level.len() > 1 {
4✔
1010
        let (next_level, new_next_free) = stage_interior_level(pager, &level, next_free_page)?;
2✔
1011
        next_free_page = new_next_free;
1✔
1012
        level = next_level;
2✔
1013
    }
1014
    Ok((level[0].0, next_free_page))
2✔
1015
}
1016

1017
/// Packs the index's (value, rowid) entries into a sibling-chained run
1018
/// of `TableLeaf` pages. Iteration order matches `SecondaryIndex::iter_entries`
1019
/// (ascending value; rowids in insertion order within a value), which is
1020
/// also ascending by the "cell rowid" carried in each IndexCell (the
1021
/// original row's rowid) — so Cell::peek_rowid + the slot directory's
1022
/// rowid ordering stays consistent.
1023
fn stage_index_leaves(
2✔
1024
    pager: &mut Pager,
1025
    idx: &SecondaryIndex,
1026
    start_page: u32,
1027
) -> Result<(Vec<(u32, i64)>, u32)> {
1028
    let mut leaves: Vec<(u32, i64)> = Vec::new();
2✔
1029
    let mut current_leaf = TablePage::empty();
4✔
1030
    let mut current_leaf_page = start_page;
2✔
1031
    let mut current_max_rowid: Option<i64> = None;
2✔
1032
    let mut next_free_page = start_page + 1;
2✔
1033

1034
    // Sort the entries by original rowid so the in-page slot directory,
1035
    // which binary-searches by rowid, stays valid. (iter_entries orders by
1036
    // value; we reorder here for B-Tree correctness.)
1037
    let mut entries: Vec<(Value, i64)> = idx.iter_entries().collect();
4✔
1038
    entries.sort_by_key(|(_, r)| *r);
6✔
1039

1040
    for (value, rowid) in entries {
4✔
1041
        let cell = IndexCell::new(rowid, value);
2✔
1042
        let entry_bytes = cell.encode()?;
4✔
1043

1044
        if !current_leaf.would_fit(entry_bytes.len()) {
4✔
1045
            let next_leaf_page_num = next_free_page;
1✔
1046
            emit_leaf(pager, current_leaf_page, &current_leaf, next_leaf_page_num);
1✔
1047
            leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1✔
1048
            current_leaf = TablePage::empty();
1✔
1049
            current_leaf_page = next_leaf_page_num;
1✔
1050
            next_free_page += 1;
1✔
1051

1052
            if !current_leaf.would_fit(entry_bytes.len()) {
2✔
1053
                return Err(SQLRiteError::Internal(format!(
×
1054
                    "index entry of {} bytes exceeds empty-page capacity {}",
1055
                    entry_bytes.len(),
×
1056
                    current_leaf.free_space()
×
1057
                )));
1058
            }
1059
        }
1060
        current_leaf.insert_entry(rowid, &entry_bytes)?;
4✔
1061
        current_max_rowid = Some(rowid);
2✔
1062
    }
1063

1064
    emit_leaf(pager, current_leaf_page, &current_leaf, 0);
2✔
1065
    leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
2✔
1066
    Ok((leaves, next_free_page))
2✔
1067
}
1068

1069
/// Phase 7d.3 — stages an HNSW index's page tree at `start_page`.
1070
/// Each leaf cell is a `KIND_HNSW` entry carrying one node's
1071
/// (node_id, layers). Returns `(root_page, next_free_page)`.
1072
///
1073
/// Tree shape is identical to `stage_index_btree` — chained leaves +
1074
/// optional interior layers. The slot directory binary-searches by
1075
/// node_id (which is the cell's "rowid" in `Cell::peek_rowid` terms),
1076
/// so reads can locate any node in O(log N) once 7d.4-or-later
1077
/// optimizes the load path to lazy-fetch instead of read-all.
1078
/// Today, `load_hnsw_nodes` reads the entire tree on open.
1079
fn stage_hnsw_btree(
1✔
1080
    pager: &mut Pager,
1081
    idx: &crate::sql::hnsw::HnswIndex,
1082
    start_page: u32,
1083
) -> Result<(u32, u32)> {
1084
    let (leaves, mut next_free_page) = stage_hnsw_leaves(pager, idx, start_page)?;
1✔
1085
    if leaves.len() == 1 {
2✔
1086
        return Ok((leaves[0].0, next_free_page));
2✔
1087
    }
1088
    let mut level: Vec<(u32, i64)> = leaves;
×
1089
    while level.len() > 1 {
×
1090
        let (next_level, new_next_free) = stage_interior_level(pager, &level, next_free_page)?;
×
1091
        next_free_page = new_next_free;
×
1092
        level = next_level;
×
1093
    }
1094
    Ok((level[0].0, next_free_page))
×
1095
}
1096

1097
/// Packs HNSW nodes into a sibling-chained run of `TableLeaf` pages.
1098
/// `serialize_nodes` already returns nodes in ascending node_id order,
1099
/// so the slot directory's rowid ordering stays valid.
1100
fn stage_hnsw_leaves(
1✔
1101
    pager: &mut Pager,
1102
    idx: &crate::sql::hnsw::HnswIndex,
1103
    start_page: u32,
1104
) -> Result<(Vec<(u32, i64)>, u32)> {
1105
    use crate::sql::pager::hnsw_cell::HnswNodeCell;
1106

1107
    let mut leaves: Vec<(u32, i64)> = Vec::new();
1✔
1108
    let mut current_leaf = TablePage::empty();
2✔
1109
    let mut current_leaf_page = start_page;
1✔
1110
    let mut current_max_rowid: Option<i64> = None;
1✔
1111
    let mut next_free_page = start_page + 1;
1✔
1112

1113
    let serialized = idx.serialize_nodes();
1✔
1114

1115
    // Empty index → emit a single empty leaf page so the rootpage
1116
    // pointer in sqlrite_master stays nonzero (== "graph is persisted,
1117
    // it just happens to be empty"). load_hnsw_nodes is fine with an
1118
    // empty leaf — slot_count() returns 0.
1119
    for (node_id, layers) in serialized {
2✔
1120
        let cell = HnswNodeCell::new(node_id, layers);
1✔
1121
        let entry_bytes = cell.encode()?;
2✔
1122

1123
        if !current_leaf.would_fit(entry_bytes.len()) {
2✔
1124
            let next_leaf_page_num = next_free_page;
×
1125
            emit_leaf(pager, current_leaf_page, &current_leaf, next_leaf_page_num);
×
1126
            leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
×
1127
            current_leaf = TablePage::empty();
×
1128
            current_leaf_page = next_leaf_page_num;
×
1129
            next_free_page += 1;
×
1130

1131
            if !current_leaf.would_fit(entry_bytes.len()) {
×
1132
                return Err(SQLRiteError::Internal(format!(
×
1133
                    "HNSW node {node_id} cell of {} bytes exceeds empty-page capacity {}",
1134
                    entry_bytes.len(),
×
1135
                    current_leaf.free_space()
×
1136
                )));
1137
            }
1138
        }
1139
        current_leaf.insert_entry(node_id, &entry_bytes)?;
2✔
1140
        current_max_rowid = Some(node_id);
1✔
1141
    }
1142

1143
    emit_leaf(pager, current_leaf_page, &current_leaf, 0);
1✔
1144
    leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1✔
1145
    Ok((leaves, next_free_page))
1✔
1146
}
1147

1148
fn load_table_rows(pager: &Pager, table: &mut Table, root_page: u32) -> Result<()> {
2✔
1149
    let first_leaf = find_leftmost_leaf(pager, root_page)?;
2✔
1150
    let mut current = first_leaf;
2✔
1151
    while current != 0 {
2✔
1152
        let page_buf = pager
2✔
1153
            .read_page(current)
2✔
1154
            .ok_or_else(|| SQLRiteError::Internal(format!("missing leaf page {current}")))?;
2✔
1155
        if page_buf[0] != PageType::TableLeaf as u8 {
2✔
1156
            return Err(SQLRiteError::Internal(format!(
×
1157
                "page {current} tagged {} but expected TableLeaf",
1158
                page_buf[0]
1159
            )));
1160
        }
1161
        let next_leaf = u32::from_le_bytes(page_buf[1..5].try_into().unwrap());
2✔
1162
        let payload: &[u8; PAYLOAD_PER_PAGE] = (&page_buf[PAGE_HEADER_SIZE..])
4✔
1163
            .try_into()
2✔
1164
            .map_err(|_| SQLRiteError::Internal("leaf payload slice size".to_string()))?;
2✔
1165
        let leaf = TablePage::from_bytes(payload);
2✔
1166

1167
        for slot in 0..leaf.slot_count() {
6✔
1168
            let entry = leaf.entry_at(slot)?;
4✔
1169
            let cell = match entry {
2✔
1170
                PagedEntry::Local(c) => c,
2✔
1171
                PagedEntry::Overflow(r) => {
1✔
1172
                    let body_bytes =
2✔
1173
                        read_overflow_chain(pager, r.first_overflow_page, r.total_body_len)?;
1174
                    let (c, _) = Cell::decode(&body_bytes, 0)?;
2✔
1175
                    c
1✔
1176
                }
1177
            };
1178
            table.restore_row(cell.rowid, cell.values)?;
4✔
1179
        }
1180
        current = next_leaf;
2✔
1181
    }
1182
    Ok(())
2✔
1183
}
1184

1185
/// Descends from `root_page` through `InteriorNode` pages, always taking
1186
/// the leftmost child, until a `TableLeaf` is reached. Returns that leaf's
1187
/// page number. A root that's already a leaf is returned as-is.
1188
fn find_leftmost_leaf(pager: &Pager, root_page: u32) -> Result<u32> {
2✔
1189
    let mut current = root_page;
2✔
1190
    loop {
1191
        let page_buf = pager.read_page(current).ok_or_else(|| {
2✔
1192
            SQLRiteError::Internal(format!("missing page {current} during tree descent"))
×
1193
        })?;
1194
        match page_buf[0] {
1195
            t if t == PageType::TableLeaf as u8 => return Ok(current),
4✔
1196
            t if t == PageType::InteriorNode as u8 => {
2✔
1197
                let payload: &[u8; PAYLOAD_PER_PAGE] =
1✔
1198
                    (&page_buf[PAGE_HEADER_SIZE..]).try_into().map_err(|_| {
1199
                        SQLRiteError::Internal("interior payload slice size".to_string())
×
1200
                    })?;
1201
                let interior = InteriorPage::from_bytes(payload);
1✔
1202
                current = interior.leftmost_child()?;
2✔
1203
            }
1204
            other => {
×
1205
                return Err(SQLRiteError::Internal(format!(
×
1206
                    "unexpected page type {other} during tree descent at page {current}"
1207
                )));
1208
            }
1209
        }
1210
    }
1211
}
1212

1213
/// Stages a table's B-Tree starting at `start_page`. Returns
1214
/// `(root_page, next_free_page)`. Builds bottom-up:
1215
///
1216
/// 1. Pack all row cells into `TableLeaf` pages, chaining them via each
1217
///    leaf's `next_page` sibling pointer (for fast sequential scans).
1218
/// 2. If the table fits in a single leaf, that leaf is the root.
1219
/// 3. Otherwise, group leaves into `InteriorNode` pages; recurse up the
1220
///    tree until one root remains.
1221
///
1222
/// Deterministic: same in-memory rows → same pages at same offsets, so
1223
/// the Pager's diff commit still skips unchanged tables.
1224
fn stage_table_btree(pager: &mut Pager, table: &Table, start_page: u32) -> Result<(u32, u32)> {
2✔
1225
    let (leaves, mut next_free_page) = stage_leaves(pager, table, start_page)?;
2✔
1226
    if leaves.len() == 1 {
4✔
1227
        return Ok((leaves[0].0, next_free_page));
4✔
1228
    }
1229
    let mut level: Vec<(u32, i64)> = leaves;
1✔
1230
    while level.len() > 1 {
4✔
1231
        let (next_level, new_next_free) = stage_interior_level(pager, &level, next_free_page)?;
2✔
1232
        next_free_page = new_next_free;
1✔
1233
        level = next_level;
2✔
1234
    }
1235
    Ok((level[0].0, next_free_page))
2✔
1236
}
1237

1238
/// Packs the table's rows into a sibling-linked chain of `TableLeaf` pages.
1239
/// Returns each leaf's `(page_number, max_rowid)` (used by the next level
1240
/// up to build divider cells) and the first free page after the chain
1241
/// including any overflow pages allocated for oversized cells.
1242
fn stage_leaves(
2✔
1243
    pager: &mut Pager,
1244
    table: &Table,
1245
    start_page: u32,
1246
) -> Result<(Vec<(u32, i64)>, u32)> {
1247
    let mut leaves: Vec<(u32, i64)> = Vec::new();
2✔
1248
    let mut current_leaf = TablePage::empty();
4✔
1249
    let mut current_leaf_page = start_page;
2✔
1250
    let mut current_max_rowid: Option<i64> = None;
2✔
1251
    let mut next_free_page = start_page + 1;
2✔
1252

1253
    for rowid in table.rowids() {
6✔
1254
        let entry_bytes = build_row_entry(pager, table, rowid, &mut next_free_page)?;
4✔
1255

1256
        if !current_leaf.would_fit(entry_bytes.len()) {
4✔
1257
            // Commit the current leaf. Its sibling next_page is the page
1258
            // number where the new leaf will go — which is next_free_page
1259
            // right now (no overflow pages have been allocated between
1260
            // this decision and the new leaf's allocation below).
1261
            let next_leaf_page_num = next_free_page;
1✔
1262
            emit_leaf(pager, current_leaf_page, &current_leaf, next_leaf_page_num);
1✔
1263
            leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
1✔
1264
            current_leaf = TablePage::empty();
1✔
1265
            current_leaf_page = next_leaf_page_num;
1✔
1266
            next_free_page += 1;
1✔
1267
            // current_max_rowid is reassigned by the insert below; no need
1268
            // to zero it out here.
1269

1270
            if !current_leaf.would_fit(entry_bytes.len()) {
2✔
1271
                return Err(SQLRiteError::Internal(format!(
×
1272
                    "entry of {} bytes exceeds empty-page capacity {}",
1273
                    entry_bytes.len(),
×
1274
                    current_leaf.free_space()
×
1275
                )));
1276
            }
1277
        }
1278
        current_leaf.insert_entry(rowid, &entry_bytes)?;
4✔
1279
        current_max_rowid = Some(rowid);
2✔
1280
    }
1281

1282
    // Final leaf: sibling next_page = 0 (end of chain).
1283
    emit_leaf(pager, current_leaf_page, &current_leaf, 0);
2✔
1284
    leaves.push((current_leaf_page, current_max_rowid.unwrap_or(i64::MIN)));
2✔
1285
    Ok((leaves, next_free_page))
2✔
1286
}
1287

1288
/// Encodes a single row's on-leaf entry — either the local cell bytes, or
1289
/// an `OverflowRef` pointing at a freshly-allocated overflow chain if the
1290
/// encoded cell exceeded the inline threshold. Advances `next_free_page`
1291
/// past any overflow pages used.
1292
fn build_row_entry(
2✔
1293
    pager: &mut Pager,
1294
    table: &Table,
1295
    rowid: i64,
1296
    next_free_page: &mut u32,
1297
) -> Result<Vec<u8>> {
1298
    let values = table.extract_row(rowid);
2✔
1299
    let local_cell = Cell::new(rowid, values);
2✔
1300
    let local_bytes = local_cell.encode()?;
4✔
1301
    if local_bytes.len() > OVERFLOW_THRESHOLD {
7✔
1302
        let overflow_start = *next_free_page;
1✔
1303
        *next_free_page = write_overflow_chain(pager, &local_bytes, overflow_start)?;
2✔
1304
        Ok(OverflowRef {
2✔
1305
            rowid,
1306
            total_body_len: local_bytes.len() as u64,
1✔
1307
            first_overflow_page: overflow_start,
1308
        }
1309
        .encode())
1✔
1310
    } else {
1311
        Ok(local_bytes)
2✔
1312
    }
1313
}
1314

1315
/// Builds one level of `InteriorNode` pages above the given children.
1316
/// Each interior packs as many dividers as will fit; the last child
1317
/// assigned to an interior becomes its `rightmost_child`. Returns the
1318
/// emitted interior pages as `(page_number, max_rowid_in_subtree)` so the
1319
/// next level can build on top of them.
1320
fn stage_interior_level(
1✔
1321
    pager: &mut Pager,
1322
    children: &[(u32, i64)],
1323
    start_page: u32,
1324
) -> Result<(Vec<(u32, i64)>, u32)> {
1325
    let mut next_level: Vec<(u32, i64)> = Vec::new();
1✔
1326
    let mut next_free_page = start_page;
1✔
1327
    let mut idx = 0usize;
1✔
1328

1329
    while idx < children.len() {
1✔
1330
        let interior_page_num = next_free_page;
1✔
1331
        next_free_page += 1;
2✔
1332

1333
        // Seed the interior with the first unassigned child as its
1334
        // rightmost. As we add more children, the previous rightmost
1335
        // graduates to being a divider and the new arrival takes over
1336
        // as rightmost.
1337
        let (mut rightmost_child_page, mut rightmost_child_max) = children[idx];
2✔
1338
        idx += 1;
2✔
1339
        let mut interior = InteriorPage::empty(rightmost_child_page);
2✔
1340

1341
        while idx < children.len() {
1✔
1342
            let new_divider_cell = InteriorCell {
1343
                divider_rowid: rightmost_child_max,
1344
                child_page: rightmost_child_page,
1345
            };
1346
            let new_divider_bytes = new_divider_cell.encode();
1✔
1347
            if !interior.would_fit(new_divider_bytes.len()) {
2✔
1348
                break;
1349
            }
1350
            interior.insert_divider(rightmost_child_max, rightmost_child_page)?;
2✔
1351
            let (next_child_page, next_child_max) = children[idx];
1✔
1352
            interior.set_rightmost_child(next_child_page);
1✔
1353
            rightmost_child_page = next_child_page;
1✔
1354
            rightmost_child_max = next_child_max;
1✔
1355
            idx += 1;
1✔
1356
        }
1357

1358
        emit_interior(pager, interior_page_num, &interior);
1✔
1359
        next_level.push((interior_page_num, rightmost_child_max));
1✔
1360
    }
1361

1362
    Ok((next_level, next_free_page))
1✔
1363
}
1364

1365
/// Wraps a `TablePage` in the 7-byte page header and hands it to the pager.
1366
fn emit_leaf(pager: &mut Pager, page_num: u32, leaf: &TablePage, next_leaf: u32) {
2✔
1367
    let mut buf = [0u8; PAGE_SIZE];
2✔
1368
    buf[0] = PageType::TableLeaf as u8;
2✔
1369
    buf[1..5].copy_from_slice(&next_leaf.to_le_bytes());
2✔
1370
    // For leaf pages the legacy `payload_len` field isn't used — the slot
1371
    // directory self-describes. Zero it by convention.
1372
    buf[5..7].copy_from_slice(&0u16.to_le_bytes());
2✔
1373
    buf[PAGE_HEADER_SIZE..].copy_from_slice(leaf.as_bytes());
2✔
1374
    pager.stage_page(page_num, buf);
2✔
1375
}
1376

1377
/// Wraps an `InteriorPage` in the 7-byte page header. Interior pages
1378
/// don't use `next_page` (there's no sibling chain between interiors);
1379
/// `payload_len` is also unused (the slot directory self-describes).
1380
fn emit_interior(pager: &mut Pager, page_num: u32, interior: &InteriorPage) {
1✔
1381
    let mut buf = [0u8; PAGE_SIZE];
1✔
1382
    buf[0] = PageType::InteriorNode as u8;
1✔
1383
    buf[1..5].copy_from_slice(&0u32.to_le_bytes());
1✔
1384
    buf[5..7].copy_from_slice(&0u16.to_le_bytes());
1✔
1385
    buf[PAGE_HEADER_SIZE..].copy_from_slice(interior.as_bytes());
1✔
1386
    pager.stage_page(page_num, buf);
1✔
1387
}
1388

1389
#[cfg(test)]
1390
mod tests {
1391
    use super::*;
1392
    use crate::sql::process_command;
1393

1394
    fn seed_db() -> Database {
1✔
1395
        let mut db = Database::new("test".to_string());
1✔
1396
        process_command(
1397
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, age INTEGER);",
1398
            &mut db,
1399
        )
1400
        .unwrap();
1401
        process_command(
1402
            "INSERT INTO users (name, age) VALUES ('alice', 30);",
1403
            &mut db,
1404
        )
1405
        .unwrap();
1406
        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db).unwrap();
1✔
1407
        process_command(
1408
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1409
            &mut db,
1410
        )
1411
        .unwrap();
1412
        process_command("INSERT INTO notes (body) VALUES ('hello');", &mut db).unwrap();
1✔
1413
        db
1✔
1414
    }
1415

1416
    fn tmp_path(name: &str) -> std::path::PathBuf {
1✔
1417
        let mut p = std::env::temp_dir();
1✔
1418
        let pid = std::process::id();
2✔
1419
        let nanos = std::time::SystemTime::now()
2✔
1420
            .duration_since(std::time::UNIX_EPOCH)
1✔
1421
            .map(|d| d.as_nanos())
3✔
1422
            .unwrap_or(0);
1423
        p.push(format!("sqlrite-{pid}-{nanos}-{name}.sqlrite"));
1✔
1424
        p
1✔
1425
    }
1426

1427
    /// Phase 4c: every .sqlrite has a `-wal` sidecar now. Delete both so
1428
    /// `/tmp` doesn't accumulate orphan WALs across test runs.
1429
    fn cleanup(path: &std::path::Path) {
1✔
1430
        let _ = std::fs::remove_file(path);
1✔
1431
        let mut wal = path.as_os_str().to_owned();
1✔
1432
        wal.push("-wal");
1✔
1433
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1434
    }
1435

1436
    #[test]
1437
    fn round_trip_preserves_schema_and_data() {
4✔
1438
        let path = tmp_path("roundtrip");
1✔
1439
        let mut db = seed_db();
1✔
1440
        save_database(&mut db, &path).expect("save");
2✔
1441

1442
        let loaded = open_database(&path, "test".to_string()).expect("open");
1✔
1443
        assert_eq!(loaded.tables.len(), 2);
2✔
1444

1445
        let users = loaded.get_table("users".to_string()).expect("users table");
1✔
1446
        assert_eq!(users.columns.len(), 3);
1✔
1447
        let rowids = users.rowids();
1✔
1448
        assert_eq!(rowids.len(), 2);
2✔
1449
        let names: Vec<String> = rowids
1✔
1450
            .iter()
1451
            .filter_map(|r| match users.get_value("name", *r) {
3✔
1452
                Some(Value::Text(s)) => Some(s),
1✔
1453
                _ => None,
×
1454
            })
1455
            .collect();
1456
        assert!(names.contains(&"alice".to_string()));
2✔
1457
        assert!(names.contains(&"bob".to_string()));
1✔
1458

1459
        let notes = loaded.get_table("notes".to_string()).expect("notes table");
1✔
1460
        assert_eq!(notes.rowids().len(), 1);
1✔
1461

1462
        cleanup(&path);
1✔
1463
    }
1464

1465
    // -----------------------------------------------------------------
1466
    // Phase 7a — VECTOR(N) save / reopen round-trip
1467
    // -----------------------------------------------------------------
1468

1469
    #[test]
1470
    fn round_trip_preserves_vector_column() {
3✔
1471
        let path = tmp_path("vec_roundtrip");
1✔
1472

1473
        // Build, populate, save.
1474
        {
1475
            let mut db = Database::new("test".to_string());
2✔
1476
            process_command(
1477
                "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1478
                &mut db,
1479
            )
1480
            .unwrap();
1481
            process_command(
1482
                "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1483
                &mut db,
1484
            )
1485
            .unwrap();
1486
            process_command(
1487
                "INSERT INTO docs (embedding) VALUES ([1.5, -2.0, 3.5]);",
1488
                &mut db,
1489
            )
1490
            .unwrap();
1491
            save_database(&mut db, &path).expect("save");
1✔
1492
        } // db drops → its exclusive lock releases before reopen.
1✔
1493

1494
        // Reopen and verify schema + data both round-tripped.
1495
        let loaded = open_database(&path, "test".to_string()).expect("open");
1✔
1496
        let docs = loaded.get_table("docs".to_string()).expect("docs table");
2✔
1497

1498
        // Schema preserved: column is still VECTOR(3).
1499
        let embedding_col = docs
3✔
1500
            .columns
1501
            .iter()
1502
            .find(|c| c.column_name == "embedding")
3✔
1503
            .expect("embedding column");
1504
        assert!(
×
1505
            matches!(embedding_col.datatype, DataType::Vector(3)),
1✔
1506
            "expected DataType::Vector(3) after round-trip, got {:?}",
1507
            embedding_col.datatype
1508
        );
1509

1510
        // Data preserved: both vectors still readable bit-for-bit.
1511
        let mut rows: Vec<Vec<f32>> = docs
1✔
1512
            .rowids()
1513
            .iter()
1514
            .filter_map(|r| match docs.get_value("embedding", *r) {
3✔
1515
                Some(Value::Vector(v)) => Some(v),
1✔
1516
                _ => None,
×
1517
            })
1518
            .collect();
1519
        rows.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
3✔
1520
        assert_eq!(rows.len(), 2);
1✔
1521
        assert_eq!(rows[0], vec![0.1f32, 0.2, 0.3]);
1✔
1522
        assert_eq!(rows[1], vec![1.5f32, -2.0, 3.5]);
1✔
1523

1524
        cleanup(&path);
1✔
1525
    }
1526

1527
    #[test]
1528
    fn round_trip_preserves_json_column() {
4✔
1529
        // Phase 7e — JSON columns are stored as Text under the hood with
1530
        // INSERT-time validation. Save + reopen should preserve the
1531
        // schema (DataType::Json) and the underlying text bytes; a
1532
        // post-reopen json_extract should still resolve paths correctly.
1533
        let path = tmp_path("json_roundtrip");
1✔
1534

1535
        {
1536
            let mut db = Database::new("test".to_string());
2✔
1537
            process_command(
1538
                "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
1539
                &mut db,
1540
            )
1541
            .unwrap();
1542
            process_command(
1543
                r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "tags": ["rust","sql"]}');"#,
1544
                &mut db,
1545
            )
1546
            .unwrap();
1547
            save_database(&mut db, &path).expect("save");
1✔
1548
        }
1549

1550
        let mut loaded = open_database(&path, "test".to_string()).expect("open");
1✔
1551
        let docs = loaded.get_table("docs".to_string()).expect("docs");
2✔
1552

1553
        // Schema: column declared as JSON, restored with the same type.
1554
        let payload_col = docs
3✔
1555
            .columns
1556
            .iter()
1557
            .find(|c| c.column_name == "payload")
3✔
1558
            .unwrap();
1559
        assert!(
×
1560
            matches!(payload_col.datatype, DataType::Json),
1✔
1561
            "expected DataType::Json, got {:?}",
1562
            payload_col.datatype
1563
        );
1564

1565
        // json_extract works against the reopened data — exercises the
1566
        // full Text-storage + serde_json::from_str path post-reopen.
1567
        let resp = process_command(
1568
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
1569
            &mut loaded,
1570
        )
1571
        .expect("select via json_extract after reopen");
1572
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1573

1574
        cleanup(&path);
2✔
1575
    }
1576

1577
    #[test]
1578
    fn round_trip_rebuilds_hnsw_index_from_create_sql() {
3✔
1579
        // Phase 7d.3: HNSW indexes now persist their graph as cell-encoded
1580
        // pages. After save+reopen the index entry reattaches with the
1581
        // same column + same node count, loaded directly from disk
1582
        // instead of re-walking rows.
1583
        let path = tmp_path("hnsw_roundtrip");
1✔
1584

1585
        // Build, populate, index, save.
1586
        {
1587
            let mut db = Database::new("test".to_string());
2✔
1588
            process_command(
1589
                "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1590
                &mut db,
1591
            )
1592
            .unwrap();
1593
            for v in &[
1✔
1594
                "[1.0, 0.0]",
1595
                "[2.0, 0.0]",
1596
                "[0.0, 3.0]",
1597
                "[1.0, 4.0]",
1598
                "[10.0, 10.0]",
1599
            ] {
1600
                process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1601
            }
1602
            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1✔
1603
            save_database(&mut db, &path).expect("save");
1✔
1604
        } // db drops → exclusive lock releases.
1✔
1605

1606
        // Reopen and verify the index reattached, with the same name +
1607
        // column + populated graph.
1608
        let mut loaded = open_database(&path, "test".to_string()).expect("open");
1✔
1609
        {
1610
            let table = loaded.get_table("docs".to_string()).expect("docs");
2✔
1611
            assert_eq!(table.hnsw_indexes.len(), 1, "HNSW index should reattach");
1✔
1612
            let entry = &table.hnsw_indexes[0];
2✔
1613
            assert_eq!(entry.name, "ix_e");
1✔
1614
            assert_eq!(entry.column_name, "e");
1✔
1615
            assert_eq!(entry.index.len(), 5, "loaded graph should hold all 5 rows");
1✔
1616
            assert!(
×
1617
                !entry.needs_rebuild,
1✔
1618
                "fresh load should not be marked dirty"
1619
            );
1620
        }
1621

1622
        // Quick functional check: KNN query through the loaded index
1623
        // returns results.
1624
        let resp = process_command(
1625
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1626
            &mut loaded,
1627
        )
1628
        .unwrap();
1629
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1630

1631
        cleanup(&path);
2✔
1632
    }
1633

1634
    #[test]
1635
    fn round_trip_rebuilds_fts_index_from_create_sql() {
3✔
1636
        // Phase 8b — FTS indexes don't yet persist their posting lists
1637
        // (Phase 8c does that). On open, sqlrite_master records the
1638
        // CREATE INDEX SQL and `rebuild_fts_index` replays it through
1639
        // `execute_create_index`, walking current rows.
1640
        let path = tmp_path("fts_roundtrip");
1✔
1641

1642
        {
1643
            let mut db = Database::new("test".to_string());
2✔
1644
            process_command(
1645
                "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1646
                &mut db,
1647
            )
1648
            .unwrap();
1649
            for body in &[
1✔
1650
                "rust embedded database",
1651
                "rust web framework",
1652
                "go embedded systems",
1653
                "python web framework",
1654
                "rust rust embedded power",
1655
            ] {
1656
                process_command(
1657
                    &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
1658
                    &mut db,
1659
                )
1660
                .unwrap();
1661
            }
1662
            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1✔
1663
            save_database(&mut db, &path).expect("save");
1✔
1664
        } // db drops → exclusive lock releases.
1✔
1665

1666
        let mut loaded = open_database(&path, "test".to_string()).expect("open");
1✔
1667
        {
1668
            let table = loaded.get_table("docs".to_string()).expect("docs");
2✔
1669
            assert_eq!(table.fts_indexes.len(), 1, "FTS index should reattach");
1✔
1670
            let entry = &table.fts_indexes[0];
2✔
1671
            assert_eq!(entry.name, "ix_body");
1✔
1672
            assert_eq!(entry.column_name, "body");
1✔
1673
            assert_eq!(
1✔
1674
                entry.index.len(),
1✔
1675
                5,
1676
                "rebuilt posting list should hold all 5 rows"
1677
            );
1678
            assert!(!entry.needs_rebuild);
1✔
1679
        }
1680

1681
        // Functional smoke: an FTS query through the reloaded index
1682
        // returns the expected hit count.
1683
        let resp = process_command(
1684
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1685
            &mut loaded,
1686
        )
1687
        .unwrap();
1688
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1689

1690
        cleanup(&path);
2✔
1691
    }
1692

1693
    #[test]
1694
    fn delete_then_save_then_reopen_excludes_deleted_node_from_fts() {
3✔
1695
        // Phase 8b — DELETE marks the FTS index dirty; save rebuilds it
1696
        // from current rows; reopen replays the CREATE INDEX SQL against
1697
        // the post-delete row set. The deleted rowid must not surface
1698
        // in `fts_match` results post-reopen.
1699
        let path = tmp_path("fts_delete_rebuild");
1✔
1700
        let mut db = Database::new("test".to_string());
2✔
1701
        process_command(
1702
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1703
            &mut db,
1704
        )
1705
        .unwrap();
1706
        for body in &[
1✔
1707
            "rust embedded",
1708
            "rust framework",
1709
            "go embedded",
1710
            "python web",
1711
        ] {
1712
            process_command(
1713
                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
1714
                &mut db,
1715
            )
1716
            .unwrap();
1717
        }
1718
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1✔
1719

1720
        // Delete row 1 ('rust embedded'); save (rebuild fires); reopen.
1721
        process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1722
        save_database(&mut db, &path).expect("save");
1✔
1723
        drop(db);
1✔
1724

1725
        let mut loaded = open_database(&path, "test".to_string()).expect("open");
1✔
1726
        let resp = process_command(
1727
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1728
            &mut loaded,
1729
        )
1730
        .unwrap();
1731
        // Pre-delete: 2 rows ('rust embedded', 'rust framework') had
1732
        // 'rust'. Post-delete: only id=2 remains.
1733
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1734

1735
        cleanup(&path);
2✔
1736
    }
1737

1738
    #[test]
1739
    fn delete_then_save_then_reopen_excludes_deleted_node_from_hnsw() {
3✔
1740
        // Phase 7d.3 — DELETE marks HNSW dirty; save rebuilds it from
1741
        // current rows + serializes; reopen loads the post-delete graph.
1742
        // After all that, the deleted rowid must NOT come back from a
1743
        // KNN query.
1744
        let path = tmp_path("hnsw_delete_rebuild");
1✔
1745
        let mut db = Database::new("test".to_string());
2✔
1746
        process_command(
1747
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1748
            &mut db,
1749
        )
1750
        .unwrap();
1751
        for v in &["[1.0, 0.0]", "[2.0, 0.0]", "[3.0, 0.0]", "[4.0, 0.0]"] {
1✔
1752
            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1753
        }
1754
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
1✔
1755

1756
        // Delete row 1 (the closest match to [0.5, 0.0]).
1757
        process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1758
        // Confirm it marked dirty.
1759
        let dirty_before_save = db.tables["docs"].hnsw_indexes[0].needs_rebuild;
1✔
1760
        assert!(dirty_before_save, "DELETE should mark dirty");
1✔
1761

1762
        save_database(&mut db, &path).expect("save");
2✔
1763
        // Confirm save cleared the dirty flag.
1764
        let dirty_after_save = db.tables["docs"].hnsw_indexes[0].needs_rebuild;
1✔
1765
        assert!(!dirty_after_save, "save should clear dirty");
1✔
1766
        drop(db);
1✔
1767

1768
        // Reopen, query for the closest match. Row 1 is gone; row 2
1769
        // (id=2, vector [2.0, 0.0]) should now be the nearest.
1770
        let loaded = open_database(&path, "test".to_string()).expect("open");
1✔
1771
        let docs = loaded.get_table("docs".to_string()).expect("docs");
2✔
1772

1773
        // Row 1 must not appear in any storage anymore.
1774
        assert!(
1✔
1775
            !docs.rowids().contains(&1),
2✔
1776
            "deleted row 1 should not be in row storage"
1777
        );
1778
        assert_eq!(docs.rowids().len(), 3, "should have 3 surviving rows");
1✔
1779

1780
        // The HNSW index must also have shed the deleted node.
1781
        assert_eq!(
1✔
1782
            docs.hnsw_indexes[0].index.len(),
1✔
1783
            3,
1784
            "HNSW graph should have shed the deleted node"
1785
        );
1786

1787
        cleanup(&path);
2✔
1788
    }
1789

1790
    #[test]
1791
    fn round_trip_survives_writes_after_load() {
3✔
1792
        let path = tmp_path("after_load");
1✔
1793
        save_database(&mut seed_db(), &path).unwrap();
2✔
1794

1795
        {
1796
            let mut db = open_database(&path, "test".to_string()).unwrap();
1✔
1797
            process_command(
1798
                "INSERT INTO users (name, age) VALUES ('carol', 40);",
1799
                &mut db,
1800
            )
1801
            .unwrap();
1802
            save_database(&mut db, &path).unwrap();
1✔
1803
        } // db drops → its exclusive lock releases before we reopen below.
1✔
1804

1805
        let db2 = open_database(&path, "test".to_string()).unwrap();
1✔
1806
        let users = db2.get_table("users".to_string()).unwrap();
2✔
1807
        assert_eq!(users.rowids().len(), 3);
1✔
1808

1809
        cleanup(&path);
1✔
1810
    }
1811

1812
    #[test]
1813
    fn open_rejects_garbage_file() {
3✔
1814
        let path = tmp_path("bad");
1✔
1815
        std::fs::write(&path, b"not a sqlrite database, just bytes").unwrap();
2✔
1816
        let result = open_database(&path, "x".to_string());
1✔
1817
        assert!(result.is_err());
2✔
1818
        cleanup(&path);
1✔
1819
    }
1820

1821
    #[test]
1822
    fn many_small_rows_spread_across_leaves() {
3✔
1823
        let path = tmp_path("many_rows");
1✔
1824
        let mut db = Database::new("big".to_string());
2✔
1825
        process_command(
1826
            "CREATE TABLE things (id INTEGER PRIMARY KEY, data TEXT);",
1827
            &mut db,
1828
        )
1829
        .unwrap();
1830
        for i in 0..200 {
1✔
1831
            let body = "x".repeat(200);
1✔
1832
            let q = format!("INSERT INTO things (data) VALUES ('row-{i}-{body}');");
2✔
1833
            process_command(&q, &mut db).unwrap();
2✔
1834
        }
1835
        save_database(&mut db, &path).unwrap();
1✔
1836
        let loaded = open_database(&path, "big".to_string()).unwrap();
1✔
1837
        let things = loaded.get_table("things".to_string()).unwrap();
2✔
1838
        assert_eq!(things.rowids().len(), 200);
1✔
1839
        cleanup(&path);
1✔
1840
    }
1841

1842
    #[test]
1843
    fn huge_row_goes_through_overflow() {
3✔
1844
        let path = tmp_path("overflow_row");
1✔
1845
        let mut db = Database::new("big".to_string());
2✔
1846
        process_command(
1847
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1848
            &mut db,
1849
        )
1850
        .unwrap();
1851
        let body = "A".repeat(10_000);
1✔
1852
        process_command(
1853
            &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
1854
            &mut db,
1855
        )
1856
        .unwrap();
1857
        save_database(&mut db, &path).unwrap();
1✔
1858

1859
        let loaded = open_database(&path, "big".to_string()).unwrap();
1✔
1860
        let docs = loaded.get_table("docs".to_string()).unwrap();
2✔
1861
        let rowids = docs.rowids();
1✔
1862
        assert_eq!(rowids.len(), 1);
2✔
1863
        let stored = docs.get_value("body", rowids[0]);
1✔
1864
        match stored {
1✔
1865
            Some(Value::Text(s)) => assert_eq!(s.len(), 10_000),
1✔
1866
            other => panic!("expected Text, got {other:?}"),
×
1867
        }
1868
        cleanup(&path);
1✔
1869
    }
1870

1871
    #[test]
1872
    fn create_sql_synthesis_round_trips() {
3✔
1873
        // Build a table via CREATE, then verify table_to_create_sql +
1874
        // parse_create_sql reproduce an equivalent column list.
1875
        let mut db = Database::new("x".to_string());
1✔
1876
        process_command(
1877
            "CREATE TABLE t (id INTEGER PRIMARY KEY, tag TEXT UNIQUE, note TEXT NOT NULL);",
1878
            &mut db,
1879
        )
1880
        .unwrap();
1881
        let t = db.get_table("t".to_string()).unwrap();
1✔
1882
        let sql = table_to_create_sql(t);
1✔
1883
        let (name, cols) = parse_create_sql(&sql).unwrap();
2✔
1884
        assert_eq!(name, "t");
2✔
1885
        assert_eq!(cols.len(), 3);
1✔
1886
        assert!(cols[0].is_pk);
1✔
1887
        assert!(cols[1].is_unique);
1✔
1888
        assert!(cols[2].not_null);
1✔
1889
    }
1890

1891
    #[test]
1892
    fn sqlrite_master_is_not_exposed_as_a_user_table() {
3✔
1893
        // After open, the public db.tables map should not list the master.
1894
        let path = tmp_path("no_master");
1✔
1895
        save_database(&mut seed_db(), &path).unwrap();
2✔
1896
        let loaded = open_database(&path, "x".to_string()).unwrap();
1✔
1897
        assert!(!loaded.tables.contains_key(MASTER_TABLE_NAME));
2✔
1898
        cleanup(&path);
2✔
1899
    }
1900

1901
    #[test]
1902
    fn multi_leaf_table_produces_an_interior_root() {
3✔
1903
        // 200 fat rows force the table into multiple leaves, which means
1904
        // save_database must build at least one InteriorNode above them.
1905
        // The test verifies the round-trip works and confirms the root is
1906
        // indeed an interior page (not a leaf) by reading the page type
1907
        // directly out of the open pager.
1908
        let path = tmp_path("multi_leaf_interior");
1✔
1909
        let mut db = Database::new("big".to_string());
2✔
1910
        process_command(
1911
            "CREATE TABLE things (id INTEGER PRIMARY KEY, data TEXT);",
1912
            &mut db,
1913
        )
1914
        .unwrap();
1915
        for i in 0..200 {
1✔
1916
            let body = "x".repeat(200);
1✔
1917
            let q = format!("INSERT INTO things (data) VALUES ('row-{i}-{body}');");
2✔
1918
            process_command(&q, &mut db).unwrap();
2✔
1919
        }
1920
        save_database(&mut db, &path).unwrap();
1✔
1921

1922
        // Confirm the round-trip preserved all 200 rows.
1923
        let loaded = open_database(&path, "big".to_string()).unwrap();
1✔
1924
        let things = loaded.get_table("things".to_string()).unwrap();
2✔
1925
        assert_eq!(things.rowids().len(), 200);
1✔
1926

1927
        // Peek at `things`'s root page via the pager attached to the
1928
        // loaded DB and check it's an InteriorNode, not a leaf.
1929
        let pager = loaded
2✔
1930
            .pager
1931
            .as_ref()
1932
            .expect("loaded DB should have a pager");
1933
        // sqlrite_master's row for `things` holds its root page. Easiest
1934
        // way to find it: walk the leaf chain by using find_leftmost_leaf
1935
        // and then hop one level up. Simpler: read the master, scan for
1936
        // the "things" row, look up rootpage.
1937
        let mut master = build_empty_master_table();
1✔
1938
        load_table_rows(pager, &mut master, pager.header().schema_root_page).unwrap();
2✔
1939
        let things_root = master
1✔
1940
            .rowids()
1941
            .into_iter()
1942
            .find_map(|r| match master.get_value("name", r) {
3✔
1943
                Some(Value::Text(s)) if s == "things" => match master.get_value("rootpage", r) {
3✔
1944
                    Some(Value::Integer(p)) => Some(p as u32),
1✔
1945
                    _ => None,
×
1946
                },
1947
                _ => None,
×
1948
            })
1949
            .expect("things should appear in sqlrite_master");
1950
        let root_buf = pager.read_page(things_root).unwrap();
1✔
1951
        assert_eq!(
1✔
1952
            root_buf[0],
1953
            PageType::InteriorNode as u8,
1954
            "expected a multi-leaf table to have an interior root, got tag {}",
1955
            root_buf[0]
×
1956
        );
1957

1958
        cleanup(&path);
2✔
1959
    }
1960

1961
    #[test]
1962
    fn explicit_index_persists_across_save_and_open() {
3✔
1963
        let path = tmp_path("idx_persist");
1✔
1964
        let mut db = Database::new("idx".to_string());
2✔
1965
        process_command(
1966
            "CREATE TABLE users (id INTEGER PRIMARY KEY, tag TEXT);",
1967
            &mut db,
1968
        )
1969
        .unwrap();
1970
        for i in 1..=5 {
1✔
1971
            let tag = if i % 2 == 0 { "odd" } else { "even" };
2✔
1972
            process_command(
1973
                &format!("INSERT INTO users (tag) VALUES ('{tag}');"),
1✔
1974
                &mut db,
1975
            )
1976
            .unwrap();
1977
        }
1978
        process_command("CREATE INDEX users_tag_idx ON users (tag);", &mut db).unwrap();
1✔
1979
        save_database(&mut db, &path).unwrap();
1✔
1980

1981
        let loaded = open_database(&path, "idx".to_string()).unwrap();
1✔
1982
        let users = loaded.get_table("users".to_string()).unwrap();
2✔
1983
        let idx = users
1✔
1984
            .index_by_name("users_tag_idx")
1985
            .expect("explicit index should survive save/open");
1986
        assert_eq!(idx.column_name, "tag");
1✔
1987
        assert!(!idx.is_unique);
1✔
1988
        // 5 rows: rowids 2, 4 are "odd" (i % 2 == 0 when i is 2 or 4) — 2 entries;
1989
        // rowids 1, 3, 5 are "even" (i % 2 != 0) — 3 entries.
1990
        let even_rowids = idx.lookup(&Value::Text("even".into()));
2✔
1991
        let odd_rowids = idx.lookup(&Value::Text("odd".into()));
1✔
1992
        assert_eq!(even_rowids.len(), 3);
1✔
1993
        assert_eq!(odd_rowids.len(), 2);
1✔
1994

1995
        cleanup(&path);
1✔
1996
    }
1997

1998
    #[test]
1999
    fn auto_indexes_for_unique_columns_survive_save_open() {
3✔
2000
        let path = tmp_path("auto_idx_persist");
1✔
2001
        let mut db = Database::new("a".to_string());
2✔
2002
        process_command(
2003
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE);",
2004
            &mut db,
2005
        )
2006
        .unwrap();
2007
        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1✔
2008
        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1✔
2009
        save_database(&mut db, &path).unwrap();
1✔
2010

2011
        let loaded = open_database(&path, "a".to_string()).unwrap();
1✔
2012
        let users = loaded.get_table("users".to_string()).unwrap();
2✔
2013
        // Every UNIQUE column auto-creates an index; the load path populated
2014
        // it from the persisted entries.
2015
        let auto_name = SecondaryIndex::auto_name("users", "email");
1✔
2016
        let idx = users
1✔
2017
            .index_by_name(&auto_name)
2✔
2018
            .expect("auto index should be restored");
2019
        assert!(idx.is_unique);
1✔
2020
        assert_eq!(idx.lookup(&Value::Text("a@x".into())).len(), 1);
1✔
2021
        assert_eq!(idx.lookup(&Value::Text("b@x".into())).len(), 1);
1✔
2022

2023
        cleanup(&path);
1✔
2024
    }
2025

2026
    #[test]
2027
    fn deep_tree_round_trips() {
3✔
2028
        // Force a 3-level tree by bypassing process_command (which prints
2029
        // the full table on every INSERT, making large bulk loads O(N^2)
2030
        // in I/O). We build the Table directly via restore_row.
2031
        use crate::sql::db::table::Column as TableColumn;
2032

2033
        let path = tmp_path("deep_tree");
1✔
2034
        let mut db = Database::new("deep".to_string());
2✔
2035
        let columns = vec![
3✔
2036
            TableColumn::new("id".into(), "integer".into(), true, true, true),
2✔
2037
            TableColumn::new("s".into(), "text".into(), false, true, false),
2✔
2038
        ];
2039
        let mut table = build_empty_table("t", columns, 0);
1✔
2040
        // ~900-byte rows → ~4 rows per leaf. 6000 rows → ~1500 leaves,
2041
        // which with interior fanout ~400 needs 2 interior levels (3-level
2042
        // tree total, counting leaves).
2043
        for i in 1..=6_000i64 {
2✔
2044
            let body = "q".repeat(900);
1✔
2045
            table
1✔
2046
                .restore_row(
2047
                    i,
2048
                    vec![
3✔
2049
                        Some(Value::Integer(i)),
1✔
2050
                        Some(Value::Text(format!("r-{i}-{body}"))),
2✔
2051
                    ],
2052
                )
2053
                .unwrap();
2054
        }
2055
        db.tables.insert("t".to_string(), table);
1✔
2056
        save_database(&mut db, &path).unwrap();
1✔
2057

2058
        let loaded = open_database(&path, "deep".to_string()).unwrap();
1✔
2059
        let t = loaded.get_table("t".to_string()).unwrap();
2✔
2060
        assert_eq!(t.rowids().len(), 6_000);
1✔
2061

2062
        // Confirm the tree actually grew past 2 levels — i.e., the root's
2063
        // leftmost child is itself an interior page, not a leaf.
2064
        let pager = loaded.pager.as_ref().unwrap();
1✔
2065
        let mut master = build_empty_master_table();
1✔
2066
        load_table_rows(pager, &mut master, pager.header().schema_root_page).unwrap();
2✔
2067
        let t_root = master
1✔
2068
            .rowids()
2069
            .into_iter()
2070
            .find_map(|r| match master.get_value("name", r) {
3✔
2071
                Some(Value::Text(s)) if s == "t" => match master.get_value("rootpage", r) {
3✔
2072
                    Some(Value::Integer(p)) => Some(p as u32),
1✔
2073
                    _ => None,
×
2074
                },
2075
                _ => None,
×
2076
            })
2077
            .expect("t in sqlrite_master");
2078
        let root_buf = pager.read_page(t_root).unwrap();
1✔
2079
        assert_eq!(root_buf[0], PageType::InteriorNode as u8);
1✔
2080
        let root_payload: &[u8; PAYLOAD_PER_PAGE] =
1✔
2081
            (&root_buf[PAGE_HEADER_SIZE..]).try_into().unwrap();
2082
        let root_interior = InteriorPage::from_bytes(root_payload);
1✔
2083
        let child = root_interior.leftmost_child().unwrap();
2✔
2084
        let child_buf = pager.read_page(child).unwrap();
1✔
2085
        assert_eq!(
1✔
2086
            child_buf[0],
2087
            PageType::InteriorNode as u8,
2088
            "expected 3-level tree: root's leftmost child should also be InteriorNode",
2089
        );
2090

2091
        cleanup(&path);
2✔
2092
    }
2093
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc