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

joaoh82 / rust_sqlite / 25284563739

03 May 2026 04:29PM UTC coverage: 58.704% (+0.9%) from 57.852%
25284563739

push

github

web-flow
feat(fts): 8b — SQL surface for full-text search (#79)

Phase 8 sub-phase 8b per docs/phase-8-plan.md. Wires the standalone
FTS algorithms (8a) into the SQL executor end-to-end. Mirrors the
Phase 7d.2 HNSW integration shape across every touchpoint.

User-visible surface:

  CREATE INDEX ix ON docs USING fts (body);

  -- predicate: row contains any query term?
  SELECT id FROM docs WHERE fts_match(body, 'rust embedded');

  -- BM25 ranking, top-k:
  SELECT id FROM docs
   WHERE fts_match(body, 'rust embedded')
   ORDER BY bm25_score(body, 'rust embedded') DESC
   LIMIT 10;

Engine plumbing:

- src/sql/db/table.rs:
  - new FtsIndexEntry { name, column_name, index: PostingList,
    needs_rebuild }, mirroring HnswIndexEntry field-for-field
  - Table::fts_indexes alongside hnsw_indexes
  - maintain_fts_on_insert hook called after secondary-index +
    HNSW maintenance in insert_row
  - deep_clone propagates fts_indexes for transaction snapshots
- src/sql/executor.rs:
  - IndexMethod::Fts arm + "fts" string-match in CREATE INDEX
    USING dispatch
  - create_fts_index validates TEXT (rejects VECTOR/JSON/INTEGER
    with a clear error), seeds PostingList from existing rows,
    pushes FtsIndexEntry
  - fts_match / bm25_score scalar fns in eval_function — bare
    column ident in arg 0, TEXT expression in arg 1; both error if
    no FTS index covers the column
  - try_fts_probe optimizer hook recognizes
    `ORDER BY bm25_score(col, 'q') DESC LIMIT k` and serves it
    from PostingList::query (top-k lookup). ASC falls through.
    Mirrors try_hnsw_probe's WHERE-drop posture per Q6
  - DELETE / UPDATE flag fts_indexes[i].needs_rebuild = true,
    same shape as HNSW
  - name uniqueness check spans btree + hnsw + fts namespaces
- src/sql/pager/mod.rs:
  - rebuild_dirty_fts_indexes runs at save_database start, walks
    current rows of dirty FTS indexes, replaces the PostingList
  - rebuild_fts_index replays CREATE INDEX SQL on open via
    execute_create_index ... (continued)

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

92.51
/src/sql/mod.rs
1
pub mod db;
2
pub mod executor;
3
pub mod fts;
4
pub mod hnsw;
5
pub mod pager;
6
pub mod parser;
7
// pub mod tokenizer;
8

9
use parser::create::CreateQuery;
10
use parser::insert::InsertQuery;
11
use parser::select::SelectQuery;
12

13
use sqlparser::ast::Statement;
14
use sqlparser::dialect::SQLiteDialect;
15
use sqlparser::parser::{Parser, ParserError};
16

17
use crate::error::{Result, SQLRiteError};
18
use crate::sql::db::database::Database;
19
use crate::sql::db::table::Table;
20

21
#[derive(Debug, PartialEq)]
22
pub enum SQLCommand {
23
    Insert(String),
24
    Delete(String),
25
    Update(String),
26
    CreateTable(String),
27
    Select(String),
28
    Unknown(String),
29
}
30

31
impl SQLCommand {
32
    pub fn new(command: String) -> SQLCommand {
1✔
33
        let v = command.split(" ").collect::<Vec<&str>>();
2✔
34
        match v[0] {
1✔
35
            "insert" => SQLCommand::Insert(command),
1✔
36
            "update" => SQLCommand::Update(command),
2✔
37
            "delete" => SQLCommand::Delete(command),
2✔
38
            "create" => SQLCommand::CreateTable(command),
2✔
39
            "select" => SQLCommand::Select(command),
2✔
40
            _ => SQLCommand::Unknown(command),
1✔
41
        }
42
    }
43
}
44

45
/// Output of running one SQL statement through the engine.
46
///
47
/// Two fields:
48
///
49
/// - `status` is the short human-readable confirmation line every caller
50
///   wants ("INSERT Statement executed.", "3 rows updated.", "BEGIN", etc.).
51
/// - `rendered` is the pre-formatted prettytable rendering of a SELECT's
52
///   result rows. Populated only for `SELECT` statements; `None` for every
53
///   other statement type. The REPL prints this above the status line so
54
///   users see both the rows and the confirmation; SDK / FFI / MCP callers
55
///   ignore it and reach for the typed-row APIs (`Connection::prepare` →
56
///   `Statement::query` → `Rows`) when they want row data instead.
57
///
58
/// Splitting the two means [`process_command_with_render`] can return
59
/// everything the REPL needs without writing to stdout itself —
60
/// historically `process_command` would `print!()` the rendered table
61
/// directly, which corrupted any non-REPL stdout channel (the MCP server's
62
/// JSON-RPC wire, structured loggers piping engine output, …).
63
#[derive(Debug, Clone)]
64
pub struct CommandOutput {
65
    pub status: String,
66
    pub rendered: Option<String>,
67
}
68

69
/// Backwards-compatible wrapper around [`process_command_with_render`] that
70
/// returns just the status string. Every existing call site (the public
71
/// `Connection::execute`, the SDK FFI shims, the .ask meta-command's
72
/// inline runner, the engine's own tests) keeps working unchanged.
73
///
74
/// Callers that want the rendered SELECT table (the REPL, future
75
/// terminal-style consumers) should call [`process_command_with_render`]
76
/// directly and inspect [`CommandOutput::rendered`].
77
pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
2✔
78
    process_command_with_render(query, db).map(|o| o.status)
6✔
79
}
80

