Skip to main content

sqlite_graphrag/commands/
migrate.rs

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