sqlite_graphrag/commands/
migrate.rs1use 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 #[arg(long, default_value_t = false)]
14 pub json: bool,
15 #[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 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; 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 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 let version = latest_schema_version(&conn).unwrap();
200 assert_eq!(version, "0");
201 }
202}