ic_sql_migrate/
lib.rs

1//! A lightweight database migration library for Internet Computer (ICP) canisters.
2//!
3//! This library provides automatic database schema management and version control
4//! for SQLite (via `ic-rusqlite`) and Turso databases in ICP canisters. Migrations
5//! are embedded at compile time and executed during canister initialization and upgrades.
6//!
7//! # Features
8//!
9//! **IMPORTANT**: You must enable exactly one database feature for this library to work:
10//! - **SQLite support** via `ic-rusqlite` (feature: `sqlite`)
11//! - **Turso support** for distributed SQLite (feature: `turso`)
12//!
13//! Additional capabilities:
14//! - **Automatic migration execution** on canister `init` and `post_upgrade`
15//! - **Compile-time migration embedding** via `include!()` macro
16//! - **Transaction-based execution** for atomicity
17//!
18//! The library has no default features. Attempting to use it without enabling
19//! either `sqlite` or `turso` will result in compilation errors when trying to
20//! access the database modules.
21//!
22//! # Quick Start for ICP Canisters
23//!
24//! ## 1. Prerequisites
25//!
26//! ### For SQLite (via ic-rusqlite)
27//! SQLite support requires the WASI SDK toolchain. Follow the setup instructions at
28//! [ic-rusqlite](https://crates.io/crates/ic-rusqlite) or run:
29//! ```bash
30//! curl -fsSL https://raw.githubusercontent.com/wasm-forge/ic-rusqlite/main/prepare.sh | sh
31//! ```
32//!
33//! ### For Turso
34//! No additional toolchain setup required beyond Rust and DFX.
35//!
36//! ## 2. Add to Cargo.toml
37//! ```toml
38//! [dependencies]
39//! ic-sql-migrate = { version = "0.0.1", features = ["sqlite"] }
40//! ic-rusqlite = "0.37.0"  # or turso = "0.1.4" for Turso
41//! ic-cdk = "0.16"
42//!
43//! [build-dependencies]
44//! ic-sql-migrate = "0.0.1"
45//! ```
46//!
47//! ## 3. Create build.rs
48//! ```no_run
49//! fn main() {
50//!     ic_sql_migrate::list(Some("migrations")).unwrap();
51//! }
52//! ```
53//!
54//! ## 4. Use in canister
55//! ```ignore
56//! use ic_cdk::{init, post_upgrade, pre_upgrade};
57//! use ic_rusqlite::{close_connection, with_connection, Connection};
58//!
59//! static MIGRATIONS: &[ic_sql_migrate::Migration] = ic_sql_migrate::include!();
60//!
61//! fn run_migrations() {
62//!     with_connection(|mut conn| {
63//!         let conn: &mut Connection = &mut conn;
64//!         ic_sql_migrate::sqlite::up(conn, MIGRATIONS).unwrap();
65//!     });
66//! }
67//!
68//! #[init]
69//! fn init() {
70//!     run_migrations();
71//! }
72//!
73//! #[pre_upgrade]
74//! fn pre_upgrade() {
75//!     close_connection();
76//! }
77//!
78//! #[post_upgrade]
79//! fn post_upgrade() {
80//!     run_migrations();
81//! }
82//! ```
83
84mod db;
85
86#[cfg(feature = "turso")]
87pub use crate::db::turso;
88
89#[cfg(feature = "sqlite")]
90pub use crate::db::sqlite;
91
92#[cfg(feature = "turso")]
93use ::turso as turso_crate;
94
95use thiserror::Error;
96
97/// Custom error type for migration operations.
98///
99/// This enum represents all possible errors that can occur during migration operations.
100/// The actual database error variant depends on the feature flag enabled (either `sqlite` or `turso`).
101#[derive(Debug, Error)]
102pub enum Error {
103    /// I/O operation failed during build-time migration discovery
104    #[error("IO error: {0}")]
105    Io(#[from] std::io::Error),
106
107    /// A specific migration failed to execute
108    ///
109    /// Contains the migration ID and the error message from the database
110    #[error("Migration '{id}' failed: {message}")]
111    MigrationFailed { id: String, message: String },
112
113    /// Environment variable was not found during build-time processing
114    #[error("Environment variable '{0}' not set")]
115    EnvVarNotFound(String),
116
117    /// Database error from the underlying database driver
118    #[error("Database error: {0}")]
119    Database(Box<dyn std::error::Error + Send + Sync>),
120}
121
122// IMPORTANT: Users must enable exactly one database feature: either 'sqlite' or 'turso'
123// The library can be compiled without features for publishing to crates.io,
124// but actual usage requires selecting a database backend. If no feature is selected,
125// the database modules will not be available and the library cannot be used.
126
127#[cfg(feature = "sqlite")]
128impl From<rusqlite::Error> for Error {
129    fn from(err: rusqlite::Error) -> Self {
130        Error::Database(Box::new(err))
131    }
132}
133
134#[cfg(feature = "turso")]
135impl From<turso_crate::Error> for Error {
136    fn from(err: turso_crate::Error) -> Self {
137        Error::Database(Box::new(err))
138    }
139}
140
141/// Type alias for `Result<T, Error>` used throughout the library.
142///
143/// This provides a convenient shorthand for functions that can return migration errors.
144pub type MigrateResult<T> = std::result::Result<T, Error>;
145
146/// Represents a single database migration with its unique identifier and SQL content.
147///
148/// Migrations are typically created at compile time by the `include!()` macro
149/// from SQL files in your migrations directory. Each migration consists of:
150/// - An identifier (usually the filename without extension)
151/// - The SQL statements to execute
152///
153/// # Example in ICP Canister
154/// ```
155/// use ic_sql_migrate::Migration;
156///
157/// // Typically included via the include!() macro:
158/// static MIGRATIONS: &[Migration] = &[
159///     Migration::new(
160///         "001_create_users",
161///         "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);"
162///     ),
163///     Migration::new(
164///         "002_add_email",
165///         "ALTER TABLE users ADD COLUMN email TEXT;"
166///     ),
167/// ];
168/// ```
169#[derive(Debug, Clone)]
170pub struct Migration {
171    /// Unique identifier for the migration, typically derived from the filename.
172    /// This ID is stored in the `_migrations` table to track which migrations have been applied.
173    pub id: &'static str,
174    /// SQL statements to execute for this migration.
175    /// Can contain multiple statements separated by semicolons.
176    pub sql: &'static str,
177}
178
179impl Migration {
180    /// Creates a new migration with the given ID and SQL content.
181    ///
182    /// This is a `const fn`, allowing migrations to be created at compile time.
183    ///
184    /// # Arguments
185    /// * `id` - Unique identifier for the migration (must not contain whitespace or special characters)
186    /// * `sql` - SQL statements to execute (can be multiple statements separated by semicolons)
187    ///
188    /// # Example
189    /// ```
190    /// use ic_sql_migrate::Migration;
191    ///
192    /// // Static migrations for use in ICP canisters
193    /// static INIT_MIGRATION: Migration = Migration::new(
194    ///     "001_init",
195    ///     "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"
196    /// );
197    /// ```
198    pub const fn new(id: &'static str, sql: &'static str) -> Self {
199        Self { id, sql }
200    }
201}
202
203/// Includes all migration files discovered by the `list` function at compile time.
204///
205/// This macro expands to a static slice of `Migration` structs containing
206/// all SQL files found in the migrations directory. The migrations are ordered
207/// alphabetically by filename, so it's recommended to prefix them with numbers
208/// (e.g., `001_initial.sql`, `002_add_users.sql`).
209///
210/// # Prerequisites
211/// You must call `ic_sql_migrate::list()` in your `build.rs` file to generate
212/// the migration data that this macro includes.
213///
214/// # Example in ICP Canister
215/// ```ignore
216/// // In your canister lib.rs
217/// use ic_cdk::{init, post_upgrade};
218/// use ic_rusqlite::{with_connection, Connection};
219///
220/// static MIGRATIONS: &[ic_sql_migrate::Migration] = ic_sql_migrate::include!();
221///
222/// fn run_migrations() {
223///     with_connection(|mut conn| {
224///         let conn: &mut Connection = &mut conn;
225///         ic_sql_migrate::sqlite::up(conn, MIGRATIONS).unwrap();
226///     });
227/// }
228///
229/// #[init]
230/// fn init() {
231///     run_migrations();
232/// }
233///
234/// #[post_upgrade]
235/// fn post_upgrade() {
236///     run_migrations();
237/// }
238/// ```
239#[macro_export]
240macro_rules! include {
241    () => {
242        include!(concat!(env!("OUT_DIR"), "/migrations_gen.rs"))
243    };
244}
245
246/// Discovers and lists all SQL migration files for inclusion at compile time.
247///
248/// This function should be called in `build.rs` to generate code that embeds
249/// all migration files into the binary. It scans the specified directory for
250/// `.sql` files and generates Rust code to include them.
251///
252/// The function will:
253/// 1. Look for SQL files in the specified directory (relative to `Cargo.toml`)
254/// 2. Sort them alphabetically by filename
255/// 3. Generate code that includes their content at compile time
256/// 4. Set up cargo to rebuild when migration files change
257///
258/// # Arguments
259/// * `migrations_dir_name` - Optional custom directory name (defaults to "migrations")
260///
261/// # Example in build.rs
262/// ```no_run
263/// // In your canister's build.rs file
264/// fn main() {
265///     // Use default "migrations" directory
266///     ic_sql_migrate::list(None).unwrap();
267///
268///     // Or specify a custom directory relative to Cargo.toml
269///     ic_sql_migrate::list(Some("migrations")).unwrap();
270/// }
271/// ```
272///
273/// # File Naming Convention
274/// Migration files should be named with a sortable prefix to ensure correct execution order:
275/// - `001_initial_schema.sql`
276/// - `002_add_users_table.sql`
277/// - `003_add_indexes.sql`
278///
279/// # Errors
280/// Returns an I/O error if:
281/// - The output directory (`OUT_DIR`) cannot be written to
282/// - File system operations fail
283/// - Environment variables `CARGO_MANIFEST_DIR` or `OUT_DIR` are not set
284pub fn list(migrations_dir_name: Option<&str>) -> std::io::Result<()> {
285    use std::env;
286    use std::fs;
287    use std::path::Path;
288
289    let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|_| {
290        std::io::Error::new(std::io::ErrorKind::NotFound, "CARGO_MANIFEST_DIR not set")
291    })?;
292
293    let dir_name = migrations_dir_name.unwrap_or("migrations");
294    let migrations_dir = Path::new(&manifest_dir).join(dir_name);
295
296    // Ensure cargo rebuilds when migrations change
297    println!("cargo:rerun-if-changed={}", migrations_dir.display());
298
299    // Generate the output file path
300    let out_dir = env::var("OUT_DIR")
301        .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "OUT_DIR not set"))?;
302    let dest_path = Path::new(&out_dir).join("migrations_gen.rs");
303
304    // If migrations directory doesn't exist, create empty migrations array
305    if !migrations_dir.exists() {
306        fs::write(dest_path, "&[]")?;
307        return Ok(());
308    }
309
310    // Collect all SQL files
311    let migration_files = collect_migration_files(&migrations_dir)?;
312
313    // Generate and write the Rust code
314    let generated_code = generate_migrations_code(&migration_files);
315    fs::write(dest_path, generated_code)?;
316
317    Ok(())
318}
319
320/// Collects all SQL migration files from the specified directory.
321///
322/// Returns a sorted list of (migration_id, file_path) tuples.
323fn collect_migration_files(
324    migrations_dir: &std::path::Path,
325) -> std::io::Result<Vec<(String, String)>> {
326    use std::fs;
327
328    let mut migration_files = Vec::new();
329
330    let entries = fs::read_dir(migrations_dir)?;
331    for entry in entries {
332        let entry = entry?;
333        let path = entry.path();
334
335        // Only process .sql files
336        if path.extension().and_then(|s| s.to_str()) != Some("sql") {
337            continue;
338        }
339
340        if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
341            let absolute_path = path.to_string_lossy().to_string();
342            migration_files.push((file_stem.to_string(), absolute_path));
343
344            // Ensure cargo rebuilds when this specific file changes
345            println!("cargo:rerun-if-changed={}", path.display());
346        }
347    }
348
349    // Sort migration files by name to ensure consistent ordering
350    migration_files.sort_by(|a, b| a.0.cmp(&b.0));
351
352    Ok(migration_files)
353}
354
355/// Generates Rust code for including migration files.
356///
357/// Creates a static array initialization with all migration files.
358fn generate_migrations_code(migration_files: &[(String, String)]) -> String {
359    let mut code = String::from("&[\n");
360
361    for (migration_id, file_path) in migration_files {
362        code.push_str(&format!(
363            "    ic_sql_migrate::Migration::new(\"{migration_id}\", include_str!(\"{file_path}\")),\n"
364        ));
365    }
366
367    code.push_str("]\n");
368    code
369}