mixtape_tools/sqlite/migration/
import.rs1use crate::prelude::*;
4use crate::sqlite::manager::with_connection;
5
6use super::{compute_checksum, ensure_migrations_table, MIGRATIONS_TABLE};
7
8#[derive(Debug, Deserialize, JsonSchema)]
10pub struct MigrationRecord {
11 pub version: String,
13
14 pub name: String,
16
17 pub sql: String,
19
20 #[serde(default)]
22 pub checksum: Option<String>,
23}
24
25#[derive(Debug, Deserialize, JsonSchema)]
27pub struct ImportMigrationsInput {
28 #[serde(default)]
30 pub db_path: Option<String>,
31
32 pub migrations: Vec<MigrationRecord>,
34
35 #[serde(default)]
37 pub on_conflict: ConflictStrategy,
38}
39
40#[derive(Debug, Default, Clone, Deserialize, JsonSchema)]
42#[serde(rename_all = "snake_case")]
43pub enum ConflictStrategy {
44 #[default]
46 Skip,
47 Fail,
49 Replace,
51}
52
53#[derive(Debug, Serialize)]
55struct ImportResult {
56 version: String,
57 name: String,
58 status: &'static str,
59 message: Option<String>,
60}
61
62pub 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 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 conn.execute(
142 &format!("DELETE FROM {MIGRATIONS_TABLE} WHERE version = ?1"),
143 [&migration.version],
144 )?;
145 }
146 }
147 }
148
149 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 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 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 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 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 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 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 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 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 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 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 RunMigrationsTool
439 .execute(RunMigrationsInput {
440 db_path: Some(db2.key()),
441 })
442 .await
443 .unwrap();
444
445 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}