81
/// Performs initial parsing of SQL Statement using sqlparser-rs.
82
///
83
/// Returns a [`CommandOutput`] carrying both the status string and (for
84
/// SELECT statements) the pre-rendered prettytable output. **Never writes
85
/// to stdout.** The REPL is responsible for printing whatever it wants
86
/// from the returned struct.
87
pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<CommandOutput> {
2✔
88
    let dialect = SQLiteDialect {};
2✔
89
    let message: String;
90
    let mut rendered: Option<String> = None;
2✔
91
    let mut ast = Parser::parse_sql(&dialect, query).map_err(SQLRiteError::from)?;
4✔
92

93
    if ast.len() > 1 {
4✔
94
        return Err(SQLRiteError::SqlError(ParserError::ParserError(format!(
×
95
            "Expected a single query statement, but there are {}",
96
            ast.len()
×
97
        ))));
98
    }
99

100
    // Comment-only or whitespace-only input parses to an empty Vec<Statement>.
101
    // Return a benign status rather than panicking on `pop().unwrap()`. Callers
102
    // (REPL, Tauri app) treat this as a no-op with no disk write triggered.
103
    let Some(query) = ast.pop() else {
4✔
104
        return Ok(CommandOutput {
1✔
105
            status: "No statement to execute.".to_string(),
1✔
106
            rendered: None,
1✔
107
        });
108
    };
109

110
    // Transaction boundary statements are routed to Database-level
111
    // handlers before we even inspect the rest of the AST. They don't
112
    // mutate table data directly, so they short-circuit the
113
    // is_write_statement / auto-save path.
114
    match &query {
2✔
115
        Statement::StartTransaction { .. } => {
116
            db.begin_transaction()?;
3✔
117
            return Ok(CommandOutput {
1✔
118
                status: String::from("BEGIN"),
1✔
119
                rendered: None,
1✔
120
            });
121
        }
122
        Statement::Commit { .. } => {
123
            if !db.in_transaction() {
2✔
124
                return Err(SQLRiteError::General(
1✔
125
                    "cannot COMMIT: no transaction is open".to_string(),
1✔
126
                ));
127
            }
128
            // Flush accumulated in-memory changes to disk. If the save
129
            // fails we auto-rollback the in-memory state to the
130
            // pre-BEGIN snapshot and surface a combined error. Leaving
131
            // the transaction open after a failed COMMIT would be
132
            // unsafe: auto-save on any subsequent non-transactional
133
            // statement would silently publish partial mid-transaction
134
            // work. Auto-rollback keeps the disk-plus-memory pair
135
            // coherent — the user loses their in-flight work on a disk
136
            // error, but that's the only safe outcome.
137
            if let Some(path) = db.source_path.clone() {
2✔
138
                if let Err(save_err) = pager::save_database(db, &path) {
2✔
139
                    let _ = db.rollback_transaction();
2✔
140
                    return Err(SQLRiteError::General(format!(
1✔
141
                        "COMMIT failed — transaction rolled back: {save_err}"
142
                    )));
143
                }
144
            }
145
            db.commit_transaction()?;
2✔
146
            return Ok(CommandOutput {
1✔
147
                status: String::from("COMMIT"),
1✔
148
                rendered: None,
1✔
149
            });
150
        }
151
        Statement::Rollback { .. } => {
152
            db.rollback_transaction()?;
3✔
153
            return Ok(CommandOutput {
1✔
154
                status: String::from("ROLLBACK"),
1✔
155
                rendered: None,
1✔
156
            });
157
        }
158
        _ => {}
159
    }
160

161
    // Statements that mutate state — trigger auto-save on success. Read-only
162
    // SELECTs skip the save entirely to avoid pointless file writes.
163
    let is_write_statement = matches!(
3✔
164
        &query,
2✔
165
        Statement::CreateTable(_)
166
            | Statement::CreateIndex(_)
167
            | Statement::Insert(_)
168
            | Statement::Update(_)
169
            | Statement::Delete(_)
170
    );
171

172
    // Early-reject mutations on a read-only database before they touch
173
    // in-memory state. Phase 4e: without this, a user running INSERT
174
    // on a `--readonly` REPL would see the row appear in the printed
175
    // table, and then the auto-save would fail — leaving the in-memory
176
    // Database visibly diverged from disk.
177
    if is_write_statement && db.is_read_only() {
4✔
178
        return Err(SQLRiteError::General(
1✔
179
            "cannot execute: database is opened read-only".to_string(),
1✔
180
        ));
181
    }
182

183
    // Initialy only implementing some basic SQL Statements
184
    match query {
2✔
185
        Statement::CreateTable(_) => {
186
            let create_query = CreateQuery::new(&query);
2✔
187
            match create_query {
2✔
188
                Ok(payload) => {
2✔
189
                    let table_name = payload.table_name.clone();
2✔
190
                    if table_name == pager::MASTER_TABLE_NAME {
4✔
191
                        return Err(SQLRiteError::General(format!(
×
192
                            "'{}' is a reserved name used by the internal schema catalog",
193
                            pager::MASTER_TABLE_NAME
194
                        )));
195
                    }
196
                    // Checking if table already exists, after parsing CREATE TABLE query
197
                    match db.contains_table(table_name.to_string()) {
4✔
198
                        true => {
199
                            return Err(SQLRiteError::Internal(
×
200
                                "Cannot create, table already exists.".to_string(),
×
201
                            ));
202
                        }
203
                        false => {
204
                            let table = Table::new(payload);
4✔
205
                            // Note: we used to call `table.print_table_schema()` here
206
                            // for REPL convenience. Removed because it wrote
207
                            // directly to stdout, which corrupted any non-REPL
208
                            // protocol channel (most painfully the MCP server's
209
                            // JSON-RPC wire). The status line below is enough for
210
                            // the REPL; users who want to inspect the schema can
211
                            // run a follow-up describe / `.tables`-style command.
212
                            db.tables.insert(table_name.to_string(), table);
4✔
213
                            message = String::from("CREATE TABLE Statement executed.");
2✔
214
                        }
215
                    }
216
                }
217
                Err(err) => return Err(err),
1✔
218
            }
219
        }
220
        Statement::Insert(_) => {
221
            let insert_query = InsertQuery::new(&query);
2✔
222
            match insert_query {
2✔
223
                Ok(payload) => {
2✔
224
                    let table_name = payload.table_name;
2✔
225
                    let columns = payload.columns;
2✔
226
                    let values = payload.rows;
2✔
227

228
                    // println!("table_name = {:?}\n cols = {:?}\n vals = {:?}", table_name, columns, values);
229
                    // Checking if Table exists in Database
230
                    match db.contains_table(table_name.to_string()) {
4✔
231
                        true => {
232
                            let db_table = db.get_table_mut(table_name.to_string()).unwrap();
4✔
233
                            // Checking if columns on INSERT query exist on Table
234
                            match columns
6✔
235
                                .iter()
2✔
236
                                .all(|column| db_table.contains_column(column.to_string()))
6✔
237
                            {
238
                                true => {
239
                                    for value in &values {
4✔
240
                                        // Checking if number of columns in query are the same as number of values
241
                                        if columns.len() != value.len() {
4✔
242
                                            return Err(SQLRiteError::Internal(format!(
×
243
                                                "{} values for {} columns",
244
                                                value.len(),
×
245
                                                columns.len()
×
246
                                            )));
247
                                        }
248
                                        db_table
2✔
249
                                            .validate_unique_constraint(&columns, value)
2✔
250
                                            .map_err(|err| {
2✔
251
                                                SQLRiteError::Internal(format!(
×
252
                                                    "Unique key constraint violation: {err}"
253
                                                ))
254
                                            })?;
255
                                        db_table.insert_row(&columns, value)?;
2✔
256
                                    }
257
                                }
258
                                false => {
259
                                    return Err(SQLRiteError::Internal(
×
260
                                        "Cannot insert, some of the columns do not exist"
261
                                            .to_string(),
×
262
                                    ));
263
                                }
264
                            }
265
                            // Note: we used to call `db_table.print_table_data()`
266
                            // here, which dumped the *entire* table to stdout
267
                            // after every INSERT. Beyond corrupting non-REPL
268
                            // stdout channels, that's actively bad UX on any
269
                            // table with more than a few rows. Removed in the
270
                            // engine-stdout-pollution cleanup.
271
                        }
272
                        false => {
273
                            return Err(SQLRiteError::Internal("Table doesn't exist".to_string()));
2✔
274
                        }
275
                    }
276
                }
277
                Err(err) => return Err(err),
×
278
            }
279

280
            message = String::from("INSERT Statement executed.")
2✔
281
        }
282
        Statement::Query(_) => {
283
            let select_query = SelectQuery::new(&query)?;
3✔
284
            let (rendered_table, rows) = executor::execute_select(select_query, db)?;
3✔
285
            // Stash the rendered prettytable in the output so the REPL
286
            // (or any terminal-style consumer) can print it above the
287
            // status line. SDK / FFI / MCP callers ignore this field.
288
            // The previous implementation `print!("{rendered}")`-ed
289
            // directly to stdout, which broke every non-REPL embedder.
290
            rendered = Some(rendered_table);
1✔
291
            message = format!(
2✔
292
                "SELECT Statement executed. {rows} row{s} returned.",
293
                s = if rows == 1 { "" } else { "s" }
1✔
294
            );
295
        }
296
        Statement::Delete(_) => {
297
            let rows = executor::execute_delete(&query, db)?;
2✔
298
            message = format!(
1✔
299
                "DELETE Statement executed. {rows} row{s} deleted.",
300
                s = if rows == 1 { "" } else { "s" }
1✔
301
            );
302
        }
303
        Statement::Update(_) => {
304
            let rows = executor::execute_update(&query, db)?;
3✔
305
            message = format!(
1✔
306
                "UPDATE Statement executed. {rows} row{s} updated.",
307
                s = if rows == 1 { "" } else { "s" }
1✔
308
            );
309
        }
310
        Statement::CreateIndex(_) => {
311
            let name = executor::execute_create_index(&query, db)?;
3✔
312
            message = format!("CREATE INDEX '{name}' executed.");
2✔
313
        }
314
        _ => {
315
            return Err(SQLRiteError::NotImplemented(
1✔
316
                "SQL Statement not supported yet.".to_string(),
1✔
317
            ));
318
        }
319
    };
320

321
    // Auto-save: if the database is backed by a file AND no explicit
322
    // transaction is open AND the statement changed state, flush to
323
    // disk before returning. Inside a `BEGIN … COMMIT` block the
324
    // mutations accumulate in memory (protected by the ROLLBACK
325
    // snapshot) and land on disk in one shot when COMMIT runs.
326
    //
327
    // A failed save surfaces as an error — the in-memory state already
328
    // mutated, so the caller should know disk is out of sync. The
329
    // Pager held on `db` diffs against its last-committed snapshot,
330
    // so only pages whose bytes actually changed are written.
331
    if is_write_statement && db.source_path.is_some() && !db.in_transaction() {
4✔
332
        let path = db.source_path.clone().unwrap();
2✔
333
        pager::save_database(db, &path)?;
4✔
334
    }
335

336
    Ok(CommandOutput {
2✔
337
        status: message,
2✔
338
        rendered,
2✔
339
    })
340
}
341

342
#[cfg(test)]
343
mod tests {
344
    use super::*;
345
    use crate::sql::db::table::Value;
346

347
    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
348
    /// with three rows, for use in executor-level tests.
349
    fn seed_users_table() -> Database {
1✔
350
        let mut db = Database::new("tempdb".to_string());
1✔
351
        process_command(
352
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
353
            &mut db,
354
        )
355
        .expect("create table");
356
        process_command(
357
            "INSERT INTO users (name, age) VALUES ('alice', 30);",
358
            &mut db,
359
        )
360
        .expect("insert alice");
361
        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
1✔
362
            .expect("insert bob");
363
        process_command(
364
            "INSERT INTO users (name, age) VALUES ('carol', 40);",
365
            &mut db,
366
        )
367
        .expect("insert carol");
368
        db
1✔
369
    }
370

371
    #[test]
372
    fn process_command_select_all_test() {
3✔
373
        let mut db = seed_users_table();
1✔
374
        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
2✔
375
        assert!(response.contains("3 rows returned"));
2✔
376
    }
377

378
    #[test]
379
    fn process_command_select_where_test() {
3✔
380
        let mut db = seed_users_table();
1✔
381
        let response =
2✔
382
            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
383
        assert!(response.contains("2 rows returned"));
2✔
384
    }
385

386
    #[test]
387
    fn process_command_select_eq_string_test() {
3✔
388
        let mut db = seed_users_table();
1✔
389
        let response =
2✔
390
            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
391
        assert!(response.contains("1 row returned"));
2✔
392
    }
393

394
    #[test]
395
    fn process_command_select_limit_test() {
3✔
396
        let mut db = seed_users_table();
1✔
397
        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
1✔
398
            .expect("select");
399
        assert!(response.contains("2 rows returned"));
2✔
400
    }
401

402
    #[test]
403
    fn process_command_select_unknown_table_test() {
3✔
404
        let mut db = Database::new("tempdb".to_string());
1✔
405
        let result = process_command("SELECT * FROM nope;", &mut db);
1✔
406
        assert!(result.is_err());
2✔
407
    }
408

409
    #[test]
410
    fn process_command_select_unknown_column_test() {
3✔
411
        let mut db = seed_users_table();
1✔
412
        let result = process_command("SELECT height FROM users;", &mut db);
1✔
413
        assert!(result.is_err());
2✔
414
    }
415

416
    #[test]
417
    fn process_command_insert_test() {
3✔
418
        // Creating temporary database
419
        let mut db = Database::new("tempdb".to_string());
1✔
420

421
        // Creating temporary table for testing purposes
422
        let query_statement = "CREATE TABLE users (
1✔
423
            id INTEGER PRIMARY KEY,
424
            name TEXT
425
        );";
426
        let dialect = SQLiteDialect {};
427
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
2✔
428
        if ast.len() > 1 {
2✔
429
            panic!("Expected a single query statement, but there are more then 1.")
×
430
        }
431
        let query = ast.pop().unwrap();
2✔
432
        let create_query = CreateQuery::new(&query).unwrap();
2✔
433

434
        // Inserting table into database
435
        db.tables.insert(
2✔
436
            create_query.table_name.to_string(),
2✔
437
            Table::new(create_query),
1✔
438
        );
439

440
        // Inserting data into table
441
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
442
        match process_command(&insert_query, &mut db) {
2✔
443
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
444
            Err(err) => {
×
445
                eprintln!("Error: {}", err);
×
446
                assert!(false)
×
447
            }
448
        };
449
    }
