Skip to main content

mixtape_tools/sqlite/migration/
import.rs

1//! Import migrations tool
2
3use crate::prelude::*;
4use crate::sqlite::manager::with_connection;
5
6use super::{compute_checksum, ensure_migrations_table, MIGRATIONS_TABLE};
7
8/// A migration record to import
9#[derive(Debug, Deserialize, JsonSchema)]
10pub struct MigrationRecord {
11    /// Version identifier (timestamp format recommended)
12    pub version: String,
13
14    /// Human-readable name/description
15    pub name: String,
16
17    /// The SQL to execute
18    pub sql: String,
19
20    /// Optional checksum for verification (computed if not provided)
21    #[serde(default)]
22    pub checksum: Option<String>,
23}
24
25/// Input for importing migrations
26#[derive(Debug, Deserialize, JsonSchema)]
27pub struct ImportMigrationsInput {
28    /// Database to import migrations into (uses default if not specified)
29    #[serde(default)]
30    pub db_path: Option<String>,
31
32    /// Migrations to import
33    pub migrations: Vec<MigrationRecord>,
34
35    /// How to handle migrations that already exist
36    #[serde(default)]
37    pub on_conflict: ConflictStrategy,
38}
39
40/// Strategy for handling migrations that already exist
41#[derive(Debug, Default, Clone, Deserialize, JsonSchema)]
42#[serde(rename_all = "snake_case")]
43pub enum ConflictStrategy {
44    /// Skip migrations that already exist (by version)
45    #[default]
46    Skip,
47    /// Fail if any migration already exists
48    Fail,
49    /// Replace existing migrations (only if pending, fails for applied)
50    Replace,
51}
52
53/// Result for a single migration import
54#[derive(Debug, Serialize)]
55struct ImportResult {
56    version: String,
57    name: String,
58    status: &'static str,
59    message: Option<String>,
60}
61
62/// Imports migrations from an export into the database as pending migrations
63///
64/// Use this to transfer migrations from one database to another. Imported
65/// migrations are added as pending and must be applied using `sqlite_run_migrations`.
66pub struct ImportMigrationsTool;
67
68impl Tool for ImportMigrationsTool {
69    type Input = ImportMigrationsInput;
70
71    fn name(&self) -> &str {
72        "sqlite_import_migrations"
73    }
74
75    fn description(&self) -> &str {
76        "Import migrations into the database as pending migrations. \
77         Use this to transfer migrations exported from another database via \
78         sqlite_export_migrations. Imported migrations must be applied using \
79         sqlite_run_migrations."
80    }
81
82    async fn execute(&self, input: Self::Input) -> Result<ToolResult, ToolError> {
83        let migrations = input.migrations;
84        let on_conflict = input.on_conflict;
85
86        let (results, imported, skipped, failed) = with_connection(input.db_path, move |conn| {
87            ensure_migrations_table(conn)?;
88
89            let mut results = Vec::new();
90            let mut imported = 0;
91            let mut skipped = 0;
92            let mut failed = 0;
93
94            for migration in migrations {
95                // Check if migration already exists
96                let existing: Option<(String, Option<String>)> = conn
97                    .query_row(
98                        &format!(
99                            "SELECT name, applied_at FROM {MIGRATIONS_TABLE} WHERE version = ?1"
100                        ),
101                        [&migration.version],
102                        |row| Ok((row.get(0)?, row.get(1)?)),
103                    )
104                    .ok();
105
106                if let Some((existing_name, applied_at)) = existing {
107                    match on_conflict {
108                        ConflictStrategy::Skip => {
109                            results.push(ImportResult {
110                                version: migration.version,
111                                name: migration.name,
112                                status: "skipped",
113                                message: Some(format!(
114                                    "Migration already exists as '{}'",
115                                    existing_name
116                                )),
117                            });
118                            skipped += 1;
119                            continue;
120                        }
121                        ConflictStrategy::Fail => {
122                            return Err(crate::sqlite::error::SqliteToolError::InvalidQuery(format!(
123                                "Migration '{}' already exists. Use on_conflict: 'skip' or 'replace' to handle duplicates.",
124                                migration.version
125                            )));
126                        }
127                        ConflictStrategy::Replace => {
128                            if applied_at.is_some() {
129                                results.push(ImportResult {
130                                    version: migration.version,
131                                    name: migration.name,
132                                    status: "failed",
133                                    message: Some(
134                                        "Cannot replace applied migration".to_string(),
135                                    ),
136                                });
137                                failed += 1;
138                                continue;
139                            }
140                            // Delete existing pending migration
141                            conn.execute(
142                                &format!("DELETE FROM {MIGRATIONS_TABLE} WHERE version = ?1"),
143                                [&migration.version],
144                            )?;
145                        }
146                    }
147                }
148
149                // Compute or verify checksum
150                let computed_checksum = compute_checksum(&migration.sql);
151                if let Some(provided) = &migration.checksum {
152                    if provided != &computed_checksum {
153                        results.push(ImportResult {
154                            version: migration.version,
155                            name: migration.name,
156                            status: "failed",
157                            message: Some(format!(
158                                "Checksum mismatch: expected {}, got {}",
159                                provided, computed_checksum
160                            )),
161                        });
162                        failed += 1;
163                        continue;
164                    }
165                }
166
167                // Insert as pending migration
168                conn.execute(
169                    &format!(
170                        "INSERT INTO {MIGRATIONS_TABLE} (version, name, sql, applied_at, checksum) \
171                         VALUES (?1, ?2, ?3, NULL, ?4)"
172                    ),
173                    (
174                        &migration.version,
175                        &migration.name,
176                        &migration.sql,
177                        &computed_checksum,
178                    ),
179                )?;
180
181                results.push(ImportResult {
182                    version: migration.version,
183                    name: migration.name,
184                    status: "imported",
185                    message: None,
186                });
187                imported += 1;
188            }
189
190            Ok((results, imported, skipped, failed))
191        })
192        .await?;
193
194        Ok(ToolResult::Json(serde_json::json!({
195            "status": if failed == 0 { "success" } else { "partial" },
196            "imported": imported,
197            "skipped": skipped,
198            "failed": failed,
199            "results": results
200        })))
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::sqlite::migration::add::AddMigrationInput;
208    use crate::sqlite::migration::export::{
209        ExportFormat, ExportMigrationsInput, ExportMigrationsTool,
210    };
211    use crate::sqlite::migration::list::{ListMigrationsInput, ListMigrationsTool};
212    use crate::sqlite::migration::run::RunMigrationsInput;
213    use crate::sqlite::migration::MigrationStatusFilter;
214    use crate::sqlite::migration::{AddMigrationTool, RunMigrationsTool};
215    use crate::sqlite::test_utils::{unwrap_json, TestDatabase};
216
217    #[tokio::test]
218    async fn test_import_migrations() {
219        let db = TestDatabase::new().await;
220
221        let tool = ImportMigrationsTool;
222        let result = tool
223            .execute(ImportMigrationsInput {
224                db_path: Some(db.key()),
225                migrations: vec![
226                    MigrationRecord {
227                        version: "20240101_120000_000000".to_string(),
228                        name: "create users".to_string(),
229                        sql: "CREATE TABLE users (id INTEGER PRIMARY KEY);".to_string(),
230                        checksum: None,
231                    },
232                    MigrationRecord {
233                        version: "20240101_120001_000000".to_string(),
234                        name: "create posts".to_string(),
235                        sql: "CREATE TABLE posts (id INTEGER PRIMARY KEY);".to_string(),
236                        checksum: None,
237                    },
238                ],
239                on_conflict: ConflictStrategy::Skip,
240            })
241            .await
242            .unwrap();
243
244        let json = unwrap_json(result);
245
246        assert_eq!(json["status"], "success");
247        assert_eq!(json["imported"], 2);
248        assert_eq!(json["skipped"], 0);
249    }
250
251    #[tokio::test]
252    async fn test_import_skip_existing() {
253        let db = TestDatabase::new().await;
254
255        // Add a migration directly
256        AddMigrationTool
257            .execute(AddMigrationInput {
258                name: "existing".to_string(),
259                sql: "CREATE TABLE existing (id INTEGER);".to_string(),
260                db_path: Some(db.key()),
261            })
262            .await
263            .unwrap();
264
265        // Get the version
266        let list_result = ListMigrationsTool
267            .execute(ListMigrationsInput {
268                db_path: Some(db.key()),
269                filter: MigrationStatusFilter::All,
270            })
271            .await
272            .unwrap();
273
274        let list_json = unwrap_json(list_result);
275        let version = list_json["migrations"][0]["version"]
276            .as_str()
277            .unwrap()
278            .to_string();
279
280        // Try to import with same version
281        let tool = ImportMigrationsTool;
282        let result = tool
283            .execute(ImportMigrationsInput {
284                db_path: Some(db.key()),
285                migrations: vec![MigrationRecord {
286                    version,
287                    name: "different name".to_string(),
288                    sql: "CREATE TABLE different (id INTEGER);".to_string(),
289                    checksum: None,
290                }],
291                on_conflict: ConflictStrategy::Skip,
292            })
293            .await
294            .unwrap();
295
296        let json = unwrap_json(result);
297
298        assert_eq!(json["imported"], 0);
299        assert_eq!(json["skipped"], 1);
300    }
301
302    #[tokio::test]
303    async fn test_import_fail_on_conflict() {
304        let db = TestDatabase::new().await;
305
306        // Add a migration
307        AddMigrationTool
308            .execute(AddMigrationInput {
309                name: "existing".to_string(),
310                sql: "CREATE TABLE existing (id INTEGER);".to_string(),
311                db_path: Some(db.key()),
312            })
313            .await
314            .unwrap();
315
316        let list_result = ListMigrationsTool
317            .execute(ListMigrationsInput {
318                db_path: Some(db.key()),
319                filter: MigrationStatusFilter::All,
320            })
321            .await
322            .unwrap();
323
324        let list_json = unwrap_json(list_result);
325        let version = list_json["migrations"][0]["version"]
326            .as_str()
327            .unwrap()
328            .to_string();
329
330        // Try to import with fail strategy
331        let tool = ImportMigrationsTool;
332        let result = tool
333            .execute(ImportMigrationsInput {
334                db_path: Some(db.key()),
335                migrations: vec![MigrationRecord {
336                    version,
337                    name: "different".to_string(),
338                    sql: "CREATE TABLE different (id INTEGER);".to_string(),
339                    checksum: None,
340                }],
341                on_conflict: ConflictStrategy::Fail,
342            })
343            .await;
344
345        assert!(result.is_err());
346    }
347
348    #[tokio::test]
349    async fn test_import_checksum_verification() {
350        let db = TestDatabase::new().await;
351
352        let tool = ImportMigrationsTool;
353        let result = tool
354            .execute(ImportMigrationsInput {
355                db_path: Some(db.key()),
356                migrations: vec![MigrationRecord {
357                    version: "20240101_120000_000000".to_string(),
358                    name: "test".to_string(),
359                    sql: "CREATE TABLE test (id INTEGER);".to_string(),
360                    checksum: Some("invalid_checksum".to_string()),
361                }],
362                on_conflict: ConflictStrategy::Skip,
363            })
364            .await
365            .unwrap();
366
367        let json = unwrap_json(result);
368
369        assert_eq!(json["status"], "partial");
370        assert_eq!(json["imported"], 0);
371        assert_eq!(json["failed"], 1);
372    }
373
374    #[tokio::test]
375    async fn test_roundtrip_export_import() {
376        let db1 = TestDatabase::new().await;
377        let db2 = TestDatabase::new().await;
378
379        // Add migrations to db1
380        AddMigrationTool
381            .execute(AddMigrationInput {
382                name: "create users".to_string(),
383                sql: "CREATE TABLE users (id INTEGER PRIMARY KEY);".to_string(),
384                db_path: Some(db1.key()),
385            })
386            .await
387            .unwrap();
388
389        AddMigrationTool
390            .execute(AddMigrationInput {
391                name: "create posts".to_string(),
392                sql: "CREATE TABLE posts (id INTEGER PRIMARY KEY);".to_string(),
393                db_path: Some(db1.key()),
394            })
395            .await
396            .unwrap();
397
398        // Export from db1
399        let export_result = ExportMigrationsTool
400            .execute(ExportMigrationsInput {
401                db_path: Some(db1.key()),
402                filter: MigrationStatusFilter::All,
403                format: ExportFormat::Json,
404            })
405            .await
406            .unwrap();
407
408        let export_json = unwrap_json(export_result);
409        let exported = export_json["migrations"].as_array().unwrap();
410
411        // Convert to import format
412        let migrations: Vec<MigrationRecord> = exported
413            .iter()
414            .map(|m| MigrationRecord {
415                version: m["version"].as_str().unwrap().to_string(),
416                name: m["name"].as_str().unwrap().to_string(),
417                sql: m["sql"].as_str().unwrap().to_string(),
418                checksum: Some(m["checksum"].as_str().unwrap().to_string()),
419            })
420            .collect();
421
422        // Import to db2
423        let import_result = ImportMigrationsTool
424            .execute(ImportMigrationsInput {
425                db_path: Some(db2.key()),
426                migrations,
427                on_conflict: ConflictStrategy::Skip,
428            })
429            .await
430            .unwrap();
431
432        let json = unwrap_json(import_result);
433
434        assert_eq!(json["status"], "success");
435        assert_eq!(json["imported"], 2);
436
437        // Run migrations on db2
438        RunMigrationsTool
439            .execute(RunMigrationsInput {
440                db_path: Some(db2.key()),
441            })
442            .await
443            .unwrap();
444
445        // Verify tables exist in db2
446        let rows = db2.query(
447            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('users', 'posts')",
448        );
449        assert_eq!(rows[0][0], 2);
450    }
451}