Skip to main content

monarch_db/
lib.rs

1//! # Monarch-DB
2//!
3//! Monarch-DB is a lightweight SQLite database migration tool designed to run whenever the first
4//! connection in an app opens. It provides a simple, reliable way to manage SQLite database
5//! schema evolution in Rust applications.
6//!
7//! ## Quick Start
8//!
9//! ```rust
10//! use monarch_db::{StaticMonarchConfiguration, MonarchDB, ConnectionConfiguration};
11//!
12//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
13//! // Define your migrations at compile time
14//! let config = StaticMonarchConfiguration {
15//!     name: "my_app",
16//!     enable_foreign_keys: true,
17//!     migrations: [
18//!         // Migration 1: Create users table
19//!         r#"
20//!         CREATE TABLE users (
21//!             id INTEGER PRIMARY KEY AUTOINCREMENT,
22//!             username TEXT NOT NULL UNIQUE,
23//!             email TEXT NOT NULL,
24//!             created_at DATETIME DEFAULT CURRENT_TIMESTAMP
25//!         );
26//!         "#,
27//!         // Migration 2: Create posts table
28//!         r#"
29//!         CREATE TABLE posts (
30//!             id INTEGER PRIMARY KEY AUTOINCREMENT,
31//!             user_id INTEGER NOT NULL,
32//!             title TEXT NOT NULL,
33//!             content TEXT,
34//!             created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
35//!             FOREIGN KEY (user_id) REFERENCES users(id)
36//!         );
37//!         "#,
38//!     ],
39//! };
40//!
41//! // Convert to MonarchDB instance
42//! let monarch_db: MonarchDB = config.into();
43//!
44//! // Create connection configuration
45//! let connection_config = ConnectionConfiguration {
46//!     database: None, // Use in-memory database for this example
47//! };
48//!
49//! // Create database connection with migrations applied
50//! let connection = monarch_db.create_connection(&connection_config)?;
51//!
52//! // Use your database normally
53//! connection.execute(
54//!     "INSERT INTO users (username, email) VALUES (?, ?)",
55//!     ["alice2", "alice2@example.com"],
56//! )?;
57//! # Ok(())
58//! # }
59//! ```
60//!
61//! ### Directory-Based Configuration
62//!
63//! Use directory-based configuration when you want to manage migrations as separate files:
64//!
65//! ```rust,no_run
66//! use monarch_db::{MonarchConfiguration, MonarchDB, ConnectionConfiguration};
67//!
68//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
69//! let config = MonarchConfiguration {
70//!     name: "my_app".to_string(),
71//!     enable_foreign_keys: true,
72//!     migration_directory: "./migrations".into(),
73//! };
74//!
75//! let monarch_db = MonarchDB::from_configuration(config)?;
76//!
77//! let connection_config = ConnectionConfiguration {
78//!     database: Some("./my_app.db".into()),
79//! };
80//!
81//! let connection = monarch_db.create_connection(&connection_config)?;
82//!
83//! // Database is ready with all migrations applied
84//! # Ok(())
85//! # }
86//! ```
87//!
88//! ## Configuration Types
89//!
90//! - [`StaticMonarchConfiguration`] - For compile-time embedded migrations
91//! - [`MonarchConfiguration`] - For runtime directory-based migrations
92//! - [`ConnectionConfiguration`] - For specifying database file paths
93//!
94//! ## Core Types
95//!
96//! - [`MonarchDB`] - Main migration manager that applies schema changes
97//! - [`Migrations`] - Helper for applying migrations to database connections
98//!
99
100use std::{borrow::Cow, collections::BTreeMap, io};
101
102use camino::Utf8PathBuf;
103use rusqlite::Connection;
104
105type Migration = Cow<'static, str>;
106
107const VERSION_TABLE: &str = "monarch_db_schema_version";
108
109/// Configuration for opening a new SQLite database connection.
110///
111/// This struct controls how a database connection is established, including
112/// whether to use a file-based database or an in-memory database.
113#[derive(Debug, Clone)]
114#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
115pub struct ConnectionConfiguration {
116    /// Optional path to the database file.
117    ///
118    /// If `None`, an in-memory database will be used. If `Some`, the database
119    /// will be persisted to the specified file path.
120    #[cfg_attr(feature = "serde", serde(default))]
121    pub database: Option<Utf8PathBuf>,
122}
123
124/// Configuration for MonarchDB that loads migrations from a directory at runtime.
125///
126/// This configuration is used when migrations are stored as separate files in a
127/// directory and need to be loaded dynamically when the application starts.
128#[derive(Debug, Clone)]
129#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
130pub struct MonarchConfiguration {
131    /// The name of the database schema, used for tracking migration versions.
132    pub name: String,
133    /// Whether to enable foreign key constraints in SQLite.
134    pub enable_foreign_keys: bool,
135    /// Path to the directory containing migration files.
136    pub migration_directory: Utf8PathBuf,
137}
138
139/// Configuration for MonarchDB with compile-time known migrations.
140///
141/// This configuration is used when all migrations are embedded in the binary
142/// at compile time, typically using `include_str!` or similar macros.
143/// This provides better performance and eliminates runtime file I/O.
144#[derive(Debug, Clone)]
145pub struct StaticMonarchConfiguration<const N: usize> {
146    /// The name of the database schema, used for tracking migration versions.
147    pub name: &'static str,
148    /// Whether to enable foreign key constraints in SQLite.
149    pub enable_foreign_keys: bool,
150    /// Array of migration SQL strings, ordered from oldest to newest.
151    pub migrations: [&'static str; N],
152}
153
154impl<const N: usize> From<StaticMonarchConfiguration<N>> for MonarchDB {
155    fn from(configuration: StaticMonarchConfiguration<N>) -> Self {
156        MonarchDB {
157            name: configuration.name.into(),
158            enable_foreign_keys: configuration.enable_foreign_keys,
159            migrations: configuration
160                .migrations
161                .iter()
162                .map(|q| Cow::Borrowed(*q))
163                .collect(),
164        }
165    }
166}
167
168/// MonarchDB manages schema migrations and new connections for a database.
169#[derive(Debug)]
170pub struct MonarchDB {
171    name: Cow<'static, str>,
172    enable_foreign_keys: bool,
173    migrations: Vec<Migration>,
174}
175
176impl MonarchDB {
177    /// Creates a new in-memory SQLite database connection with migrations applied.
178    ///
179    /// This is useful for testing or for applications that need a temporary database.
180    /// All migrations will be automatically applied to the in-memory database.
181    ///
182    /// # Returns
183    ///
184    /// Returns a `rusqlite::Result<Connection>` with migrations applied on success.
185    pub fn open_in_memory(&self) -> rusqlite::Result<Connection> {
186        let connection = Connection::open_in_memory()?;
187        self.migrate(connection)
188    }
189
190    /// Creates a new MonarchDB instance from a configuration that loads migrations from disk.
191    ///
192    /// This reads all migration files from the specified directory and creates a MonarchDB
193    /// instance that can be used to manage database connections and schema migrations.
194    ///
195    /// # Arguments
196    ///
197    /// * `configuration` - A MonarchConfiguration containing the migration directory path,
198    ///   database name, and foreign key settings.
199    ///
200    /// # Returns
201    ///
202    /// Returns a `io::Result<Self>` containing the configured MonarchDB instance.
203    ///
204    /// # Errors
205    ///
206    /// This function will return an error if:
207    /// - The migration directory cannot be read
208    /// - Any migration file cannot be read
209    /// - File system operations fail
210    pub fn from_configuration(configuration: MonarchConfiguration) -> io::Result<Self> {
211        let mut migrations = BTreeMap::new();
212        for diritem in configuration.migration_directory.read_dir_utf8()? {
213            let entry = diritem?;
214
215            if entry.file_type()?.is_file() {
216                let query = std::fs::read_to_string(entry.path())?;
217                migrations.insert(entry.file_name().to_owned(), Cow::from(query));
218            }
219        }
220
221        Ok(MonarchDB {
222            name: configuration.name.into(),
223            enable_foreign_keys: configuration.enable_foreign_keys,
224            migrations: migrations.into_values().collect(),
225        })
226    }
227
228    /// Returns the current schema version, which is the number of migrations available.
229    ///
230    /// This represents the latest version that the database schema can be migrated to.
231    ///
232    /// # Returns
233    ///
234    /// Returns the number of migrations as a `u32`.
235    pub fn current_version(&self) -> u32 {
236        self.migrations.len() as u32
237    }
238
239    fn get_migration(&self, version: u32) -> Option<&str> {
240        self.migrations
241            .get(version as usize)
242            .map(|query| query.as_ref())
243    }
244
245    /// Creates a new SQLite database connection with migrations applied.
246    ///
247    /// If a database path is specified in the configuration, opens that file.
248    /// Otherwise, creates an in-memory database. All migrations will be automatically
249    /// applied to ensure the schema is up to date.
250    ///
251    /// # Arguments
252    ///
253    /// * `configuration` - A ConnectionConfiguration specifying the database path.
254    ///   If `database` is None, an in-memory database will be created.
255    ///
256    /// # Returns
257    ///
258    /// Returns a `rusqlite::Result<Connection>` with migrations applied on success.
259    pub fn create_connection(
260        &self,
261        configuration: &ConnectionConfiguration,
262    ) -> rusqlite::Result<Connection> {
263        let connection = if let Some(path) = configuration.database.as_deref() {
264            Connection::open(path)?
265        } else {
266            Connection::open_in_memory()?
267        };
268        self.migrate(connection)
269    }
270
271    /// Applies all necessary migrations to an existing database connection.
272    ///
273    /// This method takes ownership of a connection and returns it after applying
274    /// all migrations to bring the schema up to the current version. It will
275    /// also configure foreign key constraints if enabled.
276    ///
277    /// # Arguments
278    ///
279    /// * `connection` - An existing SQLite connection to migrate.
280    ///
281    /// # Returns
282    ///
283    /// Returns the connection with migrations applied on success.
284    pub fn migrate(&self, mut connection: Connection) -> rusqlite::Result<Connection> {
285        let migrations = Migrations {
286            connection: &mut connection,
287            monarch: self,
288        };
289        migrations.prepare()?;
290        Ok(connection)
291    }
292
293    /// Create a migration manager for the given connection.
294    ///
295    /// This method initializes a new `Migrations` instance, which can be used to
296    /// apply migrations to the provided connection.
297    pub fn migrations<'c>(&'c self, connection: &'c mut Connection) -> Migrations<'c> {
298        Migrations {
299            connection,
300            monarch: self,
301        }
302    }
303}
304
305/// Helper struct for applying migrations to a database connection.
306///
307/// This struct manages the migration process, ensuring that the database
308/// schema is brought up to the current version by applying any pending migrations.
309pub struct Migrations<'c> {
310    connection: &'c mut Connection,
311    monarch: &'c MonarchDB,
312}
313
314impl<'c> Migrations<'c> {
315    /// Prepares the database connection by configuring settings and applying migrations.
316    ///
317    /// This method performs the following operations:
318    /// 1. Enables foreign key constraints if configured
319    /// 2. Applies any pending migrations to bring the schema up to date
320    ///
321    /// # Returns
322    ///
323    /// Returns `Ok(())` on success, or a `rusqlite::Error` if any operation fails.
324    #[tracing::instrument(level = "trace", skip_all, fields(monarch=%self.monarch.name))]
325    pub fn prepare(self) -> rusqlite::Result<()> {
326        if self.monarch.enable_foreign_keys {
327            tracing::trace!("Set foreign keys");
328            self.connection.pragma_update(None, "foreign_keys", true)?;
329        }
330        self.migrate()?;
331        Ok(())
332    }
333
334    fn migrate(self) -> rusqlite::Result<()> {
335        let tx = self.connection.transaction()?;
336        let mut version = select_schema_version(&tx, &self.monarch.name)?;
337
338        while version < self.monarch.current_version() {
339            let query = self
340                .monarch
341                .get_migration(version)
342                .expect("version <-> migration mismatch");
343            tracing::trace!("Running migration to version {}", version + 1);
344            tx.execute_batch(query)?;
345            version += 1;
346        }
347
348        set_schema_version(&tx, &self.monarch.name, version)?;
349        tx.commit()?;
350        tracing::debug!("Migrations complete");
351        Ok(())
352    }
353}
354
355fn create_schema_version_table(connection: &Connection) -> rusqlite::Result<()> {
356    let mut stmt = connection.prepare(include_str!("00.versions.sql"))?;
357    stmt.execute([])?;
358    Ok(())
359}
360
361fn insert_initial_schema_version(connection: &Connection, name: &str) -> rusqlite::Result<()> {
362    let mut stmt = connection.prepare(&format!(
363        "INSERT INTO {VERSION_TABLE} (monarch_schema, version) VALUES (:name, 0)"
364    ))?;
365    stmt.execute(&[(":name", name)])?;
366    Ok(())
367}
368
369fn select_schema_version(connection: &Connection, name: &str) -> rusqlite::Result<u32> {
370    let mut stmt = connection.prepare("SELECT name FROM sqlite_master WHERE name = :table")?;
371
372    let has_version_tbl: Option<Result<String, _>> = stmt
373        .query_map(&[(":table", VERSION_TABLE)], |row| row.get(0))?
374        .next();
375
376    match has_version_tbl {
377        Some(Ok(_)) => {}
378        Some(Err(error)) => {
379            return Err(error);
380        }
381        None => {
382            tracing::trace!("Create schema version table {VERSION_TABLE}");
383            create_schema_version_table(connection)?;
384            insert_initial_schema_version(connection, name)?;
385            return Ok(0u32);
386        }
387    };
388
389    let mut stmt = connection.prepare(&format!(
390        "SELECT version FROM {VERSION_TABLE} WHERE monarch_schema = :name"
391    ))?;
392    let version: Option<u32> = stmt
393        .query_map(&[(":name", name)], |row| row.get::<_, u32>(0))?
394        .next()
395        .transpose()?;
396    if let Some(version) = version {
397        tracing::trace!(%version, "Get schema version");
398        Ok(version)
399    } else {
400        tracing::trace!("Insert new version for {name}");
401        insert_initial_schema_version(connection, name)?;
402        Ok(0)
403    }
404}
405
406fn set_schema_version(connection: &Connection, name: &str, version: u32) -> rusqlite::Result<()> {
407    tracing::trace!(%version, "Set schema version for {name}");
408    let mut stmt = connection.prepare(&format!(
409        "UPDATE {VERSION_TABLE} SET version = :version WHERE monarch_schema = :name"
410    ))?;
411    stmt.execute(rusqlite::named_params! { ":version": version, ":name": name})?;
412    Ok(())
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_static_monarch_configuration_creation() {
421        let config = StaticMonarchConfiguration {
422            name: "test_db",
423            enable_foreign_keys: true,
424            migrations: [
425                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
426                "ALTER TABLE users ADD COLUMN email TEXT;",
427            ],
428        };
429
430        assert_eq!(config.name, "test_db");
431        assert!(config.enable_foreign_keys);
432        assert_eq!(config.migrations.len(), 2);
433    }
434
435    #[test]
436    fn test_static_configuration_to_monarch_db() {
437        let config = StaticMonarchConfiguration {
438            name: "test_db",
439            enable_foreign_keys: false,
440            migrations: ["CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL);"],
441        };
442
443        let monarch_db: MonarchDB = config.into();
444        assert_eq!(monarch_db.current_version(), 1);
445        assert_eq!(monarch_db.name, "test_db");
446        assert!(!monarch_db.enable_foreign_keys);
447    }
448
449    #[test]
450    fn test_open_in_memory_with_static_migrations() -> rusqlite::Result<()> {
451        let config = StaticMonarchConfiguration {
452            name: "test_memory_db",
453            enable_foreign_keys: true,
454            migrations: [
455                "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
456                "CREATE INDEX idx_items_name ON items(name);",
457            ],
458        };
459
460        let monarch_db: MonarchDB = config.into();
461        let connection = monarch_db.open_in_memory()?;
462
463        // Verify the table was created
464        let mut stmt = connection
465            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='items'")?;
466        let table_exists: bool = stmt.query_map([], |_| Ok(true))?.next().is_some();
467        assert!(table_exists);
468
469        // Verify the index was created
470        let mut stmt = connection.prepare(
471            "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_items_name'",
472        )?;
473        let index_exists: bool = stmt.query_map([], |_| Ok(true))?.next().is_some();
474        assert!(index_exists);
475
476        Ok(())
477    }
478
479    #[test]
480    fn test_create_connection_with_static_migrations() -> rusqlite::Result<()> {
481        let config = StaticMonarchConfiguration {
482            name: "test_file_db",
483            enable_foreign_keys: false,
484            migrations: [
485                "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL);",
486            ],
487        };
488
489        let monarch_db: MonarchDB = config.into();
490        let connection_config = ConnectionConfiguration { database: None };
491        let connection = monarch_db.create_connection(&connection_config)?;
492
493        // Verify the table was created
494        let mut stmt = connection
495            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='products'")?;
496        let table_exists: bool = stmt.query_map([], |_| Ok(true))?.next().is_some();
497        assert!(table_exists);
498
499        // Test inserting data
500        connection.execute(
501            "INSERT INTO products (name, price) VALUES (?, ?)",
502            ["Test Product", "19.99"],
503        )?;
504
505        // Verify data was inserted
506        let mut stmt = connection.prepare("SELECT COUNT(*) FROM products")?;
507        let count: i64 = stmt.query_row([], |row| row.get(0))?;
508        assert_eq!(count, 1);
509
510        Ok(())
511    }
512
513    #[test]
514    fn test_migration_versioning() -> rusqlite::Result<()> {
515        let config = StaticMonarchConfiguration {
516            name: "versioning_test",
517            enable_foreign_keys: false,
518            migrations: [
519                "CREATE TABLE v1_table (id INTEGER PRIMARY KEY);",
520                "CREATE TABLE v2_table (id INTEGER PRIMARY KEY);",
521                "CREATE TABLE v3_table (id INTEGER PRIMARY KEY);",
522            ],
523        };
524
525        let monarch_db: MonarchDB = config.into();
526        assert_eq!(monarch_db.current_version(), 3);
527
528        let connection = monarch_db.open_in_memory()?;
529
530        // Verify all tables were created
531        let table_names = ["v1_table", "v2_table", "v3_table"];
532        for table_name in table_names {
533            let mut stmt = connection.prepare(&format!(
534                "SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}'"
535            ))?;
536            let table_exists: bool = stmt.query_map([], |_| Ok(true))?.next().is_some();
537            assert!(table_exists, "Table {table_name} should exist");
538        }
539
540        Ok(())
541    }
542}