450

451
    #[test]
452
    fn process_command_insert_no_pk_test() {
3✔
453
        // Creating temporary database
454
        let mut db = Database::new("tempdb".to_string());
1✔
455

456
        // Creating temporary table for testing purposes
457
        let query_statement = "CREATE TABLE users (
1✔
458
            name TEXT
459
        );";
460
        let dialect = SQLiteDialect {};
461
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
2✔
462
        if ast.len() > 1 {
2✔
463
            panic!("Expected a single query statement, but there are more then 1.")
×
464
        }
465
        let query = ast.pop().unwrap();
2✔
466
        let create_query = CreateQuery::new(&query).unwrap();
2✔
467

468
        // Inserting table into database
469
        db.tables.insert(
2✔
470
            create_query.table_name.to_string(),
2✔
471
            Table::new(create_query),
1✔
472
        );
473

474
        // Inserting data into table
475
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
476
        match process_command(&insert_query, &mut db) {
2✔
477
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
478
            Err(err) => {
×
479
                eprintln!("Error: {}", err);
×
480
                assert!(false)
×
481
            }
482
        };
483
    }
484

485
    #[test]
486
    fn process_command_delete_where_test() {
4✔
487
        let mut db = seed_users_table();
1✔
488
        let response =
2✔
489
            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
490
        assert!(response.contains("1 row deleted"));
2✔
491

492
        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
1✔
493
        assert!(remaining.contains("2 rows returned"));
2✔
494
    }
495

496
    #[test]
497
    fn process_command_delete_all_test() {
3✔
498
        let mut db = seed_users_table();
1✔
499
        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
2✔
500
        assert!(response.contains("3 rows deleted"));
2✔
501
    }
502

503
    #[test]
504
    fn process_command_update_where_test() {
3✔
505
        use crate::sql::db::table::Value;
506

507
        let mut db = seed_users_table();
1✔
508
        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
1✔
509
            .expect("update");
510
        assert!(response.contains("1 row updated"));
2✔
511

512
        // Confirm the cell was actually rewritten.
513
        let users = db.get_table("users".to_string()).unwrap();
1✔
514
        let bob_rowid = users
1✔
515
            .rowids()
516
            .into_iter()
517
            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
3✔
518
            .expect("bob row must exist");
519
        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
1✔
520
    }
521

522
    #[test]
523
    fn process_command_update_unique_violation_test() {
3✔
524
        let mut db = seed_users_table();
1✔
525
        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
526
        process_command(
527
            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
528
            &mut db,
529
        )
530
        .unwrap();
531
        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
1✔
532
        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
1✔
533

534
        let result = process_command("UPDATE tags SET label = 'a' WHERE label = 'b';", &mut db);
1✔
535
        assert!(result.is_err(), "expected UNIQUE violation, got {result:?}");
2✔
536
    }
537

538
    #[test]
539
    fn process_command_insert_type_mismatch_returns_error_test() {
3✔
540
        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
541
        let mut db = Database::new("tempdb".to_string());
1✔
542
        process_command(
543
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
544
            &mut db,
545
        )
546
        .unwrap();
547
        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
1✔
548
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
549
    }
550

551
    #[test]
552
    fn process_command_insert_missing_integer_returns_error_test() {
3✔
553
        // Non-PK INTEGER without a value should error (not panic on "Null".parse()).
554
        let mut db = Database::new("tempdb".to_string());
1✔
555
        process_command(
556
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
557
            &mut db,
558
        )
559
        .unwrap();
560
        let result = process_command("INSERT INTO items (id) VALUES (1);", &mut db);
1✔
561
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
562
    }
563

564
    #[test]
565
    fn process_command_update_arith_test() {
3✔
566
        use crate::sql::db::table::Value;
567

568
        let mut db = seed_users_table();
1✔
569
        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
2✔
570

571
        let users = db.get_table("users".to_string()).unwrap();
1✔
572
        let mut ages: Vec<i64> = users
573
            .rowids()
574
            .into_iter()
575
            .filter_map(|r| match users.get_value("age", r) {
3✔
576
                Some(Value::Integer(n)) => Some(n),
1✔
577
                _ => None,
×
578
            })
579
            .collect();
580
        ages.sort();
2✔
581
        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
1✔
582
    }
583

584
    #[test]
585
    fn process_command_select_arithmetic_where_test() {
3✔
586
        let mut db = seed_users_table();
1✔
587
        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
588
        let response =
2✔
589
            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
590
        assert!(response.contains("2 rows returned"));
2✔
591
    }
592

593
    #[test]
594
    fn process_command_divide_by_zero_test() {
3✔
595
        let mut db = seed_users_table();
1✔
596
        let result = process_command("SELECT age / 0 FROM users;", &mut db);
1✔
597
        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
598
        assert!(result.is_err());
2✔
599
    }
600

601
    #[test]
602
    fn process_command_unsupported_statement_test() {
3✔
603
        let mut db = Database::new("tempdb".to_string());
1✔
604
        // Nothing in Phase 1 handles DROP.
605
        let result = process_command("DROP TABLE users;", &mut db);
1✔
606
        assert!(result.is_err());
2✔
607
    }
608

609
    #[test]
610
    fn empty_input_is_a_noop_not_a_panic() {
3✔
611
        // Regression for: desktop app pre-fills the textarea with a
612
        // comment-only placeholder, and hitting Run used to panic because
613
        // sqlparser produced zero statements and pop().unwrap() exploded.
614
        let mut db = Database::new("t".to_string());
1✔
615
        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
4✔
616
            let result = process_command(input, &mut db);
2✔
617
            assert!(result.is_ok(), "input {input:?} should not error");
2✔
618
            let msg = result.unwrap();
1✔
619
            assert!(msg.contains("No statement"), "got: {msg:?}");
2✔
620
        }
621
    }
622

623
    #[test]
624
    fn create_index_adds_explicit_index() {
3✔
625
        let mut db = seed_users_table();
1✔
626
        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
1✔
627
            .expect("create index");
628
        assert!(response.contains("users_age_idx"));
2✔
629

630
        // The index should now be attached to the users table.
631
        let users = db.get_table("users".to_string()).unwrap();
1✔
632
        let idx = users
1✔
633
            .index_by_name("users_age_idx")
634
            .expect("index should exist after CREATE INDEX");
635
        assert_eq!(idx.column_name, "age");
1✔
636
        assert!(!idx.is_unique);
1✔
637
    }
638

639
    #[test]
640
    fn create_unique_index_rejects_duplicate_existing_values() {
3✔
641
        let mut db = seed_users_table();
1✔
642
        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
643
        // first so CREATE UNIQUE INDEX on age catches the conflict.
644
        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
2✔
645
        let result = process_command(
646
            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
647
            &mut db,
648
        );
649
        assert!(
×
650
            result.is_err(),
2✔
651
            "expected unique-index failure, got {result:?}"
652
        );
653
    }
654

655
    #[test]
656
    fn where_eq_on_indexed_column_uses_index_probe() {
3✔
657
        // Build a table big enough that a full scan would be expensive,
658
        // then rely on the index-probe fast path. This test verifies
659
        // correctness (right rows returned); the perf win is implicit.
660
        let mut db = Database::new("t".to_string());
1✔
661
        process_command(
662
            "CREATE TABLE big (id INTEGER PRIMARY KEY, tag TEXT);",
663
            &mut db,
664
        )
665
        .unwrap();
666
        process_command("CREATE INDEX big_tag_idx ON big (tag);", &mut db).unwrap();
1✔
667
        for i in 1..=100 {
1✔
668
            let tag = if i % 3 == 0 { "hot" } else { "cold" };
2✔
669
            process_command(&format!("INSERT INTO big (tag) VALUES ('{tag}');"), &mut db).unwrap();
1✔
670
        }
671
        let response =
1✔
672
            process_command("SELECT id FROM big WHERE tag = 'hot';", &mut db).expect("select");
673
        // 1..=100 has 33 multiples of 3.
674
        assert!(
×
675
            response.contains("33 rows returned"),
2✔
676
            "response was {response:?}"
677
        );
678
    }
679

680
    #[test]
681
    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
3✔
682
        let mut db = seed_users_table();
1✔
683
        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
1✔
684
            .expect("select");
685
        assert!(response.contains("1 row returned"));
2✔
686
    }
687

688
    #[test]
689
    fn where_eq_literal_first_side_uses_index_probe() {
3✔
690
        let mut db = seed_users_table();
1✔
691
        // `'bob' = name` should hit the same path as `name = 'bob'`.
692
        let response =
2✔
693
            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
694
        assert!(response.contains("1 row returned"));
2✔
695
    }
696

697
    #[test]
698
    fn non_equality_where_still_falls_back_to_full_scan() {
3✔
699
        // Sanity: range predicates bypass the optimizer and the full-scan
700
        // path still returns correct results.
701
        let mut db = seed_users_table();
1✔
702
        let response =
2✔
703
            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
704
        assert!(response.contains("2 rows returned"));
2✔
705
    }
706

707
    // -------------------------------------------------------------------
708
    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
709
    // -------------------------------------------------------------------
710

711
    #[test]
