1use crate::ast::identifiers::ObjectId;
4use crate::db::cache::{DbCache, ForeignKeyCache, IndexCache};
5use crate::model::relation::{Persistence, RelationKind, RelationState};
6use anyhow::{Context, Result};
7use postgres::{Client, NoTls};
8use std::fs;
9use std::path::Path;
10
11pub fn sync_cache(out_path: &Path, schemas: Option<&[String]>) -> Result<()> {
12 let db_url = std::env::var("DATABASE_URL")
14 .context("DATABASE_URL environment variable is required to sync database stats. Do not pass credentials via CLI flags or config files.")?;
15
16 if out_path.exists() {
18 fs::remove_file(out_path).context("Failed to remove old cache file before sync")?;
19 }
20
21 let host = db_url
23 .split('@')
24 .nth(1)
25 .and_then(|h| h.split('/').next())
26 .unwrap_or("localhost");
27 if !host.starts_with("localhost")
28 && !host.starts_with("127.")
29 && !host.starts_with("/")
30 && host != "::1"
31 {
32 eprintln!(
33 "[WARN] Connecting to PostgreSQL at {} without TLS encryption.\n\
34 The database password will be sent in cleartext over the network.\n\
35 Use an SSH tunnel or a local connection for sensitive databases,\n\
36 or add native-tls support (see https://github.com/dsecurity49/safe-migrate).",
37 host
38 );
39 }
40
41 let mut client = Client::connect(&db_url, NoTls).context("Failed to connect to PostgreSQL")?;
42
43 let mut cache = DbCache::new();
44
45 let schema_filter = if let Some(s) = schemas {
46 format!("AND n.nspname = ANY(ARRAY['{}'])", s.join("','"))
47 } else {
48 "".to_string()
49 };
50
51 let schema_filter_with_fk = if let Some(s) = schemas {
52 let arr = format!("ARRAY['{}']", s.join("','"));
53 format!(
54 "AND (
55 n.nspname = ANY({arr})
56 OR c.oid IN (
57 SELECT conrelid FROM pg_constraint cst
58 JOIN pg_class c2 ON c2.oid = cst.confrelid
59 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
60 WHERE n2.nspname = ANY({arr})
61 )
62 OR c.oid IN (
63 SELECT confrelid FROM pg_constraint cst
64 JOIN pg_class c2 ON c2.oid = cst.conrelid
65 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
66 WHERE n2.nspname = ANY({arr})
67 )
68 )"
69 )
70 } else {
71 "".to_string()
72 };
73
74 let schema_filter_n1_or_n2 = if let Some(s) = schemas {
75 let arr = format!("ARRAY['{}']", s.join("','"));
76 format!("AND (n1.nspname = ANY({arr}) OR n2.nspname = ANY({arr}))")
77 } else {
78 "".to_string()
79 };
80
81 let schema_filter_nt = if let Some(s) = schemas {
82 let arr = format!("ARRAY['{}']", s.join("','"));
83 format!(
84 "AND (
85 n_t.nspname = ANY({arr})
86 OR t.oid IN (
87 SELECT conrelid FROM pg_constraint cst
88 JOIN pg_class c2 ON c2.oid = cst.confrelid
89 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
90 WHERE n2.nspname = ANY({arr})
91 )
92 OR t.oid IN (
93 SELECT confrelid FROM pg_constraint cst
94 JOIN pg_class c2 ON c2.oid = cst.conrelid
95 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
96 WHERE n2.nspname = ANY({arr})
97 )
98 )"
99 )
100 } else {
101 "".to_string()
102 };
103
104 let version_row = client.query_one("SHOW server_version_num;", &[])?;
106 let version_str: String = version_row.get(0);
107 cache.pg_version_num = version_str.parse::<u32>().ok();
108
109 let table_query = format!(
111 "
112 SELECT
113 n.nspname AS schema_name,
114 c.relname AS relation_name,
115 c.relkind AS relation_kind,
116 c.relpersistence AS persistence,
117 CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
118 c.relpages::bigint AS relpages,
119 to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
120 to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
121 p.partstrat::text AS partition_strategy
122 FROM pg_class c
123 JOIN pg_namespace n ON n.oid = c.relnamespace
124 LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
125 LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
126 WHERE c.relkind IN ('r', 'p', 'v', 'm')
127 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
128 {schema_filter_with_fk};
129 "
130 );
131
132 for row in client.query(&table_query, &[])? {
133 let schema_name: String = row.get("schema_name");
134 let relation_name: String = row.get("relation_name");
135 let relkind: i8 = row.get("relation_kind");
136 let persistence_char: i8 = row.get("persistence");
137 let raw_rows: i64 = row.get("estimated_rows");
138 let relpages: i64 = row.get("relpages");
139
140 let last_analyze: Option<String> = row.get("last_analyze");
141 let last_autoanalyze: Option<String> = row.get("last_autoanalyze");
142
143 let object_id = ObjectId::new(&schema_name, &relation_name);
144
145 let kind = match relkind as u8 {
146 b'v' => RelationKind::View,
147 b'm' => RelationKind::MaterializedView,
148 _ => RelationKind::Table,
149 };
150
151 let persistence = match persistence_char as u8 {
152 b't' => Persistence::Temporary,
153 b'u' => Persistence::Unlogged,
154 _ => Persistence::Permanent,
155 };
156
157 let estimated_rows = if raw_rows < 0 {
158 None
159 } else {
160 Some(raw_rows as u64)
161 };
162
163 let mut state = RelationState::new(
164 object_id.clone(),
165 ObjectId::new("public", "postgres"),
166 0,
167 estimated_rows,
168 kind,
169 persistence,
170 0,
171 );
172 state.relpages = Some(relpages as u64);
173 state.last_analyze = last_analyze;
174 state.last_autoanalyze = last_autoanalyze;
175
176 let partition_strategy: Option<String> = row.get("partition_strategy");
177 if let Some(ref strat) = partition_strategy {
178 state.partition_type = Some(match strat.as_str() {
179 "r" => "RANGE".to_string(),
180 "l" => "LIST".to_string(),
181 "h" => "HASH".to_string(),
182 _ => strat.to_uppercase(),
183 });
184 }
185
186 if let Some(s) = schemas
187 && !s.contains(&schema_name)
188 {
189 state.mark_fk_dependency();
190 }
191
192 cache.insert_baseline(object_id, state);
193 }
194
195 let col_query = format!("
197 SELECT
198 n.nspname AS schema_name,
199 c.relname AS relation_name,
200 a.attname AS column_name,
201 pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
202 a.attnotnull AS not_null,
203 s.avg_width AS avg_width,
204 pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
205 a.atttypmod AS type_modifier
206 FROM pg_attribute a
207 JOIN pg_class c ON a.attrelid = c.oid
208 JOIN pg_namespace n ON n.oid = c.relnamespace
209 LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
210 LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
211 WHERE a.attnum > 0 AND NOT a.attisdropped
212 AND c.relkind IN ('r', 'p', 'v', 'm')
213 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
214 {schema_filter_with_fk}
215 ORDER BY n.nspname, c.relname;
216 ");
217
218 let mut current_object_id: Option<ObjectId> = None;
219 let mut current_rel: Option<*mut crate::model::relation::RelationState> = None;
220
221 for row in client.query(&col_query, &[])? {
222 let schema_name: String = row.get("schema_name");
223 let relation_name: String = row.get("relation_name");
224 let column_name: String = row.get("column_name");
225 let type_name: String = row.get("type_name");
226 let not_null: bool = row.get("not_null");
227 let avg_width: Option<i32> = row.get("avg_width");
228 let default_expr_text: Option<String> = row.get("default_expr_text");
229 let type_modifier: Option<i32> = row.get("type_modifier");
230
231 let is_same_rel = if let Some(ref cur) = current_object_id {
233 cur.schema == schema_name && cur.name == relation_name
234 } else {
235 false
236 };
237
238 if !is_same_rel {
239 let new_oid = ObjectId::new(&schema_name, &relation_name);
240 if let Some(rel) = cache.relations.get_mut(&new_oid) {
241 current_rel = Some(rel as *mut _);
242 } else {
243 current_rel = None;
244 }
245 current_object_id = Some(new_oid);
246 }
247
248 if let Some(rel_ptr) = current_rel {
249 let rel = unsafe { &mut *rel_ptr };
252 rel.columns.push(crate::model::column::Column {
253 name: column_name,
254 data_type: Some(type_name),
255 is_nullable: !not_null,
256 default: None,
257 avg_width,
258 default_expr_text,
259 type_modifier,
260 });
261 }
262 }
263
264 let tp_query = format!("
266 SELECT
267 n.nspname AS schema_name,
268 c.relname AS relation_name,
269 COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
270 COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
271 FROM pg_class c
272 JOIN pg_namespace n ON n.oid = c.relnamespace
273 LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
274 LEFT JOIN pg_policy p ON p.polrelid = c.oid
275 WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
276 {schema_filter_with_fk}
277 GROUP BY n.nspname, c.relname;
278 ");
279
280 for row in client.query(&tp_query, &[])? {
281 let schema_name: String = row.get("schema_name");
282 let relation_name: String = row.get("relation_name");
283 let triggers: Vec<String> = row.get("triggers");
284 let policies: Vec<String> = row.get("policies");
285
286 let object_id = ObjectId::new(&schema_name, &relation_name);
287
288 if let Some(rel) = cache.relations.get_mut(&object_id) {
289 rel.triggers.extend(triggers);
290 rel.policies.extend(policies);
291 }
292 }
293
294 let trig_query = format!(
296 "
297 SELECT
298 n.nspname AS table_schema,
299 c.relname AS table_name,
300 t.tgname AS trigger_name,
301 fn.nspname AS function_schema,
302 f.proname || '()' AS function_name
303 FROM pg_trigger t
304 JOIN pg_class c ON c.oid = t.tgrelid
305 JOIN pg_namespace n ON n.oid = c.relnamespace
306 JOIN pg_proc f ON f.oid = t.tgfoid
307 JOIN pg_namespace fn ON fn.oid = f.pronamespace
308 WHERE t.tgisinternal = false
309 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
310 {schema_filter_with_fk};
311 "
312 );
313
314 for row in client.query(&trig_query, &[])? {
315 let table_schema: String = row.get("table_schema");
316 let table_name: String = row.get("table_name");
317 let trigger_name: String = row.get("trigger_name");
318 let function_schema: String = row.get("function_schema");
319 let function_name: String = row.get("function_name");
320
321 cache.triggers.push(crate::db::cache::TriggerCache {
322 trigger_id: ObjectId::new(&table_schema, &trigger_name),
323 table_id: ObjectId::new(&table_schema, &table_name),
324 function_id: ObjectId::new(&function_schema, &function_name),
325 });
326 }
327
328 let fk_query = format!(
330 "
331 SELECT
332 c.conname AS constraint_name,
333 n1.nspname AS from_schema, t1.relname AS from_table,
334 n2.nspname AS to_schema, t2.relname AS to_table
335 FROM pg_constraint c
336 JOIN pg_class t1 ON t1.oid = c.conrelid
337 JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
338 JOIN pg_class t2 ON t2.oid = c.confrelid
339 JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
340 WHERE c.contype = 'f'
341 {schema_filter_n1_or_n2};
342 "
343 );
344
345 for row in client.query(&fk_query, &[])? {
346 let constraint_name: String = row.get("constraint_name");
347 let from_schema: String = row.get("from_schema");
348 let from_table: String = row.get("from_table");
349 let to_schema: String = row.get("to_schema");
350 let to_table: String = row.get("to_table");
351
352 if let Some(s) = schemas
353 && (!s.contains(&from_schema) || !s.contains(&to_schema))
354 {
355 let out_of_scope_schema = if !s.contains(&from_schema) {
357 &from_schema
358 } else {
359 &to_schema
360 };
361 let out_of_scope_table = if !s.contains(&from_schema) {
362 &from_table
363 } else {
364 &to_table
365 };
366 eprintln!(
367 "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
368 constraint_name, out_of_scope_schema, out_of_scope_table
369 );
370 }
371
372 cache.foreign_keys.push(ForeignKeyCache {
373 constraint_name,
374 from_table: ObjectId::new(&from_schema, &from_table),
375 to_table: ObjectId::new(&to_schema, &to_table),
376 });
377 }
378
379 let idx_query = format!(
381 "
382 SELECT
383 n_i.nspname AS index_schema, i.relname AS index_name,
384 n_t.nspname AS table_schema, t.relname AS table_name
385 FROM pg_index x
386 JOIN pg_class i ON i.oid = x.indexrelid
387 JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
388 JOIN pg_class t ON t.oid = x.indrelid
389 JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
390 WHERE x.indisvalid = true
391 {schema_filter_nt};
392 "
393 );
394
395 for row in client.query(&idx_query, &[])? {
396 let index_schema: String = row.get("index_schema");
397 let index_name: String = row.get("index_name");
398 let table_schema: String = row.get("table_schema");
399 let table_name: String = row.get("table_name");
400
401 cache.indexes.push(IndexCache {
402 index_id: ObjectId::new(&index_schema, &index_name),
403 table_id: ObjectId::new(&table_schema, &table_name),
404 });
405 }
406
407 let func_query = format!(
409 "
410 SELECT
411 n.nspname AS schema_name,
412 p.proname AS func_name,
413 COALESCE(
414 (SELECT string_agg(pg_catalog.format_type(t, NULL), ',' ORDER BY n)
415 FROM unnest(p.proargtypes::int[]) WITH ORDINALITY AS u(t, n)),
416 ''
417 ) AS arg_types,
418 pg_catalog.pg_get_function_result(p.oid) AS return_type,
419 p.provolatile::text AS volatility,
420 l.lanname AS language,
421 p.prosecdef AS security_definer
422 FROM pg_proc p
423 JOIN pg_namespace n ON n.oid = p.pronamespace
424 JOIN pg_language l ON l.oid = p.prolang
425 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
426 AND p.prokind = 'f'
427 {schema_filter};
428 "
429 );
430
431 for row in client.query(&func_query, &[])? {
432 let schema_name: String = row.get("schema_name");
433 let func_name: String = row.get("func_name");
434 let arg_types_str: String = row.get("arg_types");
435 let return_type: Option<String> = row.get("return_type");
436 let volatility_char: String = row.get("volatility");
437 let language: String = row.get("language");
438 let security_definer: bool = row.get("security_definer");
439
440 let volatility = match volatility_char.as_str() {
441 "v" => crate::model::function::Volatility::Volatile,
442 "s" => crate::model::function::Volatility::Stable,
443 "i" => crate::model::function::Volatility::Immutable,
444 _ => crate::model::function::Volatility::Volatile,
445 };
446
447 let security = if security_definer {
448 crate::model::function::SecurityMode::Definer
449 } else {
450 crate::model::function::SecurityMode::Invoker
451 };
452
453 let arg_types_str = arg_types_str
455 .split(',')
456 .map(|s| s.trim().to_lowercase())
457 .collect::<Vec<_>>()
458 .join(",");
459
460 let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str));
461
462 let arg_types = if arg_types_str.is_empty() {
463 Vec::new()
464 } else {
465 arg_types_str.split(',').map(|s| s.to_string()).collect()
466 };
467
468 cache.functions.insert(
469 id.clone(),
470 crate::model::function::FunctionState {
471 id,
472 arg_types,
473 return_type: return_type.unwrap_or_default(),
474 volatility,
475 language,
476 security,
477 },
478 );
479 }
480
481 let tmp_path = out_path.with_extension("tmp");
483 let file = std::fs::File::create(&tmp_path).context("Failed to create temporary cache file")?;
484 let writer = std::io::BufWriter::new(file);
485 let mut encoder =
486 zstd::stream::Encoder::new(writer, 3).context("Failed to init zstd compression")?;
487
488 let versioned = crate::db::cache::DbCacheVersioned::V1(cache);
489 let bincode_config = bincode::config::standard().with_variable_int_encoding();
490
491 bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config)
492 .context("Failed binary bincode 2.0 schema compilation and write")?;
493
494 encoder
495 .finish()
496 .context("Failed to flush final zstd stream to disk")?;
497
498 fs::rename(&tmp_path, out_path).context("Failed to atomically rename cache file")?;
499
500 Ok(())
501}