sqlite_graphrag/commands/
migrate.rs1use 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 #[arg(long, default_value_t = false)]
16 pub json: bool,
17 #[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 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; 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 create_db_without_history() -> Connection {
135 Connection::open_in_memory().expect("falha ao abrir banco em memória")
136 }
137
138 fn create_db_with_history(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_returns_error_without_table() {
159 let conn = create_db_without_history();
160 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_returns_max_version() {
170 let conn = create_db_with_history(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_returns_zero_when_table_empty() {
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 let version = latest_schema_version(&conn).unwrap();
202 assert_eq!(version, "0");
203 }
204}