712
    fn rollback_restores_pre_begin_in_memory_state() {
3✔
713
        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
714
        // The row must disappear from the live tables HashMap.
715
        let mut db = seed_users_table();
1✔
716
        let before = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
717
        assert_eq!(before, 3);
1✔
718

719
        process_command("BEGIN;", &mut db).expect("BEGIN");
1✔
720
        assert!(db.in_transaction());
1✔
721
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
722
            .expect("INSERT inside txn");
723
        // Mid-transaction read sees the new row.
724
        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
1✔
725
        assert_eq!(mid, 4);
1✔
726

727
        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
1✔
728
        assert!(!db.in_transaction());
1✔
729
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
730
        assert_eq!(
1✔
731
            after, 3,
732
            "ROLLBACK should have restored the pre-BEGIN state"
733
        );
734
    }
735

736
    #[test]
737
    fn commit_keeps_mutations_and_clears_txn_flag() {
3✔
738
        let mut db = seed_users_table();
1✔
739
        process_command("BEGIN;", &mut db).expect("BEGIN");
2✔
740
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
741
            .expect("INSERT inside txn");
742
        process_command("COMMIT;", &mut db).expect("COMMIT");
1✔
743
        assert!(!db.in_transaction());
1✔
744
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
745
        assert_eq!(after, 4);
1✔
746
    }
747

748
    #[test]
749
    fn rollback_undoes_update_and_delete_side_by_side() {
3✔
750
        use crate::sql::db::table::Value;
751

752
        let mut db = seed_users_table();
1✔
753
        process_command("BEGIN;", &mut db).unwrap();
2✔
754
        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
1✔
755
        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
1✔
756
        // Mid-txn: one row gone, others have age=999.
757
        let users = db.get_table("users".to_string()).unwrap();
1✔
758
        assert_eq!(users.rowids().len(), 2);
1✔
759
        for r in users.rowids() {
2✔
760
            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
761
        }
762

763
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
764
        let users = db.get_table("users".to_string()).unwrap();
1✔
765
        assert_eq!(users.rowids().len(), 3);
1✔
766
        // Original ages {30, 25, 40} — none should be 999.
767
        for r in users.rowids() {
2✔
768
            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
769
        }
770
    }
771

772
    #[test]
773
    fn nested_begin_is_rejected() {
3✔
774
        let mut db = seed_users_table();
1✔
775
        process_command("BEGIN;", &mut db).unwrap();
2✔
776
        let err = process_command("BEGIN;", &mut db).unwrap_err();
1✔
777
        assert!(
×
778
            format!("{err}").contains("already open"),
3✔
779
            "nested BEGIN should error; got: {err}"
780
        );
781
        // Still in the original transaction; a ROLLBACK clears it.
782
        assert!(db.in_transaction());
1✔
783
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
784
    }
785

786
    #[test]
787
    fn orphan_commit_and_rollback_are_rejected() {
3✔
788
        let mut db = seed_users_table();
1✔
789
        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
2✔
790
        assert!(format!("{commit_err}").contains("no transaction"));
2✔
791
        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
1✔
792
        assert!(format!("{rollback_err}").contains("no transaction"));
2✔
793
    }
794

795
    #[test]
796
    fn error_inside_transaction_keeps_txn_open() {
3✔
797
        // A bad INSERT inside a txn doesn't commit or abort automatically —
798
        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
799
        // isn't modeled here.
800
        let mut db = seed_users_table();
1✔
801
        process_command("BEGIN;", &mut db).unwrap();
2✔
802
        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
1✔
803
        assert!(err.is_err());
2✔
804
        assert!(db.in_transaction(), "txn should stay open after error");
1✔
805
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
806
    }
807

808
    /// Builds a file-backed Database at a unique temp path, with the
809
    /// schema seeded and `source_path` set so subsequent process_command
810
    /// calls auto-save. Returns (path, db). Drop the db before deleting
811
    /// the files.
812
    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
1✔
813
        use crate::sql::pager::{open_database, save_database};
814
        let mut p = std::env::temp_dir();
1✔
815
        let pid = std::process::id();
2✔
816
        let nanos = std::time::SystemTime::now()
2✔
817
            .duration_since(std::time::UNIX_EPOCH)
1✔
818
            .map(|d| d.as_nanos())
3✔
819
            .unwrap_or(0);
820
        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
1✔
821

822
        // Seed the file, then reopen to get a source_path-attached db
823
        // (save_database alone doesn't attach a fresh pager to a db
824
        // whose source_path was None before the call).
825
        {
826
            let mut seed = Database::new("t".to_string());
1✔
827
            process_command(schema, &mut seed).unwrap();
2✔
828
            save_database(&mut seed, &p).unwrap();
1✔
829
        }
830
        let db = open_database(&p, "t".to_string()).unwrap();
1✔
831
        (p, db)
1✔
832
    }
833

834
    fn cleanup_file(path: &std::path::Path) {
1✔
835
        let _ = std::fs::remove_file(path);
1✔
836
        let mut wal = path.as_os_str().to_owned();
1✔
837
        wal.push("-wal");
1✔
838
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
839
    }
840

841
    #[test]
842
    fn begin_commit_rollback_round_trip_through_disk() {
3✔
843
        // File-backed DB: commit inside a transaction must actually
844
        // persist. ROLLBACK inside a *later* transaction must not
845
        // un-do the previously-committed changes.
846
        use crate::sql::pager::open_database;
847

848
        let (path, mut db) = seed_file_backed(
849
            "roundtrip",
850
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
851
        );
852

853
        // Transaction 1: insert two rows, commit.
854
        process_command("BEGIN;", &mut db).unwrap();
2✔
855
        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
1✔
856
        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
1✔
857
        process_command("COMMIT;", &mut db).unwrap();
1✔
858

859
        // Transaction 2: insert another, roll back.
860
        process_command("BEGIN;", &mut db).unwrap();
1✔
861
        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
1✔
862
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
863

864
        drop(db); // release pager lock
1✔
865

866
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
867
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
868
        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
1✔
869

870
        drop(reopened);
1✔
871
        cleanup_file(&path);
1✔
872
    }
873

874
    #[test]
875
    fn write_inside_transaction_does_not_autosave() {
3✔
876
        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
877
        // the WAL until COMMIT. We prove it by checking the WAL file
878
        // size before vs during the transaction.
879
        let (path, mut db) =
1✔
880
            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
881

882
        let mut wal_path = path.as_os_str().to_owned();
2✔
883
        wal_path.push("-wal");
1✔
884
        let wal_path = std::path::PathBuf::from(wal_path);
1✔
885
        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
2✔
886

887
        process_command("BEGIN;", &mut db).unwrap();
1✔
888
        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
1✔
889
        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
1✔
890

891
        // Mid-transaction: WAL must be unchanged — no auto-save fired.
892
        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
1✔
893
        assert_eq!(
1✔
894
            frames_before, frames_mid,
895
            "WAL should not grow during an open transaction"
896
        );
897

898
        process_command("COMMIT;", &mut db).unwrap();
2✔
899

900
        drop(db); // release pager lock
1✔
901
        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
902
        assert_eq!(
1✔
903
            fresh.get_table("t".to_string()).unwrap().rowids().len(),
3✔
904
            2,
905
            "COMMIT should have persisted both inserted rows"
906
        );
907
        drop(fresh);
1✔
908
        cleanup_file(&path);
1✔
909
    }
910

911
    #[test]
912
    fn rollback_undoes_create_table() {
3✔
913
        // Schema DDL inside a txn: ROLLBACK must make the new table
914
        // disappear. The txn snapshot captures db.tables as of BEGIN,
915
        // and ROLLBACK reassigns tables from that snapshot, so a table
916
        // created mid-transaction has no entry in the snapshot.
917
        let mut db = seed_users_table();
1✔
918
        assert_eq!(db.tables.len(), 1);
2✔
919

920
        process_command("BEGIN;", &mut db).unwrap();
1✔
921
        process_command(
922
            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
923
            &mut db,
924
        )
925
        .unwrap();
926
        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
1✔
927
        assert_eq!(db.tables.len(), 2);
1✔
928

929
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
930
        assert_eq!(
1✔
931
            db.tables.len(),
1✔
932
            1,
933
            "CREATE TABLE should have been rolled back"
934
        );
935
        assert!(db.get_table("dropme".to_string()).is_err());
2✔
936
    }
937

938
    #[test]
939
    fn rollback_restores_secondary_index_state() {
3✔
940
        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
941
        // column must also clean up the index, otherwise a re-insert of
942
        // the same value would spuriously collide.
943
        let mut db = Database::new("t".to_string());
1✔
944
        process_command(
945
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
946
            &mut db,
947
        )
948
        .unwrap();
949
        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1✔
950

951
        process_command("BEGIN;", &mut db).unwrap();
1✔
952
        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1✔
953
        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
954
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
955

956
        // Re-inserting 'b@x' after rollback must succeed — if the index
957
        // wasn't properly restored, it would think 'b@x' is still a
958
        // collision and fail with a UNIQUE violation.
959
        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
1✔
960
        assert!(
×
961
            reinsert.is_ok(),
2✔
962
            "re-insert after rollback should succeed, got {reinsert:?}"
963
        );
964
    }
965

966
    #[test]
967
    fn rollback_restores_last_rowid_counter() {
3✔
968
        // Rowids allocated inside a rolled-back transaction should be
969
        // reusable. The snapshot restores Table::last_rowid, so the
970
        // next insert picks up where the pre-BEGIN state left off.
971
        use crate::sql::db::table::Value;
972

973
        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
1✔
974
        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
2✔
975

976
        process_command("BEGIN;", &mut db).unwrap();
1✔
977
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
1✔
978
        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
1✔
979
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
980

981
        let post = db.get_table("users".to_string()).unwrap().last_rowid;
1✔
982
        assert_eq!(pre, post, "last_rowid must roll back with the snapshot");
1✔
983

984
        // Confirm: the next insert reuses rowid pre+1.
985
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
2✔
986
        let users = db.get_table("users".to_string()).unwrap();
1✔
987
        let d_rowid = users
1✔
988
            .rowids()
989
            .into_iter()
990
            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
3✔
991
            .expect("d row must exist");
992
        assert_eq!(d_rowid, pre + 1);
1✔
993
    }
994

