Skip to main content

sqlite_graphrag/commands/
migrate.rs

1//! Handler for the `migrate` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::output;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_rw;
7use rusqlite::OptionalExtension;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11pub struct MigrateArgs {
12    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
13    pub db: Option<String>,
14    /// Explicit JSON flag. Accepted as a no-op because output is already JSON by default.
15    #[arg(long, default_value_t = false)]
16    pub json: bool,
17    /// Show already applied migrations without applying new ones.
18    #[arg(long, default_value_t = false)]
19    pub status: bool,
20}
21
22#[derive(Serialize)]
23struct MigrateResponse {
24    db_path: String,
25    schema_version: String,
26    status: String,
27    /// Total execution time in milliseconds from handler start to serialisation.
28    elapsed_ms: u64,
29}
30
31#[derive(Serialize)]
32struct MigrateStatusResponse {
33    db_path: String,
34    applied_migrations: Vec<MigrationEntry>,
35    schema_version: String,
36    elapsed_ms: u64,
37}
38
39#[derive(Serialize)]
40struct MigrationEntry {
41    version: i64,
42    name: String,
43    applied_on: Option<String>,
44}
45
46pub fn run(args: MigrateArgs) -> Result<(), AppError> {
47    let inicio = std::time::Instant::now();
48    let _ = args.json; // --json é no-op pois output já é JSON por default
49    let paths = AppPaths::resolve(args.db.as_deref())?;
50    paths.ensure_dirs()?;
51
52    let mut conn = open_rw(&paths.db)?;
53
54    if args.status {
55        let schema_version = latest_schema_version(&conn).unwrap_or_else(|_| "0".to_string());
56        let applied = list_applied_migrations(&conn)?;
57        output::emit_json(&MigrateStatusResponse {
58            db_path: paths.db.display().to_string(),
59            applied_migrations: applied,
60            schema_version,
61            elapsed_ms: inicio.elapsed().as_millis() as u64,
62        })?;
63        return Ok(());
64    }
65
66    crate::migrations::runner()
67        .run(&mut conn)
68        .map_err(|e| AppError::Internal(anyhow::anyhow!("migration failed: {e}")))?;
69
70    conn.execute_batch(&format!(
71        "PRAGMA user_version = {};",
72        crate::constants::SCHEMA_USER_VERSION
73    ))?;
74
75    let schema_version = latest_schema_version(&conn)?;
76    conn.execute(
77        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
78        rusqlite::params![schema_version],
79    )?;
80
81    output::emit_json(&MigrateResponse {
82        db_path: paths.db.display().to_string(),
83        schema_version,
84        status: "ok".to_string(),
85        elapsed_ms: inicio.elapsed().as_millis() as u64,
86    })?;
87
88    Ok(())
89}
90
91fn list_applied_migrations(conn: &rusqlite::Connection) -> Result<Vec<MigrationEntry>, AppError> {
92    let table_exists: Option<String> = conn
93        .query_row(
94            "SELECT name FROM sqlite_master WHERE type='table' AND name='refinery_schema_history'",
95            [],
96            |r| r.get(0),
97        )
98        .optional()?;
99    if table_exists.is_none() {
100        return Ok(vec![]);
101    }
102    let mut stmt = conn.prepare(
103        "SELECT version, name, applied_on FROM refinery_schema_history ORDER BY version ASC",
104    )?;
105    let entries = stmt
106        .query_map([], |r| {
107            Ok(MigrationEntry {
108                version: r.get(0)?,
109                name: r.get(1)?,
110                applied_on: r.get(2)?,
111            })
112        })?
113        .collect::<Result<Vec<_>, _>>()?;
114    Ok(entries)
115}
116
117fn latest_schema_version(conn: &rusqlite::Connection) -> Result<String, AppError> {
118    match conn.query_row(
119        "SELECT version FROM refinery_schema_history ORDER BY version DESC LIMIT 1",
120        [],
121        |row| row.get::<_, i64>(0),
122    ) {
123        Ok(version) => Ok(version.to_string()),
124        Err(rusqlite::Error::QueryReturnedNoRows) => Ok("0".to_string()),
125        Err(err) => Err(AppError::Database(err)),
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use rusqlite::Connection;
133
134    fn cria_db_sem_historico() -> Connection {
135        Connection::open_in_memory().expect("falha ao abrir banco em memória")
136    }
137
138    fn cria_db_com_historico(versao: i64) -> Connection {
139        let conn = Connection::open_in_memory().expect("falha ao abrir banco em memória");
140        conn.execute_batch(
141            "CREATE TABLE refinery_schema_history (
142                version INTEGER NOT NULL,
143                name TEXT,
144                applied_on TEXT,
145                checksum TEXT
146            );",
147        )
148        .expect("falha ao criar tabela de histórico");
149        conn.execute(
150            "INSERT INTO refinery_schema_history (version, name) VALUES (?1, 'V001__init')",
151            rusqlite::params![versao],
152        )
153        .expect("falha ao inserir versão");
154        conn
155    }
156
157    #[test]
158    fn latest_schema_version_retorna_erro_sem_tabela() {
159        let conn = cria_db_sem_historico();
160        // Sem tabela refinery_schema_history, SQLite retorna Unknown (código 1) → AppError::Database
161        let resultado = latest_schema_version(&conn);
162        assert!(
163            resultado.is_err(),
164            "deve retornar Err quando tabela não existe"
165        );
166    }
167
168    #[test]
169    fn latest_schema_version_retorna_versao_maxima() {
170        let conn = cria_db_com_historico(6);
171        let version = latest_schema_version(&conn).unwrap();
172        assert_eq!(version, "6");
173    }
174
175    #[test]
176    fn migrate_response_serializa_campos_obrigatorios() {
177        let resp = MigrateResponse {
178            db_path: "/tmp/test.sqlite".to_string(),
179            schema_version: "6".to_string(),
180            status: "ok".to_string(),
181            elapsed_ms: 12,
182        };
183        let json = serde_json::to_value(&resp).unwrap();
184        assert_eq!(json["status"], "ok");
185        assert_eq!(json["schema_version"], "6");
186        assert_eq!(json["db_path"], "/tmp/test.sqlite");
187        assert_eq!(json["elapsed_ms"], 12);
188    }
189
190    #[test]
191    fn latest_schema_version_retorna_zero_quando_tabela_vazia() {
192        let conn = Connection::open_in_memory().expect("banco em memória");
193        conn.execute_batch(
194            "CREATE TABLE refinery_schema_history (
195                version INTEGER NOT NULL,
196                name TEXT
197            );",
198        )
199        .expect("criação da tabela");
200        // Tabela existe mas está vazia → QueryReturnedNoRows → "0"
201        let version = latest_schema_version(&conn).unwrap();
202        assert_eq!(version, "0");
203    }
204}