Skip to main content

limbo/
lib.rs

1// UPSTREAM: vendored Limbo fork — allow upstream style
2//! Top-level facade of the C-free **oxisqlite** engine, a Pure-Rust fork of
3//! limbo 0.0.22 and entry point for the `oxisql-sqlite-compat` backend.
4//!
5//! Re-exports `Connection`, `Statement`, `params`/`params_from_iter`, and
6//! `Value` as a thin, ergonomic wrapper; bytecode execution, storage, and
7//! SQL processing all live in `oxisqlite-core`.
8#![allow(
9    rustdoc::bare_urls,
10    rustdoc::invalid_html_tags,
11    rustdoc::broken_intra_doc_links
12)]
13#![allow(
14    clippy::collapsible_match,
15    clippy::doc_overindented_list_items,
16    clippy::from_over_into
17)]
18
19pub mod params;
20pub mod value;
21
22pub use value::Value;
23
24pub use params::params_from_iter;
25
26use crate::params::*;
27use std::fmt::Debug;
28use std::num::NonZero;
29use std::sync::{Arc, Mutex};
30
31#[derive(Debug, thiserror::Error)]
32pub enum Error {
33    #[error("SQL conversion failure: `{0}`")]
34    ToSqlConversionFailure(BoxError),
35    #[error("Mutex lock error: {0}")]
36    MutexError(String),
37    #[error("SQL execution failure: `{0}`")]
38    SqlExecutionFailure(String),
39    /// The database schema changed after this statement was compiled (SQLITE_SCHEMA).
40    /// Re-prepare the statement and retry.
41    #[error("database schema has changed")]
42    SchemaChanged,
43}
44
45impl Error {
46    /// Returns `true` if this error signals that the database schema changed after
47    /// the statement was compiled.  Callers should re-prepare the statement
48    /// against the refreshed schema and retry.
49    pub fn is_schema_changed(&self) -> bool {
50        matches!(self, Error::SchemaChanged)
51    }
52}
53
54impl From<limbo_core::LimboError> for Error {
55    fn from(err: limbo_core::LimboError) -> Self {
56        match err {
57            limbo_core::LimboError::SchemaChanged => Error::SchemaChanged,
58            other => Error::SqlExecutionFailure(other.to_string()),
59        }
60    }
61}
62
63pub(crate) type BoxError = Box<dyn std::error::Error + Send + Sync>;
64
65pub type Result<T> = std::result::Result<T, Error>;
66pub struct Builder {
67    path: String,
68}
69
70impl Builder {
71    pub fn new_local(path: &str) -> Self {
72        Self {
73            path: path.to_string(),
74        }
75    }
76
77    #[allow(unused_variables, clippy::arc_with_non_send_sync)]
78    pub async fn build(self) -> Result<Database> {
79        match self.path.as_str() {
80            ":memory:" => {
81                let io: Arc<dyn limbo_core::IO> = Arc::new(limbo_core::MemoryIO::new());
82                let db = limbo_core::Database::open_file(io, self.path.as_str(), false)?;
83                Ok(Database { inner: db })
84            }
85            path => {
86                let io: Arc<dyn limbo_core::IO> = Arc::new(limbo_core::PlatformIO::new()?);
87                let db = limbo_core::Database::open_file(io, path, false)?;
88                Ok(Database { inner: db })
89            }
90        }
91    }
92}
93
94#[derive(Clone)]
95pub struct Database {
96    inner: Arc<limbo_core::Database>,
97}
98
99unsafe impl Send for Database {}
100unsafe impl Sync for Database {}
101
102impl Debug for Database {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("Database").finish()
105    }
106}
107
108impl Database {
109    pub fn connect(&self) -> Result<Connection> {
110        let conn = self.inner.connect()?;
111        #[allow(clippy::arc_with_non_send_sync)]
112        let connection = Connection {
113            inner: Arc::new(Mutex::new(conn)),
114        };
115        Ok(connection)
116    }
117}
118
119pub struct Connection {
120    inner: Arc<Mutex<Arc<limbo_core::Connection>>>,
121}
122
123impl Clone for Connection {
124    fn clone(&self) -> Self {
125        Self {
126            inner: Arc::clone(&self.inner),
127        }
128    }
129}
130
131unsafe impl Send for Connection {}
132unsafe impl Sync for Connection {}
133
134impl Connection {
135    pub async fn query(&self, sql: &str, params: impl IntoParams) -> Result<Rows> {
136        let mut stmt = self.prepare(sql).await?;
137        stmt.query(params).await
138    }
139
140    pub async fn execute(&self, sql: &str, params: impl IntoParams) -> Result<u64> {
141        let mut stmt = self.prepare(sql).await?;
142        stmt.execute(params).await
143    }
144
145    pub async fn prepare(&self, sql: &str) -> Result<Statement> {
146        let conn = self
147            .inner
148            .lock()
149            .map_err(|e| Error::MutexError(e.to_string()))?;
150
151        let stmt = conn.prepare(sql)?;
152
153        #[allow(clippy::arc_with_non_send_sync)]
154        let statement = Statement {
155            inner: Arc::new(Mutex::new(stmt)),
156        };
157        Ok(statement)
158    }
159
160    /// Return the number of rows changed by the most recent DML statement on
161    /// this connection.  Mirrors `sqlite3_changes()` semantics: DDL statements
162    /// and `BEGIN`/`COMMIT`/`ROLLBACK` return 0.
163    pub fn changes(&self) -> Result<i64> {
164        let conn = self
165            .inner
166            .lock()
167            .map_err(|e| Error::MutexError(e.to_string()))?;
168        Ok(conn.changes())
169    }
170
171    pub fn pragma_query<F>(&self, pragma_name: &str, mut f: F) -> Result<()>
172    where
173        F: FnMut(&Row) -> limbo_core::Result<()>,
174    {
175        let conn = self
176            .inner
177            .lock()
178            .map_err(|e| Error::MutexError(e.to_string()))?;
179
180        let rows: Vec<Row> = conn
181            .pragma_query(pragma_name)
182            .map_err(|e| Error::SqlExecutionFailure(e.to_string()))?
183            .iter()
184            .map(|row| row.iter().collect::<Row>())
185            .collect();
186
187        rows.iter().try_for_each(|row| {
188            f(row).map_err(|e| {
189                Error::SqlExecutionFailure(format!("Error executing user defined function: {}", e))
190            })
191        })?;
192        Ok(())
193    }
194}
195
196pub struct Statement {
197    inner: Arc<Mutex<limbo_core::Statement>>,
198}
199
200impl Clone for Statement {
201    fn clone(&self) -> Self {
202        Self {
203            inner: Arc::clone(&self.inner),
204        }
205    }
206}
207
208unsafe impl Send for Statement {}
209unsafe impl Sync for Statement {}
210
211impl Statement {
212    pub async fn query(&mut self, params: impl IntoParams) -> Result<Rows> {
213        let params = params.into_params()?;
214        match params {
215            params::Params::None => (),
216            params::Params::Positional(values) => {
217                for (i, value) in values.into_iter().enumerate() {
218                    let mut stmt = self
219                        .inner
220                        .lock()
221                        .map_err(|e| Error::MutexError(e.to_string()))?;
222                    if let Some(idx) = NonZero::new(i + 1) {
223                        stmt.bind_at(idx, value.into());
224                    }
225                }
226            }
227            params::Params::Named(_items) => todo!(),
228        }
229        #[allow(clippy::arc_with_non_send_sync)]
230        let rows = Rows {
231            inner: Arc::clone(&self.inner),
232        };
233        Ok(rows)
234    }
235
236    pub async fn execute(&mut self, params: impl IntoParams) -> Result<u64> {
237        {
238            // Reset the statement before executing
239            self.inner
240                .lock()
241                .map_err(|e| Error::MutexError(e.to_string()))?
242                .reset();
243        }
244        let params = params.into_params()?;
245        match params {
246            params::Params::None => (),
247            params::Params::Positional(values) => {
248                for (i, value) in values.into_iter().enumerate() {
249                    let mut stmt = self
250                        .inner
251                        .lock()
252                        .map_err(|e| Error::MutexError(e.to_string()))?;
253                    if let Some(idx) = NonZero::new(i + 1) {
254                        stmt.bind_at(idx, value.into());
255                    }
256                }
257            }
258            params::Params::Named(_items) => todo!(),
259        }
260        loop {
261            let mut stmt = self
262                .inner
263                .lock()
264                .map_err(|e| Error::MutexError(e.to_string()))?;
265            match stmt.step() {
266                Ok(limbo_core::StepResult::Row) => {
267                    // unexpected row during execution, error out.
268                    return Ok(2);
269                }
270                Ok(limbo_core::StepResult::Done) => {
271                    return Ok(0);
272                }
273                Ok(limbo_core::StepResult::IO) => {
274                    let _ = stmt.run_once();
275                    //return Ok(1);
276                }
277                Ok(limbo_core::StepResult::Busy) => {
278                    return Ok(4);
279                }
280                Ok(limbo_core::StepResult::Interrupt) => {
281                    return Ok(3);
282                }
283                Err(err) => {
284                    return Err(err.into());
285                }
286            }
287        }
288    }
289
290    pub fn columns(&self) -> Vec<Column> {
291        let Ok(stmt) = self.inner.lock() else {
292            return Vec::new();
293        };
294
295        let n = stmt.num_columns();
296
297        let mut cols = Vec::with_capacity(n);
298
299        for i in 0..n {
300            let name = stmt.get_column_name(i).into_owned();
301            let decl_type = stmt.get_column_decl_type(i).map(|s| s.into_owned());
302            cols.push(Column { name, decl_type });
303        }
304
305        cols
306    }
307}
308
309pub struct Column {
310    name: String,
311    decl_type: Option<String>,
312}
313
314impl Column {
315    pub fn name(&self) -> &str {
316        &self.name
317    }
318
319    pub fn decl_type(&self) -> Option<&str> {
320        self.decl_type.as_deref()
321    }
322}
323
324pub trait IntoValue {
325    fn into_value(self) -> Result<Value>;
326}
327
328#[derive(Debug, Clone)]
329pub enum Params {
330    None,
331    Positional(Vec<Value>),
332    Named(Vec<(String, Value)>),
333}
334pub struct Transaction {}
335
336pub struct Rows {
337    inner: Arc<Mutex<limbo_core::Statement>>,
338}
339
340impl Clone for Rows {
341    fn clone(&self) -> Self {
342        Self {
343            inner: Arc::clone(&self.inner),
344        }
345    }
346}
347
348unsafe impl Send for Rows {}
349unsafe impl Sync for Rows {}
350
351impl Rows {
352    pub async fn next(&mut self) -> Result<Option<Row>> {
353        loop {
354            let mut stmt = self
355                .inner
356                .lock()
357                .map_err(|e| Error::MutexError(e.to_string()))?;
358            match stmt.step() {
359                Ok(limbo_core::StepResult::Row) => {
360                    let row = stmt.row().ok_or_else(|| {
361                        Error::SqlExecutionFailure(
362                            "row unavailable after Row step result".to_string(),
363                        )
364                    })?;
365                    return Ok(Some(Row {
366                        values: row.get_values().map(|v| v.to_owned()).collect(),
367                    }));
368                }
369                Ok(limbo_core::StepResult::Done) => return Ok(None),
370                Ok(limbo_core::StepResult::IO) => {
371                    if let Err(e) = stmt.run_once() {
372                        return Err(e.into());
373                    }
374                    continue;
375                }
376                Ok(limbo_core::StepResult::Busy) => return Ok(None),
377                Ok(limbo_core::StepResult::Interrupt) => return Ok(None),
378                _ => return Ok(None),
379            }
380        }
381    }
382}
383
384#[derive(Debug)]
385pub struct Row {
386    values: Vec<limbo_core::Value>,
387}
388
389unsafe impl Send for Row {}
390unsafe impl Sync for Row {}
391
392impl Row {
393    pub fn get_value(&self, index: usize) -> Result<Value> {
394        let value = &self.values[index];
395        match value {
396            limbo_core::Value::Integer(i) => Ok(Value::Integer(*i)),
397            limbo_core::Value::Null => Ok(Value::Null),
398            limbo_core::Value::Float(f) => Ok(Value::Real(*f)),
399            limbo_core::Value::Text(text) => Ok(Value::Text(text.to_string())),
400            limbo_core::Value::Blob(items) => Ok(Value::Blob(items.to_vec())),
401        }
402    }
403
404    pub fn column_count(&self) -> usize {
405        self.values.len()
406    }
407}
408
409impl<'a> FromIterator<&'a limbo_core::Value> for Row {
410    fn from_iter<T: IntoIterator<Item = &'a limbo_core::Value>>(iter: T) -> Self {
411        let values = iter
412            .into_iter()
413            .map(|v| match v {
414                limbo_core::Value::Integer(i) => limbo_core::Value::Integer(*i),
415                limbo_core::Value::Null => limbo_core::Value::Null,
416                limbo_core::Value::Float(f) => limbo_core::Value::Float(*f),
417                limbo_core::Value::Text(s) => limbo_core::Value::Text(s.clone()),
418                limbo_core::Value::Blob(b) => limbo_core::Value::Blob(b.clone()),
419            })
420            .collect();
421
422        Row { values }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use tempfile::NamedTempFile;
430
431    #[tokio::test]
432    async fn test_database_persistence() -> Result<()> {
433        let temp_file = NamedTempFile::new().unwrap();
434        let db_path = temp_file.path().to_str().unwrap();
435
436        // First, create the database, a table, and insert some data
437        {
438            let db = Builder::new_local(db_path).build().await?;
439            let conn = db.connect()?;
440            conn.execute(
441                "CREATE TABLE test_persistence (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
442                (),
443            )
444            .await?;
445            conn.execute("INSERT INTO test_persistence (name) VALUES ('Alice');", ())
446                .await?;
447            conn.execute("INSERT INTO test_persistence (name) VALUES ('Bob');", ())
448                .await?;
449        } // db and conn are dropped here, simulating closing
450
451        // Now, re-open the database and check if the data is still there
452        let db = Builder::new_local(db_path).build().await?;
453        let conn = db.connect()?;
454
455        let mut rows = conn
456            .query("SELECT name FROM test_persistence ORDER BY id;", ())
457            .await?;
458
459        let row1 = rows.next().await?.expect("Expected first row");
460        assert_eq!(row1.get_value(0)?, Value::Text("Alice".to_string()));
461
462        let row2 = rows.next().await?.expect("Expected second row");
463        assert_eq!(row2.get_value(0)?, Value::Text("Bob".to_string()));
464
465        assert!(rows.next().await?.is_none(), "Expected no more rows");
466
467        Ok(())
468    }
469
470    #[tokio::test]
471    async fn test_database_persistence_many_frames() -> Result<()> {
472        let temp_file = NamedTempFile::new().unwrap();
473        let db_path = temp_file.path().to_str().unwrap();
474
475        const NUM_INSERTS: usize = 100;
476        const TARGET_STRING_LEN: usize = 1024; // 1KB
477
478        let mut original_data = Vec::with_capacity(NUM_INSERTS);
479        for i in 0..NUM_INSERTS {
480            let prefix = format!("test_string_{:04}_", i);
481            let padding_len = TARGET_STRING_LEN.saturating_sub(prefix.len());
482            let padding: String = "A".repeat(padding_len);
483            original_data.push(format!("{}{}", prefix, padding));
484        }
485
486        // First, create the database, a table, and insert many large strings
487        {
488            let db = Builder::new_local(db_path).build().await?;
489            let conn = db.connect()?;
490            conn.execute(
491                "CREATE TABLE test_large_persistence (id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL);",
492                (),
493            )
494            .await?;
495
496            for data_val in &original_data {
497                conn.execute(
498                    "INSERT INTO test_large_persistence (data) VALUES (?);",
499                    params::Params::Positional(vec![Value::Text(data_val.clone())]),
500                )
501                .await?;
502            }
503        } // db and conn are dropped here, simulating closing
504
505        // Now, re-open the database and check if the data is still there
506        let db = Builder::new_local(db_path).build().await?;
507        let conn = db.connect()?;
508
509        let mut rows = conn
510            .query("SELECT data FROM test_large_persistence ORDER BY id;", ())
511            .await?;
512
513        for (i, expected) in original_data.iter().enumerate().take(NUM_INSERTS) {
514            let row = rows
515                .next()
516                .await?
517                .unwrap_or_else(|| panic!("Expected row {} but found None", i));
518            assert_eq!(
519                row.get_value(0)?,
520                Value::Text(expected.clone()),
521                "Mismatch in retrieved data for row {}",
522                i
523            );
524        }
525
526        assert!(
527            rows.next().await?.is_none(),
528            "Expected no more rows after retrieving all inserted data"
529        );
530
531        // Delete the WAL file only and try to re-open and query
532        let wal_path = format!("{}-wal", db_path);
533        std::fs::remove_file(&wal_path)
534            .map_err(|e| eprintln!("Warning: Failed to delete WAL file for test: {}", e))
535            .unwrap();
536
537        // Re-open the database after deleting the WAL and assert the data is still
538        // fully intact. The clean close above (dropping the connection) triggers a
539        // checkpoint-on-close, which writes all WAL frames into the main `.db` file
540        // and truncates the WAL. As a result the `-wal` file is no longer
541        // load-bearing after a clean close: deleting it must NOT lose any data.
542        let db_after_wal_delete = Builder::new_local(db_path).build().await?;
543        let conn_after_wal_delete = db_after_wal_delete.connect()?;
544
545        let mut rows_after_wal_delete = conn_after_wal_delete
546            .query("SELECT data FROM test_large_persistence ORDER BY id;", ())
547            .await?;
548
549        for (i, expected) in original_data.iter().enumerate().take(NUM_INSERTS) {
550            let row = rows_after_wal_delete.next().await?.unwrap_or_else(|| {
551                panic!(
552                    "Expected row {} after WAL deletion but found None; \
553                         checkpoint-on-close should have persisted it into the main DB",
554                    i
555                )
556            });
557            assert_eq!(
558                row.get_value(0)?,
559                Value::Text(expected.clone()),
560                "Mismatch in retrieved data for row {} after WAL deletion",
561                i
562            );
563        }
564
565        assert!(
566            rows_after_wal_delete.next().await?.is_none(),
567            "Expected no more rows after WAL deletion once all checkpointed data was retrieved"
568        );
569
570        Ok(())
571    }
572
573    #[tokio::test]
574    async fn test_database_persistence_write_one_frame_many_times() -> Result<()> {
575        let temp_file = NamedTempFile::new().unwrap();
576        let db_path = temp_file.path().to_str().unwrap();
577
578        for i in 0..100 {
579            {
580                let db = Builder::new_local(db_path).build().await?;
581                let conn = db.connect()?;
582
583                conn.execute("CREATE TABLE IF NOT EXISTS test_persistence (id INTEGER PRIMARY KEY, name TEXT NOT NULL);", ()).await?;
584                conn.execute("INSERT INTO test_persistence (name) VALUES ('Alice');", ())
585                    .await?;
586            }
587            {
588                let db = Builder::new_local(db_path).build().await?;
589                let conn = db.connect()?;
590
591                let mut rows_iter = conn
592                    .query("SELECT count(*) FROM test_persistence;", ())
593                    .await?;
594                let rows = rows_iter.next().await?.unwrap();
595                assert_eq!(rows.get_value(0)?, Value::Integer(i as i64 + 1));
596                assert!(rows_iter.next().await?.is_none());
597            }
598        }
599
600        Ok(())
601    }
602
603    // ------------------------------------------------------------------
604    // A1: PRAGMA application_id
605    // ------------------------------------------------------------------
606
607    /// Read a single scalar integer value produced by a query (e.g. a PRAGMA).
608    async fn query_scalar_i64(conn: &Connection, sql: &str) -> Result<i64> {
609        let mut rows = conn.query(sql, ()).await?;
610        let row = rows
611            .next()
612            .await?
613            .unwrap_or_else(|| panic!("expected a row from `{sql}`"));
614        match row.get_value(0)? {
615            Value::Integer(i) => Ok(i),
616            other => panic!("expected Integer from `{sql}`, got {other:?}"),
617        }
618    }
619
620    #[tokio::test]
621    async fn test_application_id_write_read_round_trip() -> Result<()> {
622        let db = Builder::new_local(":memory:").build().await?;
623        let conn = db.connect()?;
624
625        // Default is 0.
626        assert_eq!(query_scalar_i64(&conn, "PRAGMA application_id;").await?, 0);
627
628        // GPKG magic (0x47504B47 = 1196444487), a large positive identifier.
629        conn.execute("PRAGMA application_id = 1196444487;", ())
630            .await?;
631        assert_eq!(
632            query_scalar_i64(&conn, "PRAGMA application_id;").await?,
633            1196444487
634        );
635
636        // Overwrite with another value.
637        conn.execute("PRAGMA application_id = 42;", ()).await?;
638        assert_eq!(query_scalar_i64(&conn, "PRAGMA application_id;").await?, 42);
639
640        Ok(())
641    }
642
643    #[tokio::test]
644    async fn test_application_id_negative_round_trip() -> Result<()> {
645        // SQLite presents application_id as a SIGNED 32-bit integer, so -1 must
646        // round-trip as -1 (not 4294967295).
647        let db = Builder::new_local(":memory:").build().await?;
648        let conn = db.connect()?;
649
650        conn.execute("PRAGMA application_id = -1;", ()).await?;
651        assert_eq!(query_scalar_i64(&conn, "PRAGMA application_id;").await?, -1);
652
653        conn.execute("PRAGMA application_id = -2147483648;", ())
654            .await?;
655        assert_eq!(
656            query_scalar_i64(&conn, "PRAGMA application_id;").await?,
657            -2147483648
658        );
659
660        Ok(())
661    }
662
663    /// Build a unique, file-backed database path under the OS temp directory.
664    ///
665    /// Uses [`std::env::temp_dir`] plus the process id and an atomically
666    /// incrementing counter so concurrently-running tests never collide, and
667    /// cleans up the database file together with its `-wal` sidecar on drop.
668    struct TempDbPath {
669        path: std::path::PathBuf,
670    }
671
672    impl TempDbPath {
673        fn new(tag: &str) -> Self {
674            use std::sync::atomic::{AtomicU64, Ordering};
675            static COUNTER: AtomicU64 = AtomicU64::new(0);
676            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
677            let mut path = std::env::temp_dir();
678            path.push(format!(
679                "oxisqlite_dur_{}_{}_{}.db",
680                tag,
681                std::process::id(),
682                n
683            ));
684            // Ensure a clean slate even if a previous run left files behind.
685            let _ = std::fs::remove_file(&path);
686            let _ = std::fs::remove_file(format!("{}-wal", path.display()));
687            Self { path }
688        }
689
690        fn as_str(&self) -> &str {
691            self.path
692                .to_str()
693                .expect("temp db path is valid UTF-8 on the test platforms")
694        }
695    }
696
697    impl Drop for TempDbPath {
698        fn drop(&mut self) {
699            let _ = std::fs::remove_file(&self.path);
700            let _ = std::fs::remove_file(format!("{}-wal", self.path.display()));
701        }
702    }
703
704    /// `application_id` survives a real close/reopen cycle for a file-backed
705    /// database (regression test for the header-cookie durability bug: the
706    /// in-memory header was previously re-read straight from the main DB file
707    /// at open time, bypassing the WAL, so a cookie that lived only in the WAL
708    /// reset to 0 on reopen).
709    #[tokio::test]
710    async fn test_application_id_persistence() -> Result<()> {
711        let temp = TempDbPath::new("app_id_persist");
712        let db_path = temp.as_str();
713
714        {
715            let db = Builder::new_local(db_path).build().await?;
716            let conn = db.connect()?;
717            conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY);", ())
718                .await?;
719            conn.execute("PRAGMA application_id = -12345;", ()).await?;
720
721            // Within the same open database the value (and its sign) is retained.
722            assert_eq!(
723                query_scalar_i64(&conn, "PRAGMA application_id;").await?,
724                -12345
725            );
726        } // connection + database dropped here, simulating a close.
727
728        // Reopen and assert the value is durably restored from the WAL.
729        let db = Builder::new_local(db_path).build().await?;
730        let conn = db.connect()?;
731        assert_eq!(
732            query_scalar_i64(&conn, "PRAGMA application_id;").await?,
733            -12345,
734            "application_id must survive close/reopen"
735        );
736
737        Ok(())
738    }
739
740    /// `application_id` set to a large positive identifier round-trips across a
741    /// close/reopen for a file-backed database.
742    #[tokio::test]
743    async fn test_application_id_durable_reopen() -> Result<()> {
744        let temp = TempDbPath::new("app_id_reopen");
745        let db_path = temp.as_str();
746
747        {
748            let db = Builder::new_local(db_path).build().await?;
749            let conn = db.connect()?;
750            conn.execute("PRAGMA application_id = 12345;", ()).await?;
751            assert_eq!(
752                query_scalar_i64(&conn, "PRAGMA application_id;").await?,
753                12345
754            );
755        }
756
757        let db = Builder::new_local(db_path).build().await?;
758        let conn = db.connect()?;
759        assert_eq!(
760            query_scalar_i64(&conn, "PRAGMA application_id;").await?,
761            12345,
762            "application_id = 12345 must survive close/reopen"
763        );
764
765        Ok(())
766    }
767
768    /// `user_version` (the canonical cookie mirror of `application_id`) survives
769    /// a close/reopen identically.
770    #[tokio::test]
771    async fn test_user_version_durable_reopen() -> Result<()> {
772        let temp = TempDbPath::new("user_version_reopen");
773        let db_path = temp.as_str();
774
775        {
776            let db = Builder::new_local(db_path).build().await?;
777            let conn = db.connect()?;
778            conn.execute("PRAGMA user_version = 12345;", ()).await?;
779            assert_eq!(
780                query_scalar_i64(&conn, "PRAGMA user_version;").await?,
781                12345
782            );
783        }
784
785        let db = Builder::new_local(db_path).build().await?;
786        let conn = db.connect()?;
787        assert_eq!(
788            query_scalar_i64(&conn, "PRAGMA user_version;").await?,
789            12345,
790            "user_version = 12345 must survive close/reopen"
791        );
792
793        Ok(())
794    }
795
796    /// A negative `application_id` (e.g. -1) is stored on disk as 0xFFFFFFFF but
797    /// must read back as the signed value -1 after a durable close/reopen, just
798    /// like SQLite.
799    #[tokio::test]
800    async fn test_application_id_negative_durable_reopen() -> Result<()> {
801        let temp = TempDbPath::new("app_id_negative_reopen");
802        let db_path = temp.as_str();
803
804        {
805            let db = Builder::new_local(db_path).build().await?;
806            let conn = db.connect()?;
807            conn.execute("PRAGMA application_id = -1;", ()).await?;
808            assert_eq!(query_scalar_i64(&conn, "PRAGMA application_id;").await?, -1);
809        }
810
811        let db = Builder::new_local(db_path).build().await?;
812        let conn = db.connect()?;
813        assert_eq!(
814            query_scalar_i64(&conn, "PRAGMA application_id;").await?,
815            -1,
816            "application_id = -1 must survive close/reopen as the signed value -1"
817        );
818
819        // And the on-disk bytes (after a checkpoint flushes the WAL into the
820        // main file) must be the 32-bit two's-complement big-endian 0xFFFFFFFF.
821        let conn = db.connect()?;
822        let _ = conn.execute("PRAGMA wal_checkpoint;", ()).await;
823        drop(conn);
824        drop(db);
825        let bytes = std::fs::read(db_path).expect("read database file");
826        assert!(bytes.len() >= 72, "database file shorter than the header");
827        assert_eq!(
828            &bytes[68..72],
829            &0xFFFF_FFFFu32.to_be_bytes(),
830            "application_id = -1 must be encoded as 0xFFFFFFFF at offset 68"
831        );
832
833        Ok(())
834    }
835
836    /// Byte-level GeoPackage check: writing the GPKG magic via
837    /// `PRAGMA application_id = 1196444487` (0x47504B47) and checkpointing must
838    /// land the big-endian magic at file offset 68, and a `user_version` write
839    /// must land at offset 60 — the exact layout GeoPackage requires.
840    #[tokio::test]
841    async fn test_application_id_byte_level_on_disk() -> Result<()> {
842        const GPKG_MAGIC: u32 = 1196444487; // 0x47504B47, "GPKG".
843        const USER_VERSION: i32 = 10201; // arbitrary GeoPackage-style version.
844
845        let temp = TempDbPath::new("app_id_bytes");
846        let db_path = temp.as_str();
847
848        {
849            let db = Builder::new_local(db_path).build().await?;
850            let conn = db.connect()?;
851            // A table forces real page allocation so the file is a valid db.
852            conn.execute("CREATE TABLE gpkg_contents (id INTEGER PRIMARY KEY);", ())
853                .await?;
854            conn.execute(&format!("PRAGMA application_id = {GPKG_MAGIC};"), ())
855                .await?;
856            conn.execute(&format!("PRAGMA user_version = {USER_VERSION};"), ())
857                .await?;
858            // Checkpoint so the WAL's page-1 frame is copied into the main
859            // database file: in WAL mode the header bytes only reach the main
860            // file after a checkpoint (this is the same requirement SQLite has
861            // for a byte-valid GeoPackage on disk).
862            let _ = conn.execute("PRAGMA wal_checkpoint;", ()).await;
863        }
864
865        let bytes = std::fs::read(db_path).expect("read database file");
866        assert!(
867            bytes.len() >= 72,
868            "database file is shorter than the 100-byte header"
869        );
870
871        // application_id at offset [68..72], big-endian == 0x47504B47.
872        assert_eq!(
873            &bytes[68..72],
874            &GPKG_MAGIC.to_be_bytes(),
875            "GPKG magic must be stored big-endian at file offset 68"
876        );
877        assert_eq!(
878            u32::from_be_bytes([bytes[68], bytes[69], bytes[70], bytes[71]]),
879            0x4750_4B47,
880            "application_id bytes must decode to 0x47504B47"
881        );
882
883        // user_version at offset [60..64], big-endian.
884        assert_eq!(
885            &bytes[60..64],
886            &USER_VERSION.to_be_bytes(),
887            "user_version must be stored big-endian at file offset 60"
888        );
889
890        // The value is also readable through PRAGMA after reopen.
891        let db = Builder::new_local(db_path).build().await?;
892        let conn = db.connect()?;
893        assert_eq!(
894            query_scalar_i64(&conn, "PRAGMA application_id;").await?,
895            GPKG_MAGIC as i64
896        );
897        assert_eq!(
898            query_scalar_i64(&conn, "PRAGMA user_version;").await?,
899            USER_VERSION as i64
900        );
901
902        Ok(())
903    }
904
905    // ------------------------------------------------------------------
906    // A2: INSERT OR IGNORE
907    // ------------------------------------------------------------------
908
909    #[tokio::test]
910    async fn test_insert_or_ignore_rowid_conflict_skipped() -> Result<()> {
911        let db = Builder::new_local(":memory:").build().await?;
912        let conn = db.connect()?;
913        conn.execute(
914            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
915            (),
916        )
917        .await?;
918        conn.execute("INSERT INTO t (id, name) VALUES (1, 'Alice');", ())
919            .await?;
920
921        // Conflicting rowid is silently ignored, not an error.
922        conn.execute("INSERT OR IGNORE INTO t (id, name) VALUES (1, 'Bob');", ())
923            .await?;
924
925        // Original row is untouched.
926        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 1);
927        let mut rows = conn.query("SELECT name FROM t WHERE id = 1;", ()).await?;
928        let row = rows.next().await?.expect("row");
929        assert_eq!(row.get_value(0)?, Value::Text("Alice".to_string()));
930
931        Ok(())
932    }
933
934    #[tokio::test]
935    async fn test_insert_or_ignore_multi_row_other_rows_land() -> Result<()> {
936        let db = Builder::new_local(":memory:").build().await?;
937        let conn = db.connect()?;
938        conn.execute(
939            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
940            (),
941        )
942        .await?;
943        conn.execute("INSERT INTO t (id, name) VALUES (2, 'Two');", ())
944            .await?;
945
946        // Multi-row INSERT OR IGNORE: row id=2 conflicts and is skipped, but ids
947        // 1 and 3 must still land.
948        conn.execute(
949            "INSERT OR IGNORE INTO t (id, name) VALUES (1, 'One'), (2, 'Dup'), (3, 'Three');",
950            (),
951        )
952        .await?;
953
954        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 3);
955        // The conflicting row keeps its original value.
956        let mut rows = conn.query("SELECT name FROM t WHERE id = 2;", ()).await?;
957        assert_eq!(
958            rows.next().await?.expect("row").get_value(0)?,
959            Value::Text("Two".to_string())
960        );
961        // The non-conflicting rows are present.
962        let mut rows = conn.query("SELECT id FROM t ORDER BY id;", ()).await?;
963        assert_eq!(
964            rows.next().await?.expect("row").get_value(0)?,
965            Value::Integer(1)
966        );
967        assert_eq!(
968            rows.next().await?.expect("row").get_value(0)?,
969            Value::Integer(2)
970        );
971        assert_eq!(
972            rows.next().await?.expect("row").get_value(0)?,
973            Value::Integer(3)
974        );
975
976        Ok(())
977    }
978
979    #[cfg(feature = "index_experimental")]
980    #[tokio::test]
981    async fn test_insert_or_ignore_unique_index_conflict_skipped() -> Result<()> {
982        let db = Builder::new_local(":memory:").build().await?;
983        let conn = db.connect()?;
984        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT);", ())
985            .await?;
986        conn.execute("CREATE UNIQUE INDEX idx_email ON t (email);", ())
987            .await?;
988        conn.execute("INSERT INTO t (id, email) VALUES (1, 'a@example.com');", ())
989            .await?;
990
991        // Different rowid but conflicting unique-index value -> skipped, no error
992        // and crucially no partial index/table state.
993        conn.execute(
994            "INSERT OR IGNORE INTO t (id, email) VALUES (2, 'a@example.com');",
995            (),
996        )
997        .await?;
998
999        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 1);
1000        // Row id=2 must NOT exist.
1001        let mut rows = conn.query("SELECT id FROM t ORDER BY id;", ()).await?;
1002        assert_eq!(
1003            rows.next().await?.expect("row").get_value(0)?,
1004            Value::Integer(1)
1005        );
1006        assert!(rows.next().await?.is_none());
1007
1008        Ok(())
1009    }
1010
1011    // ------------------------------------------------------------------
1012    // A3: INSERT OR REPLACE
1013    // ------------------------------------------------------------------
1014
1015    #[tokio::test]
1016    async fn test_insert_or_replace_rowid_conflict_replaces() -> Result<()> {
1017        let db = Builder::new_local(":memory:").build().await?;
1018        let conn = db.connect()?;
1019        conn.execute(
1020            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
1021            (),
1022        )
1023        .await?;
1024        conn.execute("INSERT INTO t (id, name) VALUES (1, 'Alice');", ())
1025            .await?;
1026
1027        // Same rowid -> old row replaced by new one.
1028        conn.execute("INSERT OR REPLACE INTO t (id, name) VALUES (1, 'Bob');", ())
1029            .await?;
1030
1031        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 1);
1032        let mut rows = conn.query("SELECT name FROM t WHERE id = 1;", ()).await?;
1033        assert_eq!(
1034            rows.next().await?.expect("row").get_value(0)?,
1035            Value::Text("Bob".to_string())
1036        );
1037
1038        Ok(())
1039    }
1040
1041    #[tokio::test]
1042    async fn test_insert_or_replace_multi_row_conflict_with_prior_row() -> Result<()> {
1043        let db = Builder::new_local(":memory:").build().await?;
1044        let conn = db.connect()?;
1045        conn.execute(
1046            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
1047            (),
1048        )
1049        .await?;
1050
1051        // Row N (id=1, 'Second') conflicts with just-inserted row N-1 (id=1,
1052        // 'First') within the same multi-row statement -> the later one wins.
1053        conn.execute(
1054            "INSERT OR REPLACE INTO t (id, name) VALUES (1, 'First'), (1, 'Second'), (2, 'Other');",
1055            (),
1056        )
1057        .await?;
1058
1059        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 2);
1060        let mut rows = conn.query("SELECT name FROM t WHERE id = 1;", ()).await?;
1061        assert_eq!(
1062            rows.next().await?.expect("row").get_value(0)?,
1063            Value::Text("Second".to_string())
1064        );
1065
1066        Ok(())
1067    }
1068
1069    #[cfg(feature = "index_experimental")]
1070    #[tokio::test]
1071    async fn test_insert_or_replace_single_unique_index_conflict() -> Result<()> {
1072        let db = Builder::new_local(":memory:").build().await?;
1073        let conn = db.connect()?;
1074        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT);", ())
1075            .await?;
1076        conn.execute("CREATE UNIQUE INDEX idx_email ON t (email);", ())
1077            .await?;
1078        conn.execute("INSERT INTO t (id, email) VALUES (1, 'a@example.com');", ())
1079            .await?;
1080
1081        // New rowid (2) but conflicting unique-index value -> the victim (id=1)
1082        // is deleted and replaced by the new row (id=2).
1083        conn.execute(
1084            "INSERT OR REPLACE INTO t (id, email) VALUES (2, 'a@example.com');",
1085            (),
1086        )
1087        .await?;
1088
1089        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 1);
1090        // Only id=2 remains, and the unique index still resolves it.
1091        let mut rows = conn
1092            .query("SELECT id FROM t WHERE email = 'a@example.com';", ())
1093            .await?;
1094        assert_eq!(
1095            rows.next().await?.expect("row").get_value(0)?,
1096            Value::Integer(2)
1097        );
1098        assert!(rows.next().await?.is_none());
1099
1100        Ok(())
1101    }
1102
1103    #[cfg(feature = "index_experimental")]
1104    #[tokio::test]
1105    async fn test_insert_or_replace_multiple_unique_indexes_different_victims() -> Result<()> {
1106        // SQLite OR REPLACE semantics: a new row that conflicts on MULTIPLE
1107        // unique indexes pointing at DIFFERENT existing rows must delete EVERY
1108        // victim, leaving exactly the new row.
1109        let db = Builder::new_local(":memory:").build().await?;
1110        let conn = db.connect()?;
1111        conn.execute(
1112            "CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT);",
1113            (),
1114        )
1115        .await?;
1116        conn.execute("CREATE UNIQUE INDEX idx_a ON t (a);", ())
1117            .await?;
1118        conn.execute("CREATE UNIQUE INDEX idx_b ON t (b);", ())
1119            .await?;
1120
1121        // Two distinct existing rows; the new row collides with row 1 on column a
1122        // and with row 2 on column b.
1123        conn.execute("INSERT INTO t (id, a, b) VALUES (1, 'A1', 'B1');", ())
1124            .await?;
1125        conn.execute("INSERT INTO t (id, a, b) VALUES (2, 'A2', 'B2');", ())
1126            .await?;
1127
1128        conn.execute(
1129            "INSERT OR REPLACE INTO t (id, a, b) VALUES (3, 'A1', 'B2');",
1130            (),
1131        )
1132        .await?;
1133
1134        // Both victims (id=1 and id=2) are gone; exactly the new row remains.
1135        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 1);
1136        let mut rows = conn.query("SELECT id, a, b FROM t;", ()).await?;
1137        let row = rows.next().await?.expect("row");
1138        assert_eq!(row.get_value(0)?, Value::Integer(3));
1139        assert_eq!(row.get_value(1)?, Value::Text("A1".to_string()));
1140        assert_eq!(row.get_value(2)?, Value::Text("B2".to_string()));
1141        assert!(rows.next().await?.is_none());
1142
1143        // Indexes resolve only the surviving row.
1144        assert_eq!(
1145            query_scalar_i64(&conn, "SELECT id FROM t WHERE a = 'A1';").await?,
1146            3
1147        );
1148        assert_eq!(
1149            query_scalar_i64(&conn, "SELECT id FROM t WHERE b = 'B2';").await?,
1150            3
1151        );
1152
1153        Ok(())
1154    }
1155
1156    // ------------------------------------------------------------------
1157    // Regression: plain INSERT conflict must still error (no Halt regression).
1158    // ------------------------------------------------------------------
1159
1160    #[tokio::test]
1161    async fn test_plain_insert_rowid_conflict_still_errors() -> Result<()> {
1162        let db = Builder::new_local(":memory:").build().await?;
1163        let conn = db.connect()?;
1164        conn.execute(
1165            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
1166            (),
1167        )
1168        .await?;
1169        conn.execute("INSERT INTO t (id, name) VALUES (1, 'Alice');", ())
1170            .await?;
1171
1172        // A plain INSERT (no OR clause) on a duplicate rowid must still fail.
1173        let result = conn
1174            .execute("INSERT INTO t (id, name) VALUES (1, 'Bob');", ())
1175            .await;
1176        assert!(
1177            result.is_err(),
1178            "plain INSERT on duplicate PRIMARY KEY must error, got Ok"
1179        );
1180
1181        // The original row is intact and no second row was written.
1182        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 1);
1183
1184        Ok(())
1185    }
1186
1187    // ------------------------------------------------------------------
1188    // Regression: orphaned-WAL row duplication.
1189    //
1190    // A previous session leaves a populated `-wal` behind; the main `.db`
1191    // file is then deleted (or otherwise recreated empty) while the `-wal`
1192    // survives. On reopen the engine must NOT replay that orphaned WAL — doing
1193    // so resurrects the previous session's committed pages on top of the fresh
1194    // database, so every row count grows by the stale content on each reopen.
1195    //
1196    // This mirrors the downstream `oxiaero-ros2` rosbag2 roundtrip failure:
1197    // AUTOINCREMENT PRIMARY KEY + two NON-UNIQUE secondary indexes + a BLOB
1198    // column, two single-row INSERTs, then a `SELECT ... ORDER BY <indexed col>`
1199    // read-back that returned 4 (then 6, 8, ...) rows instead of 2 because the
1200    // index-driven scan walked the resurrected + new index entries.
1201    //
1202    // Index maintenance only runs under `index_experimental` (a plain INSERT
1203    // into an indexed table is rejected without it — exactly the feature the
1204    // `oxisql-sqlite-compat` consumer enables), so these tests are gated on it.
1205    // ------------------------------------------------------------------
1206
1207    /// Count rows returned by `sql` by draining the cursor (works for both
1208    /// table scans and index-driven scans like `ORDER BY <indexed column>`).
1209    #[cfg(feature = "index_experimental")]
1210    async fn query_row_count(conn: &Connection, sql: &str) -> Result<i64> {
1211        let mut rows = conn.query(sql, ()).await?;
1212        let mut n = 0i64;
1213        while rows.next().await?.is_some() {
1214            n += 1;
1215        }
1216        Ok(n)
1217    }
1218
1219    /// Apply the exact consumer schema (idempotent — `IF NOT EXISTS`) to `conn`.
1220    #[cfg(feature = "index_experimental")]
1221    async fn create_messages_schema(conn: &Connection) -> Result<()> {
1222        conn.execute(
1223            "CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, topic TEXT NOT NULL, timestamp INTEGER NOT NULL, data BLOB NOT NULL);",
1224            (),
1225        )
1226        .await?;
1227        conn.execute(
1228            "CREATE INDEX IF NOT EXISTS idx_messages_topic ON messages (topic);",
1229            (),
1230        )
1231        .await?;
1232        conn.execute(
1233            "CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages (timestamp);",
1234            (),
1235        )
1236        .await?;
1237        Ok(())
1238    }
1239
1240    /// Insert one message row via positional parameters (mirrors the consumer's
1241    /// `INSERT INTO messages (topic, timestamp, data) VALUES (?, ?, ?)`).
1242    #[cfg(feature = "index_experimental")]
1243    async fn insert_message(
1244        conn: &Connection,
1245        topic: &str,
1246        timestamp: i64,
1247        data: Vec<u8>,
1248    ) -> Result<()> {
1249        conn.execute(
1250            "INSERT INTO messages (topic, timestamp, data) VALUES (?, ?, ?);",
1251            params::Params::Positional(vec![
1252                Value::Text(topic.to_string()),
1253                Value::Integer(timestamp),
1254                Value::Blob(data),
1255            ]),
1256        )
1257        .await?;
1258        Ok(())
1259    }
1260
1261    /// The full downstream consumer reproduction: write 2 rows to a file-backed
1262    /// DB, delete ONLY the main `.db` (leaving the populated `-wal`, exactly what
1263    /// the consumer's test harness does between runs), recreate + write 2 rows
1264    /// again, then read back. Must be exactly 2 rows — both via a plain table
1265    /// scan AND via the consumer's `ORDER BY timestamp` (index-driven) read — and
1266    /// must not accumulate across repeated cycles.
1267    #[cfg(feature = "index_experimental")]
1268    #[tokio::test]
1269    async fn test_orphaned_wal_does_not_duplicate_rows_two_indexes() -> Result<()> {
1270        let dir = std::env::temp_dir().join(format!(
1271            "oxisqlite_orphan_wal_{}_{:?}",
1272            std::process::id(),
1273            std::thread::current().id()
1274        ));
1275        let _ = std::fs::remove_dir_all(&dir);
1276        std::fs::create_dir_all(&dir).expect("create temp dir");
1277        let db_path = dir.join("messages.db3");
1278        let p = db_path.to_str().expect("utf-8 path").to_string();
1279
1280        // One write-then-readback cycle that recreates the DB while leaving any
1281        // pre-existing `-wal` in place.
1282        async fn cycle(p: &str) -> Result<(i64, i64)> {
1283            // Recreate the main DB file but keep a stale `-wal` if present.
1284            let _ = std::fs::remove_file(p);
1285            {
1286                let db = Builder::new_local(p).build().await?;
1287                let conn = db.connect()?;
1288                create_messages_schema(&conn).await?;
1289                insert_message(&conn, "/imu", 1_000_000_000, vec![0xDE, 0xAD]).await?;
1290                insert_message(&conn, "/gps", 2_000_000_000, vec![0xBE, 0xEF]).await?;
1291            }
1292            let db = Builder::new_local(p).build().await?;
1293            let conn = db.connect()?;
1294            create_messages_schema(&conn).await?; // consumer re-runs schema on open
1295            let scan =
1296                query_row_count(&conn, "SELECT timestamp, topic, data FROM messages;").await?;
1297            let ordered = query_row_count(
1298                &conn,
1299                "SELECT timestamp, topic, data FROM messages ORDER BY timestamp;",
1300            )
1301            .await?;
1302            Ok((scan, ordered))
1303        }
1304
1305        // First cycle starts clean.
1306        let (scan1, ord1) = cycle(&p).await?;
1307        assert_eq!(scan1, 2, "cycle 1 table scan");
1308        assert_eq!(ord1, 2, "cycle 1 ORDER BY timestamp (index scan)");
1309
1310        // Subsequent cycles each find a populated stale `-wal`; the orphaned WAL
1311        // must be discarded, so counts stay at 2 (pre-fix they were 4, 6, ...).
1312        for c in 2..=3 {
1313            let (scan, ord) = cycle(&p).await?;
1314            assert_eq!(scan, 2, "cycle {c} table scan must stay 2");
1315            assert_eq!(ord, 2, "cycle {c} ORDER BY timestamp must stay 2");
1316        }
1317
1318        let _ = std::fs::remove_dir_all(&dir);
1319        Ok(())
1320    }
1321
1322    /// Consumer-equivalent roundtrip on a clean DB: AUTOINCREMENT + 2 non-unique
1323    /// indexes + BLOB, 2 writes -> exactly 2 rows, with correct column values via
1324    /// the index-driven `ORDER BY timestamp` read.
1325    #[cfg(feature = "index_experimental")]
1326    #[tokio::test]
1327    async fn test_two_non_unique_indexes_roundtrip_values() -> Result<()> {
1328        let db = Builder::new_local(":memory:").build().await?;
1329        let conn = db.connect()?;
1330        create_messages_schema(&conn).await?;
1331        insert_message(&conn, "/imu", 1_000_000_000, vec![0xDE, 0xAD]).await?;
1332        insert_message(&conn, "/gps", 2_000_000_000, vec![0xBE, 0xEF]).await?;
1333
1334        assert_eq!(
1335            query_scalar_i64(&conn, "SELECT count(*) FROM messages;").await?,
1336            2
1337        );
1338
1339        let mut rows = conn
1340            .query(
1341                "SELECT timestamp, topic, data FROM messages ORDER BY timestamp;",
1342                (),
1343            )
1344            .await?;
1345        let r0 = rows.next().await?.expect("row 0");
1346        assert_eq!(r0.get_value(0)?, Value::Integer(1_000_000_000));
1347        assert_eq!(r0.get_value(1)?, Value::Text("/imu".to_string()));
1348        assert_eq!(r0.get_value(2)?, Value::Blob(vec![0xDE, 0xAD]));
1349        let r1 = rows.next().await?.expect("row 1");
1350        assert_eq!(r1.get_value(0)?, Value::Integer(2_000_000_000));
1351        assert_eq!(r1.get_value(1)?, Value::Text("/gps".to_string()));
1352        assert_eq!(r1.get_value(2)?, Value::Blob(vec![0xBE, 0xEF]));
1353        assert!(rows.next().await?.is_none(), "exactly two rows");
1354
1355        Ok(())
1356    }
1357
1358    /// Index-count matrix: 0/1/2/3 NON-UNIQUE secondary indexes, two single-row
1359    /// INSERTs each -> exactly 2 rows, verified by BOTH a plain table scan and an
1360    /// index-driven `ORDER BY a` scan (which is what surfaces duplicate index
1361    /// entries).
1362    #[cfg(feature = "index_experimental")]
1363    #[tokio::test]
1364    async fn test_index_count_matrix_single_inserts() -> Result<()> {
1365        for n_idx in 0..=3usize {
1366            let db = Builder::new_local(":memory:").build().await?;
1367            let conn = db.connect()?;
1368            conn.execute(
1369                "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, a INTEGER, b INTEGER, c INTEGER);",
1370                (),
1371            )
1372            .await?;
1373            let cols = ["a", "b", "c"];
1374            for col in cols.iter().take(n_idx) {
1375                conn.execute(&format!("CREATE INDEX idx_{col} ON t ({col});"), ())
1376                    .await?;
1377            }
1378            conn.execute("INSERT INTO t (a, b, c) VALUES (1, 1, 1);", ())
1379                .await?;
1380            conn.execute("INSERT INTO t (a, b, c) VALUES (2, 2, 2);", ())
1381                .await?;
1382
1383            assert_eq!(
1384                query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?,
1385                2,
1386                "n_idx={n_idx}: count(*)"
1387            );
1388            assert_eq!(
1389                query_row_count(&conn, "SELECT a FROM t;").await?,
1390                2,
1391                "n_idx={n_idx}: table scan"
1392            );
1393            assert_eq!(
1394                query_row_count(&conn, "SELECT a FROM t ORDER BY a;").await?,
1395                2,
1396                "n_idx={n_idx}: ORDER BY a (index scan)"
1397            );
1398        }
1399        Ok(())
1400    }
1401
1402    /// Multi-row `INSERT ... VALUES (..),(..),(..)` into a table with two
1403    /// non-unique indexes -> exactly 3 rows (table scan and index scan agree).
1404    #[cfg(feature = "index_experimental")]
1405    #[tokio::test]
1406    async fn test_multi_row_insert_two_indexes() -> Result<()> {
1407        let db = Builder::new_local(":memory:").build().await?;
1408        let conn = db.connect()?;
1409        conn.execute(
1410            "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, a INTEGER, b INTEGER);",
1411            (),
1412        )
1413        .await?;
1414        conn.execute("CREATE INDEX idx_a ON t (a);", ()).await?;
1415        conn.execute("CREATE INDEX idx_b ON t (b);", ()).await?;
1416        conn.execute("INSERT INTO t (a, b) VALUES (1, 10), (2, 20), (3, 30);", ())
1417            .await?;
1418
1419        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 3);
1420        assert_eq!(query_row_count(&conn, "SELECT a FROM t;").await?, 3);
1421        assert_eq!(
1422            query_row_count(&conn, "SELECT a FROM t ORDER BY a;").await?,
1423            3
1424        );
1425        assert_eq!(
1426            query_row_count(&conn, "SELECT b FROM t ORDER BY b;").await?,
1427            3
1428        );
1429        Ok(())
1430    }
1431
1432    /// `INSERT OR IGNORE` into a table with two non-unique indexes: a plain
1433    /// (non-unique) secondary index never causes a conflict, so all rows land
1434    /// exactly once.
1435    #[cfg(feature = "index_experimental")]
1436    #[tokio::test]
1437    async fn test_insert_or_ignore_two_non_unique_indexes() -> Result<()> {
1438        let db = Builder::new_local(":memory:").build().await?;
1439        let conn = db.connect()?;
1440        conn.execute(
1441            "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, a INTEGER, b INTEGER);",
1442            (),
1443        )
1444        .await?;
1445        conn.execute("CREATE INDEX idx_a ON t (a);", ()).await?;
1446        conn.execute("CREATE INDEX idx_b ON t (b);", ()).await?;
1447
1448        // Duplicate (a,b) values are fine for non-unique indexes; nothing is
1449        // ignored and nothing is duplicated.
1450        conn.execute("INSERT OR IGNORE INTO t (a, b) VALUES (1, 10);", ())
1451            .await?;
1452        conn.execute("INSERT OR IGNORE INTO t (a, b) VALUES (1, 10);", ())
1453            .await?;
1454
1455        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 2);
1456        assert_eq!(
1457            query_row_count(&conn, "SELECT a FROM t ORDER BY a;").await?,
1458            2
1459        );
1460        Ok(())
1461    }
1462
1463    /// `INSERT OR REPLACE` into a table that has both a UNIQUE index and a
1464    /// secondary NON-UNIQUE index: replacing on the unique-index conflict must
1465    /// delete the victim's entry from EVERY index, leaving exactly one row and
1466    /// no duplicate index entries.
1467    #[cfg(feature = "index_experimental")]
1468    #[tokio::test]
1469    async fn test_insert_or_replace_unique_plus_non_unique_index() -> Result<()> {
1470        let db = Builder::new_local(":memory:").build().await?;
1471        let conn = db.connect()?;
1472        conn.execute(
1473            "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, tag INTEGER);",
1474            (),
1475        )
1476        .await?;
1477        conn.execute("CREATE UNIQUE INDEX idx_email ON t (email);", ())
1478            .await?;
1479        conn.execute("CREATE INDEX idx_tag ON t (tag);", ()).await?;
1480        conn.execute(
1481            "INSERT INTO t (id, email, tag) VALUES (1, 'a@example.com', 7);",
1482            (),
1483        )
1484        .await?;
1485
1486        // New rowid, same unique email -> victim id=1 replaced by id=2.
1487        conn.execute(
1488            "INSERT OR REPLACE INTO t (id, email, tag) VALUES (2, 'a@example.com', 9);",
1489            (),
1490        )
1491        .await?;
1492
1493        assert_eq!(query_scalar_i64(&conn, "SELECT count(*) FROM t;").await?, 1);
1494        // The non-unique secondary index must resolve only the surviving row
1495        // (no orphaned victim entry left behind).
1496        assert_eq!(
1497            query_row_count(&conn, "SELECT id FROM t ORDER BY tag;").await?,
1498            1
1499        );
1500        assert_eq!(
1501            query_scalar_i64(&conn, "SELECT id FROM t WHERE email = 'a@example.com';").await?,
1502            2
1503        );
1504        assert_eq!(
1505            query_scalar_i64(&conn, "SELECT id FROM t WHERE tag = 9;").await?,
1506            2
1507        );
1508        // The old tag value must no longer resolve any row.
1509        assert_eq!(
1510            query_row_count(&conn, "SELECT id FROM t WHERE tag = 7;").await?,
1511            0
1512        );
1513        Ok(())
1514    }
1515}