995
    #[test]
996
    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
3✔
997
        // In-memory DB (no source_path): COMMIT must still work — just
998
        // no disk flush. Covers the `if let Some(path) = …` branch
999
        // where the guard falls through without calling save_database.
1000
        let mut db = seed_users_table(); // no source_path
1✔
1001
        assert!(db.source_path.is_none());
2✔
1002

1003
        process_command("BEGIN;", &mut db).unwrap();
1✔
1004
        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1✔
1005
        process_command("COMMIT;", &mut db).unwrap();
1✔
1006

1007
        assert!(!db.in_transaction());
1✔
1008
        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
2✔
1009
    }
1010

1011
    #[test]
1012
    fn failed_commit_auto_rolls_back_in_memory_state() {
3✔
1013
        // Data-safety regression: on COMMIT save failure we must auto-
1014
        // rollback the in-memory state. Otherwise, any subsequent
1015
        // non-transactional statement would auto-save the partial
1016
        // mid-transaction work, silently publishing uncommitted
1017
        // changes to disk.
1018
        //
1019
        // We simulate a save failure by making the WAL sidecar path
1020
        // unavailable mid-transaction: after BEGIN, we take an
1021
        // exclusive OS lock on the WAL via a second File handle,
1022
        // forcing the next save to fail when it tries to append.
1023
        //
1024
        // Simpler repro: point source_path at a directory (not a file).
1025
        // `OpenOptions::open` will fail with EISDIR on save.
1026
        use crate::sql::pager::save_database;
1027

1028
        // Seed a file-backed db.
1029
        let (path, mut db) = seed_file_backed(
1030
            "failcommit",
1031
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1032
        );
1033

1034
        // Prime one committed row so we have a baseline.
1035
        process_command("INSERT INTO notes (body) VALUES ('before');", &mut db).unwrap();
2✔
1036

1037
        // Open a new txn and add a row.
1038
        process_command("BEGIN;", &mut db).unwrap();
1✔
1039
        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1✔
1040
        assert_eq!(
1✔
1041
            db.get_table("notes".to_string()).unwrap().rowids().len(),
2✔
1042
            2,
1043
            "inflight row visible mid-txn"
1044
        );
1045

1046
        // Swap source_path to a path that will fail on open. A
1047
        // directory is a reliable failure mode — Pager::open on a
1048
        // directory errors with an I/O error.
1049
        let orig_source = db.source_path.clone();
1✔
1050
        let orig_pager = db.pager.take();
2✔
1051
        db.source_path = Some(std::env::temp_dir());
2✔
1052

1053
        let commit_result = process_command("COMMIT;", &mut db);
1✔
1054
        assert!(commit_result.is_err(), "commit must fail");
2✔
1055
        let err_str = format!("{}", commit_result.unwrap_err());
2✔
1056
        assert!(
×
1057
            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
3✔
1058
            "error must surface auto-rollback; got: {err_str}"
1059
        );
1060

1061
        // Auto-rollback fired: the inflight row is gone, the txn flag
1062
        // is cleared, and a follow-up non-txn statement won't leak
1063
        // stale state.
1064
        assert!(
×
1065
            !db.in_transaction(),
2✔
1066
            "txn must be cleared after auto-rollback"
1067
        );
1068
        assert_eq!(
1✔
1069
            db.get_table("notes".to_string()).unwrap().rowids().len(),
3✔
1070
            1,
1071
            "inflight row must be rolled back"
1072
        );
1073

1074
        // Restore the real source_path + pager and verify a clean
1075
        // subsequent write goes through.
1076
        db.source_path = orig_source;
1✔
1077
        db.pager = orig_pager;
1✔
1078
        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1✔
1079
        drop(db);
1✔
1080

1081
        // Reopen and assert only 'before' + 'after' landed on disk.
1082
        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1083
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1084
        assert_eq!(notes.rowids().len(), 2);
1✔
1085
        // Ensure no leaked save_database partial happened.
1086
        let _ = save_database; // silence unused-import lint if any
1087
        drop(reopened);
1✔
1088
        cleanup_file(&path);
1✔
1089
    }
1090

1091
    #[test]
1092
    fn begin_on_read_only_is_rejected() {
3✔
1093
        use crate::sql::pager::{open_database_read_only, save_database};
1094

1095
        let path = {
1✔
1096
            let mut p = std::env::temp_dir();
1✔
1097
            let pid = std::process::id();
2✔
1098
            let nanos = std::time::SystemTime::now()
2✔
1099
                .duration_since(std::time::UNIX_EPOCH)
1✔
1100
                .map(|d| d.as_nanos())
3✔
1101
                .unwrap_or(0);
1102
            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1✔
1103
            p
1✔
1104
        };
1105
        {
1106
            let mut seed = Database::new("t".to_string());
2✔
1107
            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
2✔
1108
            save_database(&mut seed, &path).unwrap();
1✔
1109
        }
1110

1111
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1112
        let err = process_command("BEGIN;", &mut ro).unwrap_err();
2✔
1113
        assert!(
×
1114
            format!("{err}").contains("read-only"),
3✔
1115
            "BEGIN on RO db should surface read-only; got: {err}"
1116
        );
1117
        assert!(!ro.in_transaction());
1✔
1118

1119
        let _ = std::fs::remove_file(&path);
2✔
1120
        let mut wal = path.as_os_str().to_owned();
1✔
1121
        wal.push("-wal");
1✔
1122
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1123
    }
1124

1125
    #[test]
1126
    fn read_only_database_rejects_mutations_before_touching_state() {
3✔
1127
        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1128
        // must error *before* the row is added to the in-memory table.
1129
        // Otherwise the user sees a rendered result table with the
1130
        // phantom row, followed by the auto-save error — UX rot and a
1131
        // state-drift risk.
1132
        use crate::sql::pager::open_database_read_only;
1133

1134
        let mut seed = Database::new("t".to_string());
1✔
1135
        process_command(
1136
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1137
            &mut seed,
1138
        )
1139
        .unwrap();
1140
        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1✔
1141

1142
        let path = {
1143
            let mut p = std::env::temp_dir();
1✔
1144
            let pid = std::process::id();
2✔
1145
            let nanos = std::time::SystemTime::now()
2✔
1146
                .duration_since(std::time::UNIX_EPOCH)
1✔
1147
                .map(|d| d.as_nanos())
3✔
1148
                .unwrap_or(0);
1149
            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1✔
1150
            p
1✔
1151
        };
1152
        crate::sql::pager::save_database(&mut seed, &path).unwrap();
2✔
1153
        drop(seed);
1✔
1154

1155
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1156
        let notes_before = ro.get_table("notes".to_string()).unwrap().rowids().len();
2✔
1157

1158
        for stmt in [
2✔
1159
            "INSERT INTO notes (body) VALUES ('beta');",
1160
            "UPDATE notes SET body = 'x';",
1161
            "DELETE FROM notes;",
1162
            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1163
            "CREATE INDEX notes_body ON notes (body);",
1164
        ] {
1165
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
1166
            assert!(
×
1167
                format!("{err}").contains("read-only"),
3✔
1168
                "stmt {stmt:?} should surface a read-only error; got: {err}"
1169
            );
1170
        }
1171

1172
        // Nothing mutated: same row count as before, and SELECTs still work.
1173
        let notes_after = ro.get_table("notes".to_string()).unwrap().rowids().len();
1✔
1174
        assert_eq!(notes_before, notes_after);
1✔
1175
        let sel = process_command("SELECT * FROM notes;", &mut ro).expect("select on RO must work");
1✔
1176
        assert!(sel.contains("1 row returned"));
2✔
1177

1178
        // Cleanup.
1179
        drop(ro);
1✔
1180
        let _ = std::fs::remove_file(&path);
1✔
1181
        let mut wal = path.as_os_str().to_owned();
1✔
1182
        wal.push("-wal");
1✔
1183
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1184
    }
1185

1186
    // -----------------------------------------------------------------
1187
    // Phase 7a — VECTOR(N) end-to-end through process_command
1188
    // -----------------------------------------------------------------
1189

1190
    #[test]
1191
    fn vector_create_table_and_insert_basic() {
3✔
1192
        let mut db = Database::new("tempdb".to_string());
1✔
1193
        process_command(
1194
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1195
            &mut db,
1196
        )
1197
        .expect("create table with VECTOR(3)");
1198
        process_command(
1199
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1200
            &mut db,
1201
        )
1202
        .expect("insert vector");
1203

1204
        // process_command returns a status string; the rendered table
1205
        // goes to stdout via print_table. Verify state by inspecting
1206
        // the database directly.
1207
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1208
        assert!(sel.contains("1 row returned"));
2✔
1209

1210
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1211
        let rowids = docs.rowids();
1✔
1212
        assert_eq!(rowids.len(), 1);
2✔
1213
        match docs.get_value("embedding", rowids[0]) {
1✔
1214
            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1✔
1215
            other => panic!("expected Value::Vector(...), got {other:?}"),
×
1216
        }
1217
    }
1218

1219
    #[test]
1220
    fn vector_dim_mismatch_at_insert_is_clean_error() {
3✔
1221
        let mut db = Database::new("tempdb".to_string());
1✔
1222
        process_command(
1223
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1224
            &mut db,
1225
        )
1226
        .expect("create table");
1227

1228
        // Too few elements.
1229
        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1✔
1230
            .unwrap_err();
1231
        let msg = format!("{err}");
2✔
1232
        assert!(
×
1233
            msg.to_lowercase().contains("dimension")
2✔
1234
                && msg.contains("declared 3")
1✔
1235
                && msg.contains("got 2"),
1✔
1236
            "expected clear dim-mismatch error, got: {msg}"
1237
        );
1238

1239
        // Too many elements.
1240
        let err = process_command(
1241
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1242
            &mut db,
1243
        )
1244
        .unwrap_err();
1245
        assert!(
×
1246
            format!("{err}").contains("got 5"),
3✔
1247
            "expected dim-mismatch error mentioning got 5, got: {err}"
1248
        );
1249
    }
