Skip to main content

mixtape_tools/sqlite/migration/
export.rs

1//! Export migrations tool
2
3use crate::prelude::*;
4use crate::sqlite::manager::with_connection;
5
6use super::{ensure_migrations_table, Migration, MigrationStatusFilter, MIGRATIONS_TABLE};
7
8/// Input for exporting migrations
9#[derive(Debug, Deserialize, JsonSchema)]
10pub struct ExportMigrationsInput {
11    /// Database to export migrations from (uses default if not specified)
12    #[serde(default)]
13    pub db_path: Option<String>,
14
15    /// Filter by migration status (default: all)
16    #[serde(default)]
17    pub filter: MigrationStatusFilter,
18
19    /// Export format (default: json)
20    #[serde(default)]
21    pub format: ExportFormat,
22}
23
24/// Export format options
25#[derive(Debug, Default, Clone, Deserialize, JsonSchema)]
26#[serde(rename_all = "lowercase")]
27pub enum ExportFormat {
28    /// JSON array of migration records
29    #[default]
30    Json,
31    /// SQL script that can be executed to recreate schema
32    Sql,
33}
34
35/// Exports migrations from the database for transfer to another database
36///
37/// This tool exports migration records that can be imported into another
38/// database using `sqlite_import_migrations`. This preserves the full
39/// migration history and audit trail.
40pub struct ExportMigrationsTool;
41
42impl Tool for ExportMigrationsTool {
43    type Input = ExportMigrationsInput;
44
45    fn name(&self) -> &str {
46        "sqlite_export_migrations"
47    }
48
49    fn description(&self) -> &str {
50        "Export migrations from the database for transfer to another database. \
51         Exports migration records (version, name, SQL, status) that can be imported \
52         elsewhere using sqlite_import_migrations. Use filter to export only pending \
53         or applied migrations."
54    }
55
56    async fn execute(&self, input: Self::Input) -> Result<ToolResult, ToolError> {
57        let filter = input.filter;
58        let format = input.format;
59
60        let migrations = with_connection(input.db_path, move |conn| {
61            ensure_migrations_table(conn)?;
62
63            // Build query based on filter
64            let query = match filter {
65                MigrationStatusFilter::All => {
66                    format!("SELECT version, name, sql, applied_at, checksum FROM {MIGRATIONS_TABLE} ORDER BY version")
67                }
68                MigrationStatusFilter::Pending => {
69                    format!("SELECT version, name, sql, applied_at, checksum FROM {MIGRATIONS_TABLE} WHERE applied_at IS NULL ORDER BY version")
70                }
71                MigrationStatusFilter::Applied => {
72                    format!("SELECT version, name, sql, applied_at, checksum FROM {MIGRATIONS_TABLE} WHERE applied_at IS NOT NULL ORDER BY version")
73                }
74            };
75
76            let mut stmt = conn.prepare(&query)?;
77            let migrations: Vec<Migration> = stmt
78                .query_map([], |row| {
79                    Ok(Migration {
80                        version: row.get(0)?,
81                        name: row.get(1)?,
82                        sql: row.get(2)?,
83                        applied_at: row.get(3)?,
84                        checksum: row.get(4)?,
85                    })
86                })?
87                .filter_map(|r| r.ok())
88                .collect();
89
90            Ok(migrations)
91        })
92        .await?;
93
94        let count = migrations.len();
95        let output = match format {
96            ExportFormat::Json => {
97                serde_json::json!({
98                    "status": "success",
99                    "format": "json",
100                    "count": count,
101                    "migrations": migrations
102                })
103            }
104            ExportFormat::Sql => {
105                // Generate SQL script with migration metadata as comments
106                let mut sql = String::new();
107                sql.push_str("-- Exported migrations\n");
108                sql.push_str("-- Import using sqlite_import_migrations tool\n\n");
109
110                for m in &migrations {
111                    sql.push_str(&format!("-- Migration: {} ({})\n", m.name, m.version));
112                    sql.push_str(&format!("-- Checksum: {}\n", m.checksum));
113                    if let Some(applied) = &m.applied_at {
114                        sql.push_str(&format!("-- Applied: {}\n", applied));
115                    } else {
116                        sql.push_str("-- Status: pending\n");
117                    }
118                    sql.push_str(&m.sql);
119                    if !m.sql.ends_with(';') {
120                        sql.push(';');
121                    }
122                    sql.push_str("\n\n");
123                }
124
125                serde_json::json!({
126                    "status": "success",
127                    "format": "sql",
128                    "count": count,
129                    "script": sql
130                })
131            }
132        };
133        Ok(ToolResult::Json(output))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::sqlite::migration::add::AddMigrationInput;
141    use crate::sqlite::migration::run::RunMigrationsInput;
142    use crate::sqlite::migration::{AddMigrationTool, RunMigrationsTool};
143    use crate::sqlite::test_utils::{unwrap_json, TestDatabase};
144
145    #[tokio::test]
146    async fn test_export_all_migrations() {
147        let db = TestDatabase::new().await;
148
149        // Add two migrations
150        let add_tool = AddMigrationTool;
151        add_tool
152            .execute(AddMigrationInput {
153                name: "create users".to_string(),
154                sql: "CREATE TABLE users (id INTEGER PRIMARY KEY);".to_string(),
155                db_path: Some(db.key()),
156            })
157            .await
158            .unwrap();
159
160        add_tool
161            .execute(AddMigrationInput {
162                name: "create posts".to_string(),
163                sql: "CREATE TABLE posts (id INTEGER PRIMARY KEY);".to_string(),
164                db_path: Some(db.key()),
165            })
166            .await
167            .unwrap();
168
169        // Apply first one
170        RunMigrationsTool
171            .execute(RunMigrationsInput {
172                db_path: Some(db.key()),
173            })
174            .await
175            .unwrap();
176
177        // Export all
178        let tool = ExportMigrationsTool;
179        let result = tool
180            .execute(ExportMigrationsInput {
181                db_path: Some(db.key()),
182                filter: MigrationStatusFilter::All,
183                format: ExportFormat::Json,
184            })
185            .await
186            .unwrap();
187
188        let json = unwrap_json(result);
189
190        assert_eq!(json["status"], "success");
191        assert_eq!(json["count"], 2);
192        assert_eq!(json["migrations"].as_array().unwrap().len(), 2);
193    }
194
195    #[tokio::test]
196    async fn test_export_pending_only() {
197        let db = TestDatabase::new().await;
198
199        // Add two migrations
200        let add_tool = AddMigrationTool;
201        add_tool
202            .execute(AddMigrationInput {
203                name: "create users".to_string(),
204                sql: "CREATE TABLE users (id INTEGER PRIMARY KEY);".to_string(),
205                db_path: Some(db.key()),
206            })
207            .await
208            .unwrap();
209
210        // Apply it
211        RunMigrationsTool
212            .execute(RunMigrationsInput {
213                db_path: Some(db.key()),
214            })
215            .await
216            .unwrap();
217
218        // Add another (pending)
219        add_tool
220            .execute(AddMigrationInput {
221                name: "create posts".to_string(),
222                sql: "CREATE TABLE posts (id INTEGER PRIMARY KEY);".to_string(),
223                db_path: Some(db.key()),
224            })
225            .await
226            .unwrap();
227
228        // Export pending only
229        let tool = ExportMigrationsTool;
230        let result = tool
231            .execute(ExportMigrationsInput {
232                db_path: Some(db.key()),
233                filter: MigrationStatusFilter::Pending,
234                format: ExportFormat::Json,
235            })
236            .await
237            .unwrap();
238
239        let json = unwrap_json(result);
240
241        assert_eq!(json["count"], 1);
242        let migrations = json["migrations"].as_array().unwrap();
243        assert_eq!(migrations[0]["name"], "create posts");
244    }
245
246    #[tokio::test]
247    async fn test_export_sql_format() {
248        let db = TestDatabase::new().await;
249
250        let add_tool = AddMigrationTool;
251        add_tool
252            .execute(AddMigrationInput {
253                name: "create users".to_string(),
254                sql: "CREATE TABLE users (id INTEGER PRIMARY KEY)".to_string(),
255                db_path: Some(db.key()),
256            })
257            .await
258            .unwrap();
259
260        let tool = ExportMigrationsTool;
261        let result = tool
262            .execute(ExportMigrationsInput {
263                db_path: Some(db.key()),
264                filter: MigrationStatusFilter::All,
265                format: ExportFormat::Sql,
266            })
267            .await
268            .unwrap();
269
270        let json = unwrap_json(result);
271
272        assert_eq!(json["format"], "sql");
273        let script = json["script"].as_str().unwrap();
274        assert!(script.contains("CREATE TABLE users"));
275        assert!(script.contains("Migration: create users"));
276    }
277
278    #[tokio::test]
279    async fn test_export_empty() {
280        let db = TestDatabase::new().await;
281
282        let tool = ExportMigrationsTool;
283        let result = tool
284            .execute(ExportMigrationsInput {
285                db_path: Some(db.key()),
286                filter: MigrationStatusFilter::All,
287                format: ExportFormat::Json,
288            })
289            .await
290            .unwrap();
291
292        let json = unwrap_json(result);
293
294        assert_eq!(json["status"], "success");
295        assert_eq!(json["count"], 0);
296    }
297}