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}