1250

1251
    #[test]
1252
    fn vector_create_table_rejects_missing_dim() {
3✔
1253
        let mut db = Database::new("tempdb".to_string());
1✔
1254
        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1255
        // empty args from sqlparser, OR may not parse as Custom at all
1256
        // depending on dialect. Either way, the column shouldn't end up
1257
        // as a usable Vector type. Accept any error here — the precise
1258
        // message is parser-version-dependent.
1259
        let result = process_command(
1260
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1261
            &mut db,
1262
        );
1263
        assert!(
×
1264
            result.is_err(),
2✔
1265
            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1266
        );
1267
    }
1268

1269
    #[test]
1270
    fn vector_create_table_rejects_zero_dim() {
3✔
1271
        let mut db = Database::new("tempdb".to_string());
1✔
1272
        let err = process_command(
1273
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1274
            &mut db,
1275
        )
1276
        .unwrap_err();
1277
        let msg = format!("{err}");
2✔
1278
        assert!(
×
1279
            msg.to_lowercase().contains("vector"),
3✔
1280
            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1281
        );
1282
    }
1283

1284
    #[test]
1285
    fn vector_high_dim_works() {
3✔
1286
        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1287
        // smoke test — if cell encoding mishandles the size, this fails.
1288
        let mut db = Database::new("tempdb".to_string());
1✔
1289
        process_command(
1290
            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1291
            &mut db,
1292
        )
1293
        .expect("create table VECTOR(384)");
1294

1295
        let lit = format!(
1✔
1296
            "[{}]",
1297
            (0..384)
1✔
1298
                .map(|i| format!("{}", i as f32 * 0.001))
3✔
1299
                .collect::<Vec<_>>()
1✔
1300
                .join(",")
1✔
1301
        );
1302
        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
2✔
1303
        process_command(&sql, &mut db).expect("insert 384-dim vector");
2✔
1304

1305
        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1✔
1306
        assert!(sel.contains("1 row returned"));
2✔
1307
    }
1308

1309
    #[test]
1310
    fn vector_multiple_rows() {
3✔
1311
        // Three rows with different vectors — exercises the Row::Vector
1312
        // BTreeMap path (not just single-row insertion).
1313
        let mut db = Database::new("tempdb".to_string());
1✔
1314
        process_command(
1315
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1316
            &mut db,
1317
        )
1318
        .expect("create");
1319
        for i in 0..3 {
1✔
1320
            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
2✔
1321
            process_command(&sql, &mut db).expect("insert");
2✔
1322
        }
1323
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1324
        assert!(sel.contains("3 rows returned"));
2✔
1325

1326
        // Verify each vector round-tripped correctly via direct DB inspection.
1327
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1328
        let rowids = docs.rowids();
1✔
1329
        assert_eq!(rowids.len(), 3);
2✔
1330
        let mut vectors: Vec<Vec<f32>> = rowids
1✔
1331
            .iter()
1332
            .filter_map(|r| match docs.get_value("e", *r) {
3✔
1333
                Some(Value::Vector(v)) => Some(v),
1✔
1334
                _ => None,
×
1335
            })
1336
            .collect();
1337
        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
4✔
1338
        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1✔
1339
        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1✔
1340
        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1✔
1341
    }
1342

1343
    // -----------------------------------------------------------------
1344
    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1345
    // -----------------------------------------------------------------
1346

1347
    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1348
    /// at known positions for clear distance reasoning. Used by both
1349
    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1350
    fn seed_hnsw_table() -> Database {
1✔
1351
        let mut db = Database::new("tempdb".to_string());
1✔
1352
        process_command(
1353
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1354
            &mut db,
1355
        )
1356
        .unwrap();
1357
        for v in &[
1✔
1358
            "[1.0, 0.0]",   // id=1
1359
            "[2.0, 0.0]",   // id=2
1360
            "[0.0, 3.0]",   // id=3
1361
            "[1.0, 4.0]",   // id=4
1362
            "[10.0, 10.0]", // id=5
1363
        ] {
1364
            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1365
        }
1366
        db
1✔
1367
    }
1368

1369
    #[test]
1370
    fn create_index_using_hnsw_succeeds() {
3✔
1371
        let mut db = seed_hnsw_table();
1✔
1372
        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1373
        assert!(resp.to_lowercase().contains("create index"));
2✔
1374
        // Index attached.
1375
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1376
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1377
        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1✔
1378
        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1✔
1379
        // Existing rows landed in the graph.
1380
        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1✔
1381
    }
1382

1383
    #[test]
1384
    fn create_index_using_hnsw_rejects_non_vector_column() {
3✔
1385
        let mut db = Database::new("tempdb".to_string());
1✔
1386
        process_command(
1387
            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1388
            &mut db,
1389
        )
1390
        .unwrap();
1391
        let err =
1✔
1392
            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1393
        let msg = format!("{err}");
2✔
1394
        assert!(
×
1395
            msg.to_lowercase().contains("vector"),
3✔
1396
            "expected error mentioning VECTOR; got: {msg}"
1397
        );
1398
    }
1399

1400
    #[test]
1401
    fn knn_query_uses_hnsw_after_create_index() {
3✔
1402
        // The KNN-shaped query route through try_hnsw_probe rather than
1403
        // the brute-force select_topk. The user-visible result should
1404
        // be the same (HNSW recall is high on small graphs); we
1405
        // primarily verify the index is being hit by checking that
1406
        // the right rowids come back in the right order.
1407
        let mut db = seed_hnsw_table();
1✔
1408
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1409

1410
        // Top-3 closest to [1.0, 0.0]:
1411
        //   id=1 [1.0, 0.0]   distance=0
1412
        //   id=2 [2.0, 0.0]   distance=1
1413
        //   id=3 [0.0, 3.0]   distance≈3.16
1414
        let resp = process_command(
1415
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1416
            &mut db,
1417
        )
1418
        .unwrap();
1419
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1420
    }
1421

1422
    #[test]
1423
    fn knn_query_works_after_subsequent_inserts() {
3✔
1424
        // Index built when 5 rows existed; insert 2 more after; the
1425
        // HNSW gets maintained incrementally by insert_row, so the
1426
        // KNN query should see the newly-inserted vectors.
1427
        let mut db = seed_hnsw_table();
1✔
1428
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1429
        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1✔
1430
        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1✔
1431

1432
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1433
        assert_eq!(
1✔
1434
            table.hnsw_indexes[0].index.len(),
1✔
1435
            7,
1436
            "incremental insert should grow HNSW alongside row storage"
1437
        );
1438

1439
        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1440
        // original 5 rows.
1441
        let resp = process_command(
1442
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1443
            &mut db,
1444
        )
1445
        .unwrap();
1446
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1447
    }
1448

1449
    // Phase 7d.3 — DELETE / UPDATE on HNSW-indexed tables now works.
1450
    // The 7d.2 versions of these tests asserted a refusal; replaced
1451
    // with assertions that the operation succeeds + the index entry's
1452
    // needs_rebuild flag flipped so the next save will rebuild.
1453

1454
    #[test]
1455
    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
3✔
1456
        let mut db = seed_hnsw_table();
1✔
1457
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1458
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1459
        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
2✔
1460

1461
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1462
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1463
        assert!(
×
1464
            entry.needs_rebuild,
1✔
1465
            "DELETE should have marked HNSW index dirty for rebuild on next save"
1466
        );
1467
    }
1468

1469
    #[test]
1470
    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
3✔
1471
        let mut db = seed_hnsw_table();
1✔
1472
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1473
        let resp =
1✔
1474
            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1475
        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
2✔
1476

1477
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1478
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1479
        assert!(
×
1480
            entry.needs_rebuild,
1✔
1481
            "UPDATE on the vector column should have marked HNSW index dirty"
1482
        );
1483
    }
1484

1485
    #[test]
1486
    fn duplicate_index_name_errors() {
3✔
1487
        let mut db = seed_hnsw_table();
1✔
1488
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1489
        let err =
1✔
1490
            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1491
        let msg = format!("{err}");
2✔
1492
        assert!(
×
1493
            msg.to_lowercase().contains("already exists"),
3✔
1494
            "expected duplicate-index error; got: {msg}"
1495
        );
1496
    }
1497

1498
    #[test]
1499
    fn index_if_not_exists_is_idempotent() {
3✔
1500
        let mut db = seed_hnsw_table();
1✔
1501
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1502
        // Second time with IF NOT EXISTS should succeed (no-op).
1503
        process_command(
1504
            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1505
            &mut db,
1506
        )
1507
        .unwrap();
1508
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1509
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1510
    }
1511

1512
    // -----------------------------------------------------------------
1513
    // Phase 8b — CREATE INDEX … USING fts end-to-end
1514
    // -----------------------------------------------------------------
1515

1516
    /// 5-row docs(id INTEGER PK, body TEXT) populated with overlapping
1517
    /// vocabulary so BM25 ranking has interesting structure.
1518
    fn seed_fts_table() -> Database {
1✔
1519
        let mut db = Database::new("tempdb".to_string());
1✔
1520
        process_command(
1521
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1522
            &mut db,
1523
        )
1524
        .unwrap();
1525
        for body in &[
1✔
1526
            "rust embedded database",        // id=1 — both 'rust' and 'embedded'
1527
            "rust web framework",            // id=2 — 'rust' only
1528
            "go embedded systems",           // id=3 — 'embedded' only
1529
            "python web framework",          // id=4 — neither
1530
            "rust rust rust embedded power", // id=5 — heavy on 'rust'
1531
        ] {
1532
            process_command(
1533
                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
1534
                &mut db,
1535
            )
1536
            .unwrap();
1537
        }
1538
        db
1✔
1539
    }
1540

1541
    #[test]
1542
    fn create_index_using_fts_succeeds_and_indexes_existing_rows() {
3✔
1543
        let mut db = seed_fts_table();
1✔
1544
        let resp =
2✔
1545
            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1546
        assert!(resp.to_lowercase().contains("create index"), "got {resp}");
2✔
1547
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1548
        assert_eq!(table.fts_indexes.len(), 1);
1✔
1549
        assert_eq!(table.fts_indexes[0].name, "ix_body");
1✔
1550
        assert_eq!(table.fts_indexes[0].column_name, "body");
1✔
1551
        // All five rows should be in the in-memory PostingList.
1552
        assert_eq!(table.fts_indexes[0].index.len(), 5);
1✔
1553
    }
1554

1555
    #[test]
1556
    fn create_index_using_fts_rejects_non_text_column() {
3✔
1557
        let mut db = Database::new("tempdb".to_string());
1✔
1558
        process_command(
1559
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
1560
            &mut db,
1561
        )
1562
        .unwrap();
1563
        let err = process_command("CREATE INDEX ix_n ON t USING fts (n);", &mut db).unwrap_err();
1✔
1564
        let msg = format!("{err}");
2✔
NEW
1565
        assert!(
×
1566
            msg.to_lowercase().contains("text"),
3✔
1567
            "expected error mentioning TEXT; got: {msg}"
1568
        );
1569
    }
1570

1571
    #[test]
1572
    fn fts_match_returns_expected_rows() {
3✔
1573
        let mut db = seed_fts_table();
1✔
1574
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1575
        // Rows that contain 'rust': ids 1, 2, 5.
1576
        let resp = process_command(
1577
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1578
            &mut db,
1579
        )
1580
        .unwrap();
1581
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1582
    }
1583

1584
    #[test]
1585
    fn fts_match_without_index_errors_clearly() {
3✔
1586
        let mut db = seed_fts_table();
1✔
1587
        // No CREATE INDEX — fts_match must surface a useful error.
1588
        let err = process_command(
1589
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1590
            &mut db,
1591
        )
1592
        .unwrap_err();
1593
        let msg = format!("{err}");
2✔
NEW
1594
        assert!(
×
1595
            msg.contains("no FTS index"),
2✔
1596
            "expected no-index error; got: {msg}"
1597
        );
1598
    }
1599

1600
    #[test]
1601
    fn bm25_score_orders_descending_by_relevance() {
3✔
1602
        let mut db = seed_fts_table();
1✔
1603
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1604
        // ORDER BY bm25_score DESC LIMIT 1: id=5 has 'rust' three times in
1605
        // a 5-token doc — highest tf, modest length penalty → top score.
1606
        let out = process_command_with_render(
1607
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1608
             ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1609
            &mut db,
1610
        )
1611
        .unwrap();
1612
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
1613
        let rendered = out.rendered.expect("SELECT should produce rendered output");
1✔
1614
        // The rendered prettytable contains the integer 5 in a cell.
NEW
1615
        assert!(
×
1616
            rendered.contains(" 5 "),
2✔
1617
            "expected id=5 to be top-ranked; rendered:\n{rendered}"
1618
        );
1619
    }
1620

1621
    #[test]
1622
    fn bm25_score_without_index_errors_clearly() {
3✔
1623
        let mut db = seed_fts_table();
1✔
1624
        let err = process_command(
1625
            "SELECT id FROM docs ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1626
            &mut db,
1627
        )
1628
        .unwrap_err();
1629
        let msg = format!("{err}");
2✔
NEW
1630
        assert!(
×
1631
            msg.contains("no FTS index"),
2✔
1632
            "expected no-index error; got: {msg}"
1633
        );
1634
    }
1635

1636
    #[test]
1637
    fn fts_post_create_inserts_are_indexed_incrementally() {
3✔
1638
        let mut db = seed_fts_table();
1✔
1639
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1640
        process_command(
1641
            "INSERT INTO docs (body) VALUES ('rust embedded analytics');",
1642
            &mut db,
1643
        )
1644
        .unwrap();
1645
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1646
        // PostingList::len() reports doc count; should be 6 now.
1647
        assert_eq!(table.fts_indexes[0].index.len(), 6);
1✔
1648
        // 'analytics' appears only in the new row → query returns 1 hit.
1649
        let resp = process_command(
1650
            "SELECT id FROM docs WHERE fts_match(body, 'analytics');",
1651
            &mut db,
1652
        )
1653
        .unwrap();
1654
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1655
    }
1656

1657
    #[test]
1658
    fn delete_on_fts_indexed_table_marks_dirty() {
3✔
1659
        let mut db = seed_fts_table();
1✔
1660
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1661
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1662
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
1663
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1664
        let entry = docs
3✔
1665
            .fts_indexes
1666
            .iter()
1667
            .find(|e| e.name == "ix_body")
3✔
1668
            .unwrap();
NEW
1669
        assert!(
×
1670
            entry.needs_rebuild,
1✔
1671
            "DELETE should have flagged the FTS index dirty"
1672
        );
1673
    }
1674

1675
    #[test]
1676
    fn update_on_fts_indexed_text_col_marks_dirty() {
3✔
1677
        let mut db = seed_fts_table();
1✔
1678
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1679
        let resp = process_command(
1680
            "UPDATE docs SET body = 'java spring framework' WHERE id = 1;",
1681
            &mut db,
1682
        )
1683
        .unwrap();
1684
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
1685
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1686
        let entry = docs
3✔
1687
            .fts_indexes
1688
            .iter()
1689
            .find(|e| e.name == "ix_body")
3✔
1690
            .unwrap();
NEW
1691
        assert!(
×
1692
            entry.needs_rebuild,
1✔
1693
            "UPDATE on the indexed TEXT column should have flagged dirty"
1694
        );
1695
    }
1696

1697
    #[test]
1698
    fn fts_index_name_collides_with_btree_and_hnsw_namespaces() {
3✔
1699
        let mut db = seed_fts_table();
1✔
1700
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1701
        let err = process_command("CREATE INDEX ix_body ON docs (body);", &mut db).unwrap_err();
1✔
1702
        let msg = format!("{err}");
2✔
NEW
1703
        assert!(
×
1704
            msg.to_lowercase().contains("already exists"),
3✔
1705
            "expected duplicate-index error; got: {msg}"
1706
        );
1707
    }
1708

1709
    #[test]
1710
    fn fts_index_rejects_unique() {
3✔
1711
        let mut db = seed_fts_table();
1✔
1712
        let err = process_command(
1713
            "CREATE UNIQUE INDEX ix_body ON docs USING fts (body);",
1714
            &mut db,
1715
        )
1716
        .unwrap_err();
1717
        let msg = format!("{err}");
2✔
NEW
1718
        assert!(
×
1719
            msg.to_lowercase().contains("unique"),
3✔
1720
            "expected UNIQUE-rejection error; got: {msg}"
1721
        );
1722
    }
1723

1724
    #[test]
1725
    fn try_fts_probe_falls_through_on_ascending() {
3✔
1726
        // BM25 is "higher = better"; ASC is rejected so the slow path
1727
        // applies. We verify by running the query and checking the
1728
        // result is still correct (the slow path goes through scalar
1729
        // bm25_score on every row).
1730
        let mut db = seed_fts_table();
1✔
1731
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1732
        // Same query as bm25_score_orders_descending but ASC → should
1733
        // still succeed (slow path), and id=5 should now be LAST.
1734
        let resp = process_command(
1735
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1736
             ORDER BY bm25_score(body, 'rust') ASC LIMIT 3;",
1737
            &mut db,
1738
        )
1739
        .unwrap();
1740
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1741
    }
1742

1743
    // -----------------------------------------------------------------
1744
    // Phase 7b — vector distance functions through process_command
1745
    // -----------------------------------------------------------------
1746

1747
    /// Builds a 3-row docs table with 2-dim vectors aligned along the
1748
    /// axes so the expected distances are easy to reason about:
1749
    ///   id=1: [1, 0]
1750
    ///   id=2: [0, 1]
1751
    ///   id=3: [1, 1]
1752
    fn seed_vector_docs() -> Database {
1✔
1753
        let mut db = Database::new("tempdb".to_string());
1✔
1754
        process_command(
1755
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1756
            &mut db,
1757
        )
1758
        .expect("create");
1759
        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1✔
1760
        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1✔
1761
        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1✔
1762
        db
1✔
1763
    }
1764

1765
    #[test]
1766
    fn vec_distance_l2_in_where_filters_correctly() {
3✔
1767
        // Distance from [1,0]:
1768
        //   id=1 [1,0]: 0
1769
        //   id=2 [0,1]: √2 ≈ 1.414
1770
        //   id=3 [1,1]: 1
1771
        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
1772
        let mut db = seed_vector_docs();
1✔
1773
        let resp = process_command(
1774
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
1775
            &mut db,
1776
        )
1777
        .expect("select");
1778
        assert!(
×
1779
            resp.contains("2 rows returned"),
2✔
1780
            "expected 2 rows, got: {resp}"
1781
        );
1782
    }
1783

1784
    #[test]
1785
    fn vec_distance_cosine_in_where() {
3✔
1786
        // [1,0] vs [1,0]: cosine distance = 0
1787
        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
1788
        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
1789
        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
1790
        let mut db = seed_vector_docs();
1✔
1791
        let resp = process_command(
1792
            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
1793
            &mut db,
1794
        )
1795
        .expect("select");
1796
        assert!(
×
1797
            resp.contains("2 rows returned"),
2✔
1798
            "expected 2 rows, got: {resp}"
1799
        );
1800
    }
1801

1802
    #[test]
1803
    fn vec_distance_dot_negated() {
3✔
1804
        // [1,0]·[1,0] = 1 → -1
1805
        // [1,0]·[0,1] = 0 → 0
1806
        // [1,0]·[1,1] = 1 → -1
1807
        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
1808
        let mut db = seed_vector_docs();
1✔
1809
        let resp = process_command(
1810
            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
1811
            &mut db,
1812
        )
1813
        .expect("select");
1814
        assert!(
×
1815
            resp.contains("2 rows returned"),
2✔
1816
            "expected 2 rows, got: {resp}"
1817
        );
1818
    }
1819

1820
    #[test]
1821
    fn knn_via_order_by_distance_limit() {
3✔
1822
        // Classic KNN shape: ORDER BY distance LIMIT k.
1823
        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
1824
        // LIMIT 2 should return id=1 then id=3 in that order.
1825
        let mut db = seed_vector_docs();
1✔
1826
        let resp = process_command(
1827
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
1828
            &mut db,
1829
        )
1830
        .expect("select");
1831
        assert!(
×
1832
            resp.contains("2 rows returned"),
2✔
1833
            "expected 2 rows, got: {resp}"
1834
        );
1835
    }
1836

1837
    #[test]
1838
    fn distance_function_dim_mismatch_errors() {
3✔
1839
        // 2-dim column queried with a 3-dim probe → clean error.
1840
        let mut db = seed_vector_docs();
1✔
1841
        let err = process_command(
1842
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
1843
            &mut db,
1844
        )
1845
        .unwrap_err();
1846
        let msg = format!("{err}");
2✔
1847
        assert!(
×
1848
            msg.to_lowercase().contains("dimension")
2✔
1849
                && msg.contains("lhs=2")
1✔
1850
                && msg.contains("rhs=3"),
1✔
1851
            "expected dim mismatch error, got: {msg}"
1852
        );
1853
    }
1854

1855
    #[test]
1856
    fn unknown_function_errors_with_name() {
3✔
1857
        // Use the function in WHERE, not projection — the projection
1858
        // parser still requires bare column references; function calls
1859
        // there are a future enhancement (with `AS alias` support).
1860
        let mut db = seed_vector_docs();
1✔
1861
        let err = process_command(
1862
            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
1863
            &mut db,
1864
        )
1865
        .unwrap_err();
1866
        let msg = format!("{err}");
2✔
1867
        assert!(
×
1868
            msg.contains("vec_does_not_exist"),
2✔
1869
            "expected error mentioning function name, got: {msg}"
1870
        );
1871
    }
1872

1873
    // -----------------------------------------------------------------
1874
    // Phase 7e — JSON column type + path-extraction functions
1875
    // -----------------------------------------------------------------
1876

1877
    fn seed_json_table() -> Database {
1✔
1878
        let mut db = Database::new("tempdb".to_string());
1✔
1879
        process_command(
1880
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
1881
            &mut db,
1882
        )
1883
        .expect("create json table");
1884
        db
1✔
1885
    }
1886

1887
    #[test]
1888
    fn json_column_round_trip_primitive_values() {
3✔
1889
        let mut db = seed_json_table();
1✔
1890
        process_command(
1891
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
1892
            &mut db,
1893
        )
1894
        .expect("insert json");
1895
        let docs = db.get_table("docs".to_string()).unwrap();
1✔
1896
        let rowids = docs.rowids();
1✔
1897
        assert_eq!(rowids.len(), 1);
2✔
1898
        // Stored verbatim as Text underneath.
1899
        match docs.get_value("payload", rowids[0]) {
1✔
1900
            Some(Value::Text(s)) => {
1✔
1901
                assert!(s.contains("alice"), "expected JSON text to round-trip: {s}");
2✔
1902
            }
1903
            other => panic!("expected Value::Text holding JSON, got {other:?}"),
×
1904
        }
1905
    }
1906

1907
    #[test]
1908
    fn json_insert_rejects_invalid_json() {
4✔
1909
        let mut db = seed_json_table();
1✔
1910
        let err = process_command(
1911
            "INSERT INTO docs (payload) VALUES ('not-valid-json{');",
1912
            &mut db,
1913
        )
1914
        .unwrap_err();
1915
        let msg = format!("{err}").to_lowercase();
2✔
1916
        assert!(
×
1917
            msg.contains("json") && msg.contains("payload"),
2✔
1918
            "expected JSON validation error mentioning column, got: {msg}"
1919
        );
1920
    }
1921

1922
    #[test]
1923
    fn json_extract_object_field() {
3✔
1924
        let mut db = seed_json_table();
1✔
1925
        process_command(
1926
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
1927
            &mut db,
1928
        )
1929
        .unwrap();
1930
        // We don't have function calls in projection (yet), so test
1931
        // the function via WHERE.
1932
        let resp = process_command(
1933
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
1934
            &mut db,
1935
        )
1936
        .expect("select via json_extract");
1937
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1938

1939
        let resp = process_command(
1940
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.age') = 30;"#,
1941
            &mut db,
1942
        )
1943
        .expect("select via numeric json_extract");
1944
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1945
    }
1946

1947
    #[test]
1948
    fn json_extract_array_index_and_nested() {
3✔
1949
        let mut db = seed_json_table();
1✔
1950
        process_command(
1951
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["rust", "sql", "vectors"], "meta": {"author": "joao"}}');"#,
1952
            &mut db,
1953
        )
1954
        .unwrap();
1955
        let resp = process_command(
1956
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.tags[0]') = 'rust';"#,
1957
            &mut db,
1958
        )
1959
        .expect("select via array index");
1960
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1961

1962
        let resp = process_command(
1963
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.meta.author') = 'joao';"#,
1964
            &mut db,
1965
        )
1966
        .expect("select via nested object");
1967
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1968
    }
1969

1970
    #[test]
1971
    fn json_extract_missing_path_returns_null() {
3✔
1972
        let mut db = seed_json_table();
1✔
1973
        process_command(
1974
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice"}');"#,
1975
            &mut db,
1976
        )
1977
        .unwrap();
1978
        // Missing key under WHERE returns NULL → predicate is false →
1979
        // 0 rows returned. (Standard SQL three-valued logic.)
1980
        let resp = process_command(
1981
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.missing') = 'something';"#,
1982
            &mut db,
1983
        )
1984
        .expect("select with missing path");
1985
        assert!(resp.contains("0 rows returned"), "got: {resp}");
2✔
1986
    }
1987

1988
    #[test]
1989
    fn json_extract_malformed_path_errors() {
3✔
1990
        let mut db = seed_json_table();
1✔
1991
        process_command(
1992
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
1993
            &mut db,
1994
        )
1995
        .unwrap();
1996
        // Path doesn't start with '$' — syntax error.
1997
        let err = process_command(
1998
            r#"SELECT id FROM docs WHERE json_extract(payload, 'a.b') = 1;"#,
1999
            &mut db,
2000
        )
2001
        .unwrap_err();
2002
        assert!(format!("{err}").contains("'$'"));
2✔
2003
    }
2004

2005
    #[test]
2006
    fn json_array_length_on_array() {
3✔
2007
        // Note: json_array_length used in WHERE clause where it can be
2008
        // compared; that exercises the function dispatch end-to-end.
2009
        let mut db = seed_json_table();
1✔
2010
        process_command(
2011
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["a", "b", "c"]}');"#,
2012
            &mut db,
2013
        )
2014
        .unwrap();
2015
        let resp = process_command(
2016
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 3;"#,
2017
            &mut db,
2018
        )
2019
        .expect("select via array_length");
2020
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2021
    }
2022

2023
    #[test]
2024
    fn json_array_length_on_non_array_errors() {
3✔
2025
        let mut db = seed_json_table();
1✔
2026
        process_command(
2027
            r#"INSERT INTO docs (payload) VALUES ('{"tags": "not-an-array"}');"#,
2028
            &mut db,
2029
        )
2030
        .unwrap();
2031
        let err = process_command(
2032
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 1;"#,
2033
            &mut db,
2034
        )
2035
        .unwrap_err();
2036
        let msg = format!("{err}").to_lowercase();
2✔
2037
        assert!(
×
2038
            msg.contains("non-array"),
1✔
2039
            "expected non-array error, got: {msg}"
2040
        );
2041
    }
2042

2043
    #[test]
2044
    fn json_type_recognizes_each_kind() {
3✔
2045
        let mut db = seed_json_table();
1✔
2046
        process_command(
2047
            r#"INSERT INTO docs (payload) VALUES ('{"o": {}, "a": [], "s": "x", "i": 1, "f": 1.5, "t": true, "n": null}');"#,
2048
            &mut db,
2049
        )
2050
        .unwrap();
2051
        let cases = &[
1✔
2052
            ("$.o", "object"),
2053
            ("$.a", "array"),
2054
            ("$.s", "text"),
2055
            ("$.i", "integer"),
2056
            ("$.f", "real"),
2057
            ("$.t", "true"),
2058
            ("$.n", "null"),
2059
        ];
2060
        for (path, expected_type) in cases {
1✔
2061
            let sql = format!(
2✔
2062
                "SELECT id FROM docs WHERE json_type(payload, '{path}') = '{expected_type}';"
2063
            );
2064
            let resp =
2✔
2065
                process_command(&sql, &mut db).unwrap_or_else(|e| panic!("path {path}: {e}"));
2066
            assert!(
×
2067
                resp.contains("1 row returned"),
2✔
2068
                "path {path} expected type {expected_type}; got response: {resp}"
2069
            );
2070
        }
2071
    }
2072

2073
    #[test]
2074
    fn update_on_json_column_revalidates() {
3✔
2075
        let mut db = seed_json_table();
1✔
2076
        process_command(
2077
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2078
            &mut db,
2079
        )
2080
        .unwrap();
2081
        // Valid JSON update succeeds.
2082
        process_command(
2083
            r#"UPDATE docs SET payload = '{"a": 2, "b": 3}' WHERE id = 1;"#,
2084
            &mut db,
2085
        )
2086
        .expect("valid JSON UPDATE");
2087
        // Invalid JSON in UPDATE is rejected with the same shape of
2088
        // error as INSERT.
2089
        let err = process_command(
2090
            r#"UPDATE docs SET payload = 'not-json{' WHERE id = 1;"#,
2091
            &mut db,
2092
        )
2093
        .unwrap_err();
2094
        let msg = format!("{err}").to_lowercase();
2✔
2095
        assert!(
×
2096
            msg.contains("json") && msg.contains("payload"),
2✔
2097
            "got: {msg}"
2098
        );
2099
    }
2100
}
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