1use crate::ast::identifiers::ObjectId;
4use crate::db::cache::{CACHE_V3_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache};
5use crate::db::cache_file::protect_cache_bytes;
6use crate::model::relation::{Persistence, RelationKind, RelationState};
7use anyhow::{Context, Result};
8use postgres::config::Host;
9use postgres::{Client, Config as PostgresConfig, NoTls};
10use std::io::Write;
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13use tempfile::NamedTempFile;
14
15#[cfg(windows)]
16use std::fs;
17
18pub fn sync_cache(
19 out_path: &Path,
20 schemas: Option<&[String]>,
21 cache_encryption: bool,
22) -> Result<()> {
23 let db_url = std::env::var("DATABASE_URL")
25 .context("DATABASE_URL environment variable is required to sync PostgreSQL schema metadata and statistics. Do not pass credentials via CLI flags or config files.")?;
26
27 let mut client = connect_database(&db_url)?;
28
29 let cache = populate_cache(&mut client, schemas)?;
30
31 write_cache(out_path, cache, cache_encryption)
32}
33
34fn connect_database(db_url: &str) -> Result<Client> {
35 let config: PostgresConfig = db_url
36 .parse()
37 .context("DATABASE_URL is not a valid PostgreSQL connection string")?;
38
39 if config
40 .get_hosts()
41 .iter()
42 .any(|host| matches!(host, Host::Tcp(name) if !is_local_host(name)))
43 {
44 anyhow::bail!(
45 "Remote DATABASE_URL connections are not supported by this build. Use an SSH tunnel and connect through localhost or a Unix socket."
46 );
47 }
48
49 config
50 .connect(NoTls)
51 .context("Failed to connect to PostgreSQL")
52}
53
54pub(crate) fn is_local_host(host: &str) -> bool {
55 if host.starts_with('/') || host.eq_ignore_ascii_case("localhost") {
56 return true;
57 }
58 host.trim_start_matches('[')
59 .trim_end_matches(']')
60 .parse::<std::net::IpAddr>()
61 .is_ok_and(|address| address.is_loopback())
62}
63
64pub(crate) fn cache_search_path(
65 database_search_path: Vec<String>,
66 schemas: Option<&[String]>,
67) -> Vec<String> {
68 let Some(schemas) = schemas else {
69 return database_search_path;
70 };
71
72 let mut scoped_search_path = Vec::new();
73 for schema in database_search_path
74 .into_iter()
75 .filter(|schema| schemas.contains(schema))
76 .chain(schemas.iter().cloned())
77 {
78 if !scoped_search_path.contains(&schema) {
79 scoped_search_path.push(schema);
80 }
81 }
82 scoped_search_path
83}
84
85pub(crate) fn relation_owner_id(owner_name: impl Into<String>) -> ObjectId {
86 ObjectId::new("", owner_name)
87}
88
89pub(crate) fn is_system_schema(schema: &str) -> bool {
90 schema == "information_schema" || schema.starts_with("pg_")
91}
92
93fn write_cache(out_path: &Path, cache: DbCache, cache_encryption: bool) -> Result<()> {
94 write_cache_with_protection(out_path, cache, |compressed| {
95 protect_cache_bytes(compressed, cache_encryption)
96 })
97}
98
99fn write_cache_with_protection(
100 out_path: &Path,
101 cache: DbCache,
102 protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
103) -> Result<()> {
104 let parent = out_path.parent().unwrap_or_else(|| Path::new("."));
105 let mut temp_file = NamedTempFile::new_in(parent).with_context(|| {
106 format!(
107 "Failed to create temporary cache file beside {}",
108 out_path.display()
109 )
110 })?;
111 let mut compressed = Vec::new();
112 let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3)
113 .context("Failed to init zstd compression")?;
114
115 encoder
116 .write_all(CACHE_V3_MAGIC)
117 .context("Failed to write cache V3 payload header")?;
118
119 let versioned = DbCacheVersioned::V3(cache);
120 let bincode_config = bincode::config::standard().with_variable_int_encoding();
121
122 bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config)
123 .context("Failed bincode schema compilation and write")?;
124
125 encoder
126 .finish()
127 .context("Failed to flush final zstd stream to disk")?;
128
129 let cache_bytes = protect(compressed)?;
130 temp_file
131 .write_all(&cache_bytes)
132 .context("Failed to write cache payload")?;
133 temp_file.flush().context("Failed to flush cache payload")?;
134
135 replace_cache(temp_file, out_path)?;
136
137 Ok(())
138}
139
140#[cfg(not(windows))]
141fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
142 temp_file
143 .persist(out_path)
144 .map_err(|error| error.error)
145 .with_context(|| {
146 format!(
147 "Failed to atomically replace cache file: {}",
148 out_path.display()
149 )
150 })?;
151 Ok(())
152}
153
154#[cfg(windows)]
155fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
156 if !out_path.exists() {
157 temp_file
158 .persist(out_path)
159 .map_err(|error| error.error)
160 .with_context(|| format!("Failed to install cache file: {}", out_path.display()))?;
161 return Ok(());
162 }
163
164 let backup = out_path.with_extension("safe-migrate.backup");
165 fs::rename(out_path, &backup).with_context(|| {
166 format!(
167 "Failed to stage existing cache for replacement: {}",
168 out_path.display()
169 )
170 })?;
171
172 match temp_file.persist(out_path) {
173 Ok(_) => {
174 fs::remove_file(&backup).with_context(|| {
175 format!(
176 "Installed new cache but failed to remove backup: {}",
177 backup.display()
178 )
179 })?;
180 Ok(())
181 }
182 Err(error) => {
183 let restore_result = fs::rename(&backup, out_path);
184 let message = if let Err(restore_error) = restore_result {
185 format!(
186 "Failed to install new cache: {}. The old cache could not be restored: {}",
187 error.error, restore_error
188 )
189 } else {
190 format!(
191 "Failed to install new cache; restored the previous cache: {}",
192 error.error
193 )
194 };
195 Err(anyhow::anyhow!(message))
196 }
197 }
198}
199
200pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result<DbCache> {
201 let mut cache = DbCache::new();
202 let schema_values = schemas.map(|items| items.to_vec());
203 cache.metadata.created_at_unix_secs = Some(
204 SystemTime::now()
205 .duration_since(UNIX_EPOCH)
206 .unwrap_or_default()
207 .as_secs(),
208 );
209 cache.metadata.schemas = schema_values.clone();
210
211 let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1))";
212 let schema_filter_with_fk = r#"
213 AND (
214 $1::text[] IS NULL
215 OR n.nspname = ANY($1)
216 OR c.oid IN (
217 SELECT conrelid FROM pg_constraint cst
218 JOIN pg_class c2 ON c2.oid = cst.confrelid
219 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
220 WHERE n2.nspname = ANY($1)
221 )
222 OR c.oid IN (
223 SELECT confrelid FROM pg_constraint cst
224 JOIN pg_class c2 ON c2.oid = cst.conrelid
225 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
226 WHERE n2.nspname = ANY($1)
227 )
228 )
229 "#;
230 let schema_filter_n1_or_n2 =
231 "AND ($1::text[] IS NULL OR n1.nspname = ANY($1) OR n2.nspname = ANY($1))";
232 let schema_filter_nt = r#"
233 AND (
234 $1::text[] IS NULL
235 OR n_t.nspname = ANY($1)
236 OR t.oid IN (
237 SELECT conrelid FROM pg_constraint cst
238 JOIN pg_class c2 ON c2.oid = cst.confrelid
239 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
240 WHERE n2.nspname = ANY($1)
241 )
242 OR t.oid IN (
243 SELECT confrelid FROM pg_constraint cst
244 JOIN pg_class c2 ON c2.oid = cst.conrelid
245 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
246 WHERE n2.nspname = ANY($1)
247 )
248 )
249 "#;
250
251 let version_row = client.query_one("SHOW server_version_num;", &[])?;
253 let version_str: String = version_row.get(0);
254 cache.pg_version_num = version_str.parse::<u32>().ok();
255
256 let database_row = client.query_one("SELECT current_database();", &[])?;
257 cache.metadata.source_database = Some(database_row.get(0));
258
259 let search_path_row = client.query_one("SELECT current_schemas(false);", &[])?;
264 cache.search_path = cache_search_path(search_path_row.get(0), schemas);
265
266 let table_query = format!(
268 "
269 SELECT
270 n.nspname AS schema_name,
271 c.relname AS relation_name,
272 c.relkind AS relation_kind,
273 c.relpersistence AS persistence,
274 pg_catalog.pg_get_userbyid(c.relowner) AS owner_name,
275 CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
276 c.relpages::bigint AS relpages,
277 to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
278 to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
279 p.partstrat::text AS partition_strategy
280 FROM pg_class c
281 JOIN pg_namespace n ON n.oid = c.relnamespace
282 LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
283 LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
284 WHERE c.relkind IN ('r', 'p', 'v', 'm')
285 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
286 {schema_filter_with_fk};
287 "
288 );
289
290 for row in client.query(&table_query, &[&schema_values])? {
291 let schema_name: String = row.get("schema_name");
292 let relation_name: String = row.get("relation_name");
293 let relkind: i8 = row.get("relation_kind");
294 let persistence_char: i8 = row.get("persistence");
295 let owner_name: String = row.get("owner_name");
296 let raw_rows: i64 = row.get("estimated_rows");
297 let relpages: i64 = row.get("relpages");
298
299 let last_analyze: Option<String> = row.get("last_analyze");
300 let last_autoanalyze: Option<String> = row.get("last_autoanalyze");
301
302 let object_id = ObjectId::new(&schema_name, &relation_name);
303
304 let kind = match relkind as u8 {
305 b'v' => RelationKind::View,
306 b'm' => RelationKind::MaterializedView,
307 _ => RelationKind::Table,
308 };
309
310 let persistence = match persistence_char as u8 {
311 b't' => Persistence::Temporary,
312 b'u' => Persistence::Unlogged,
313 _ => Persistence::Permanent,
314 };
315
316 let estimated_rows = if raw_rows < 0 {
317 None
318 } else {
319 Some(raw_rows as u64)
320 };
321
322 let mut state = RelationState::new(
323 object_id.clone(),
324 relation_owner_id(owner_name),
325 0,
326 estimated_rows,
327 kind,
328 persistence,
329 0,
330 );
331 state.relpages = Some(relpages as u64);
332 state.last_analyze = last_analyze;
333 state.last_autoanalyze = last_autoanalyze;
334
335 let partition_strategy: Option<String> = row.get("partition_strategy");
336 if let Some(ref strat) = partition_strategy {
337 state.partition_type = Some(match strat.as_str() {
338 "r" => "RANGE".to_string(),
339 "l" => "LIST".to_string(),
340 "h" => "HASH".to_string(),
341 _ => strat.to_uppercase(),
342 });
343 }
344
345 if let Some(s) = schemas
346 && !s.contains(&schema_name)
347 {
348 state.mark_fk_dependency();
349 }
350
351 cache.insert_baseline(object_id, state);
352 }
353
354 let col_query = format!("
356 SELECT
357 n.nspname AS schema_name,
358 c.relname AS relation_name,
359 a.attname AS column_name,
360 pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
361 a.attnotnull AS not_null,
362 s.avg_width AS avg_width,
363 pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
364 a.atttypmod AS type_modifier
365 FROM pg_attribute a
366 JOIN pg_class c ON a.attrelid = c.oid
367 JOIN pg_namespace n ON n.oid = c.relnamespace
368 LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
369 LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
370 WHERE a.attnum > 0 AND NOT a.attisdropped
371 AND c.relkind IN ('r', 'p', 'v', 'm')
372 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
373 {schema_filter_with_fk}
374 ORDER BY n.nspname, c.relname;
375 ");
376
377 for row in client.query(&col_query, &[&schema_values])? {
378 let schema_name: String = row.get("schema_name");
379 let relation_name: String = row.get("relation_name");
380 let column_name: String = row.get("column_name");
381 let type_name: String = row.get("type_name");
382 let not_null: bool = row.get("not_null");
383 let avg_width: Option<i32> = row.get("avg_width");
384 let default_expr_text: Option<String> = row.get("default_expr_text");
385 let type_modifier: Option<i32> = row.get("type_modifier");
386
387 let relation_id = ObjectId::new(&schema_name, &relation_name);
388 if let Some(rel) = cache.relations.get_mut(&relation_id) {
389 rel.columns.push(crate::model::column::Column {
390 name: column_name,
391 data_type: Some(type_name),
392 is_nullable: !not_null,
393 default: None,
394 avg_width,
395 default_expr_text,
396 type_modifier,
397 });
398 }
399 }
400
401 let tp_query = format!("
403 SELECT
404 n.nspname AS schema_name,
405 c.relname AS relation_name,
406 COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
407 COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
408 FROM pg_class c
409 JOIN pg_namespace n ON n.oid = c.relnamespace
410 LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
411 LEFT JOIN pg_policy p ON p.polrelid = c.oid
412 WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
413 {schema_filter_with_fk}
414 GROUP BY n.nspname, c.relname;
415 ");
416
417 for row in client.query(&tp_query, &[&schema_values])? {
418 let schema_name: String = row.get("schema_name");
419 let relation_name: String = row.get("relation_name");
420 let triggers: Vec<String> = row.get("triggers");
421 let policies: Vec<String> = row.get("policies");
422
423 let object_id = ObjectId::new(&schema_name, &relation_name);
424
425 if let Some(rel) = cache.relations.get_mut(&object_id) {
426 rel.triggers.extend(triggers);
427 rel.policies.extend(policies);
428 }
429 }
430
431 let acl_query = format!(
433 "
434 SELECT
435 n.nspname AS schema_name,
436 c.relname AS relation_name,
437 CASE
438 WHEN acl.grantee = 0 THEN 'public'
439 ELSE pg_catalog.pg_get_userbyid(acl.grantee)
440 END AS grantee,
441 acl.privilege_type
442 FROM pg_class c
443 JOIN pg_namespace n ON n.oid = c.relnamespace
444 CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
445 WHERE c.relkind IN ('r', 'p', 'v', 'm')
446 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
447 AND acl.grantee <> c.relowner
448 {schema_filter_with_fk};
449 "
450 );
451
452 for row in client.query(&acl_query, &[&schema_values])? {
453 let schema_name: String = row.get("schema_name");
454 let relation_name: String = row.get("relation_name");
455 let grantee: String = row.get("grantee");
456 let privilege_type: String = row.get("privilege_type");
457 let privilege = match privilege_type.as_str() {
458 "SELECT" => crate::model::relation::Privilege::Select,
459 "INSERT" => crate::model::relation::Privilege::Insert,
460 "UPDATE" => crate::model::relation::Privilege::Update,
461 "DELETE" => crate::model::relation::Privilege::Delete,
462 "TRUNCATE" => crate::model::relation::Privilege::Truncate,
463 "REFERENCES" => crate::model::relation::Privilege::References,
464 "TRIGGER" => crate::model::relation::Privilege::Trigger,
465 _ => continue,
466 };
467 if let Some(relation) = cache
468 .relations
469 .get_mut(&ObjectId::new(&schema_name, &relation_name))
470 {
471 relation.privileges.grant(
472 ObjectId::new("", grantee),
473 [privilege].into_iter().collect(),
474 );
475 }
476 }
477
478 let trig_query = format!(
480 "
481 SELECT
482 n.nspname AS table_schema,
483 c.relname AS table_name,
484 t.tgname AS trigger_name,
485 t.tgenabled::text AS enabled_mode,
486 fn.nspname AS function_schema,
487 f.proname || '()' AS function_name
488 FROM pg_trigger t
489 JOIN pg_class c ON c.oid = t.tgrelid
490 JOIN pg_namespace n ON n.oid = c.relnamespace
491 JOIN pg_proc f ON f.oid = t.tgfoid
492 JOIN pg_namespace fn ON fn.oid = f.pronamespace
493 WHERE t.tgisinternal = false
494 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
495 {schema_filter_with_fk};
496 "
497 );
498
499 for row in client.query(&trig_query, &[&schema_values])? {
500 let table_schema: String = row.get("table_schema");
501 let table_name: String = row.get("table_name");
502 let trigger_name: String = row.get("trigger_name");
503 let enabled_mode: String = row.get("enabled_mode");
504 let function_schema: String = row.get("function_schema");
505 let function_name: String = row.get("function_name");
506
507 cache.triggers.push(crate::db::cache::TriggerCache {
508 trigger_id: ObjectId::new(&table_schema, &trigger_name),
509 table_id: ObjectId::new(&table_schema, &table_name),
510 function_id: ObjectId::new(&function_schema, &function_name),
511 enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode)
512 .ok_or_else(|| {
513 anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}")
514 })?,
515 });
516 }
517
518 let constraint_query = format!(
520 "
521 SELECT
522 n.nspname AS table_schema,
523 c.relname AS table_name,
524 con.conname AS constraint_name,
525 con.contype::text AS constraint_type,
526 con.convalidated AS validated
527 FROM pg_constraint con
528 JOIN pg_class c ON c.oid = con.conrelid
529 JOIN pg_namespace n ON n.oid = c.relnamespace
530 WHERE con.contype IN ('c', 'f', 'p', 'u', 'x')
531 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
532 {schema_filter};
533 "
534 );
535
536 for row in client.query(&constraint_query, &[&schema_values])? {
537 let table_schema: String = row.get("table_schema");
538 let table_name: String = row.get("table_name");
539 let constraint_name: String = row.get("constraint_name");
540 let constraint_type: String = row.get("constraint_type");
541 let validated: bool = row.get("validated");
542 let kind = match constraint_type.as_str() {
543 "c" => crate::model::constraint::ConstraintKind::Check,
544 "f" => crate::model::constraint::ConstraintKind::ForeignKey,
545 "p" => crate::model::constraint::ConstraintKind::PrimaryKey,
546 "u" => crate::model::constraint::ConstraintKind::Unique,
547 "x" => crate::model::constraint::ConstraintKind::Exclusion,
548 _ => continue,
549 };
550 cache
551 .constraints
552 .push(crate::model::constraint::ConstraintState {
553 table_id: ObjectId::new(&table_schema, &table_name),
554 name: constraint_name,
555 kind,
556 validated,
557 });
558 }
559
560 let fk_query = format!(
562 "
563 SELECT
564 c.conname AS constraint_name,
565 n1.nspname AS from_schema, t1.relname AS from_table,
566 n2.nspname AS to_schema, t2.relname AS to_table
567 FROM pg_constraint c
568 JOIN pg_class t1 ON t1.oid = c.conrelid
569 JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
570 JOIN pg_class t2 ON t2.oid = c.confrelid
571 JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
572 WHERE c.contype = 'f'
573 {schema_filter_n1_or_n2};
574 "
575 );
576
577 for row in client.query(&fk_query, &[&schema_values])? {
578 let constraint_name: String = row.get("constraint_name");
579 let from_schema: String = row.get("from_schema");
580 let from_table: String = row.get("from_table");
581 let to_schema: String = row.get("to_schema");
582 let to_table: String = row.get("to_table");
583
584 if let Some(s) = schemas
585 && (!s.contains(&from_schema) || !s.contains(&to_schema))
586 {
587 let out_of_scope_schema = if !s.contains(&from_schema) {
589 &from_schema
590 } else {
591 &to_schema
592 };
593 let out_of_scope_table = if !s.contains(&from_schema) {
594 &from_table
595 } else {
596 &to_table
597 };
598 eprintln!(
599 "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
600 constraint_name, out_of_scope_schema, out_of_scope_table
601 );
602 }
603
604 cache.foreign_keys.push(ForeignKeyCache {
605 constraint_name,
606 from_table: ObjectId::new(&from_schema, &from_table),
607 to_table: ObjectId::new(&to_schema, &to_table),
608 });
609 }
610
611 let idx_query = format!(
613 "
614 SELECT
615 n_i.nspname AS index_schema, i.relname AS index_name,
616 n_t.nspname AS table_schema, t.relname AS table_name
617 FROM pg_index x
618 JOIN pg_class i ON i.oid = x.indexrelid
619 JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
620 JOIN pg_class t ON t.oid = x.indrelid
621 JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
622 WHERE x.indisvalid = true
623 AND n_i.nspname !~ '^pg_'
624 AND n_i.nspname <> 'information_schema'
625 AND n_t.nspname !~ '^pg_'
626 AND n_t.nspname <> 'information_schema'
627 {schema_filter_nt};
628 "
629 );
630
631 for row in client.query(&idx_query, &[&schema_values])? {
632 let index_schema: String = row.get("index_schema");
633 let index_name: String = row.get("index_name");
634 let table_schema: String = row.get("table_schema");
635 let table_name: String = row.get("table_name");
636
637 if is_system_schema(&index_schema) || is_system_schema(&table_schema) {
638 continue;
639 }
640
641 cache.indexes.push(IndexCache {
642 index_id: ObjectId::new(&index_schema, &index_name),
643 table_id: ObjectId::new(&table_schema, &table_name),
644 });
645 }
646
647 let func_query = format!(
649 "
650 SELECT
651 n.nspname AS schema_name,
652 p.proname AS func_name,
653 COALESCE(
654 (SELECT string_agg(pg_catalog.format_type(t, NULL), ',' ORDER BY n)
655 FROM unnest(p.proargtypes::int[]) WITH ORDINALITY AS u(t, n)),
656 ''
657 ) AS arg_types,
658 pg_catalog.pg_get_function_result(p.oid) AS return_type,
659 p.provolatile::text AS volatility,
660 l.lanname AS language,
661 p.prosecdef AS security_definer
662 FROM pg_proc p
663 JOIN pg_namespace n ON n.oid = p.pronamespace
664 JOIN pg_language l ON l.oid = p.prolang
665 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
666 AND p.prokind = 'f'
667 {schema_filter};
668 "
669 );
670
671 for row in client.query(&func_query, &[&schema_values])? {
672 let schema_name: String = row.get("schema_name");
673 let func_name: String = row.get("func_name");
674 let arg_types_str: String = row.get("arg_types");
675 let return_type: Option<String> = row.get("return_type");
676 let volatility_char: String = row.get("volatility");
677 let language: String = row.get("language");
678 let security_definer: bool = row.get("security_definer");
679
680 let volatility = match volatility_char.as_str() {
681 "v" => crate::model::function::Volatility::Volatile,
682 "s" => crate::model::function::Volatility::Stable,
683 "i" => crate::model::function::Volatility::Immutable,
684 _ => crate::model::function::Volatility::Volatile,
685 };
686
687 let security = if security_definer {
688 crate::model::function::SecurityMode::Definer
689 } else {
690 crate::model::function::SecurityMode::Invoker
691 };
692
693 let arg_types_str = arg_types_str
695 .split(',')
696 .map(|s| s.trim().to_lowercase())
697 .collect::<Vec<_>>()
698 .join(",");
699
700 let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str));
701
702 let arg_types = if arg_types_str.is_empty() {
703 Vec::new()
704 } else {
705 arg_types_str.split(',').map(|s| s.to_string()).collect()
706 };
707
708 cache.functions.insert(
709 id.clone(),
710 crate::model::function::FunctionState {
711 id,
712 arg_types,
713 return_type: return_type.unwrap_or_default(),
714 volatility,
715 language,
716 security,
717 },
718 );
719 }
720
721 let type_query = format!(
723 "
724 SELECT
725 n.nspname AS schema_name,
726 t.typname AS type_name,
727 t.typtype::text AS type_kind,
728 CASE WHEN t.typtype = 'd'
729 THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
730 ELSE NULL
731 END AS domain_base_type,
732 COALESCE(
733 array_agg(e.enumlabel ORDER BY e.enumsortorder)
734 FILTER (WHERE e.enumlabel IS NOT NULL),
735 ARRAY[]::text[]
736 ) AS enum_labels
737 FROM pg_type t
738 JOIN pg_namespace n ON n.oid = t.typnamespace
739 LEFT JOIN pg_enum e ON e.enumtypid = t.oid
740 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
741 AND t.typtype IN ('e', 'd')
742 {schema_filter}
743 GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod;
744 "
745 );
746
747 for row in client.query(&type_query, &[&schema_values])? {
748 let schema_name: String = row.get("schema_name");
749 let type_name: String = row.get("type_name");
750 let type_kind: String = row.get("type_kind");
751 let domain_base_type: Option<String> = row.get("domain_base_type");
752 let enum_labels: Vec<String> = row.get("enum_labels");
753 let kind = match type_kind.as_str() {
754 "e" => crate::model::types::TypeKind::Enum {
755 variants: enum_labels,
756 },
757 "d" => crate::model::types::TypeKind::Domain {
758 base_type: domain_base_type.unwrap_or_default(),
759 },
760 _ => continue,
761 };
762 let id = ObjectId::new(&schema_name, &type_name);
763 cache.types.insert(
764 id.clone(),
765 crate::model::types::TypeState {
766 id,
767 generation: 0,
768 kind,
769 },
770 );
771 }
772
773 let depend_query = r#"
775 SELECT
776 d.classid, d.objid, d.objsubid,
777 d.refclassid, d.refobjid, d.refobjsubid,
778 d.deptype::text,
779 COALESCE(n1.nspname, n1p.nspname, n1t.nspname) AS obj_schema,
780 COALESCE(c1.relname, p1.proname, t1.typname) AS obj_name,
781 COALESCE(n2.nspname, n2p.nspname, n2t.nspname) AS ref_schema,
782 COALESCE(c2.relname, p2.proname, t2.typname) AS ref_name
783 FROM pg_depend d
784 LEFT JOIN pg_class c1 ON c1.oid = d.objid AND d.classid = 'pg_class'::regclass
785 LEFT JOIN pg_namespace n1 ON n1.oid = c1.relnamespace
786 LEFT JOIN pg_proc p1 ON p1.oid = d.objid AND d.classid = 'pg_proc'::regclass
787 LEFT JOIN pg_namespace n1p ON n1p.oid = p1.pronamespace
788 LEFT JOIN pg_type t1 ON t1.oid = d.objid AND d.classid = 'pg_type'::regclass
789 LEFT JOIN pg_namespace n1t ON n1t.oid = t1.typnamespace
790 LEFT JOIN pg_class c2 ON c2.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
791 LEFT JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
792 LEFT JOIN pg_proc p2 ON p2.oid = d.refobjid AND d.refclassid = 'pg_proc'::regclass
793 LEFT JOIN pg_namespace n2p ON n2p.oid = p2.pronamespace
794 LEFT JOIN pg_type t2 ON t2.oid = d.refobjid AND d.refclassid = 'pg_type'::regclass
795 LEFT JOIN pg_namespace n2t ON n2t.oid = t2.typnamespace
796 WHERE d.deptype IN ('n', 'a', 'i')
797 AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname) IS NOT NULL
798 AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname)
799 NOT IN ('pg_catalog', 'information_schema')
800 AND (
801 $1::text[] IS NULL
802 OR COALESCE(n1.nspname, n1p.nspname, n1t.nspname) = ANY($1)
803 )
804 "#;
805
806 for row in client.query(depend_query, &[&schema_values])? {
807 let classid: u32 = row.get(0);
808 let objid: u32 = row.get(1);
809 let objsubid: i32 = row.get(2);
810 let refclassid: u32 = row.get(3);
811 let refobjid: u32 = row.get(4);
812 let refobjsubid: i32 = row.get(5);
813 let deptype: String = row.get(6);
814 let obj_schema: Option<String> = row.get(7);
815 let obj_name: Option<String> = row.get(8);
816 let ref_schema: Option<String> = row.get(9);
817 let ref_name: Option<String> = row.get(10);
818
819 cache.dependencies.push(crate::db::cache::DependencyCache {
820 classid,
821 objid,
822 objsubid,
823 refclassid,
824 refobjid,
825 refobjsubid,
826 deptype,
827 obj_schema,
828 obj_name,
829 ref_schema,
830 ref_name,
831 });
832 }
833
834 let view_depend_query = r#"
837 SELECT DISTINCT
838 'pg_class'::regclass::oid AS classid,
839 vc.oid AS objid,
840 0 AS objsubid,
841 'pg_class'::regclass::oid AS refclassid,
842 tc.oid AS refobjid,
843 0 AS refobjsubid,
844 vn.nspname AS obj_schema,
845 vc.relname AS obj_name,
846 tn.nspname AS ref_schema,
847 tc.relname AS ref_name
848 FROM pg_rewrite rw
849 JOIN pg_class vc ON vc.oid = rw.ev_class
850 JOIN pg_namespace vn ON vn.oid = vc.relnamespace
851 JOIN pg_depend d ON d.objid = rw.oid
852 JOIN pg_class tc ON tc.oid = d.refobjid
853 JOIN pg_namespace tn ON tn.oid = tc.relnamespace
854 WHERE vc.relkind IN ('v', 'm')
855 AND d.deptype = 'n'
856 -- PostgreSQL 14/15 expose an internal rewrite-rule self-edge. It is
857 -- not a dependency of the view definition and must not enter the
858 -- modeled dependency graph.
859 AND tc.oid <> vc.oid
860 AND (
861 $1::text[] IS NULL
862 OR (vn.nspname = ANY($1) AND tn.nspname = ANY($1))
863 )
864 "#;
865
866 for row in client.query(view_depend_query, &[&schema_values])? {
867 cache.dependencies.push(crate::db::cache::DependencyCache {
868 classid: row.get(0),
869 objid: row.get(1),
870 objsubid: row.get(2),
871 refclassid: row.get(3),
872 refobjid: row.get(4),
873 refobjsubid: row.get(5),
874 deptype: "view".to_string(),
875 obj_schema: Some(row.get(6)),
876 obj_name: Some(row.get(7)),
877 ref_schema: Some(row.get(8)),
878 ref_name: Some(row.get(9)),
879 });
880 }
881
882 Ok(cache)
883}
884
885#[cfg(test)]
886mod atomic_write_tests {
887 use super::*;
888 use crate::db::cache::DbCacheVersioned;
889 use std::fs;
890 use std::io::Read;
891
892 #[test]
893 fn production_cache_writer_atomically_replaces_and_decodes() {
894 let temp_dir = tempfile::tempdir().unwrap();
895 let cache_path = temp_dir.path().join("baseline.cache");
896 fs::write(&cache_path, b"old-cache").unwrap();
897
898 let mut cache = DbCache::new();
899 cache.pg_version_num = Some(180002);
900 write_cache(&cache_path, cache, false).unwrap();
901
902 let encoded = fs::read(&cache_path).unwrap();
903 assert_ne!(encoded, b"old-cache");
904 let reader = std::io::Cursor::new(encoded);
905 let mut decoder = zstd::stream::Decoder::new(reader).unwrap();
906 let mut payload = Vec::new();
907 decoder.read_to_end(&mut payload).unwrap();
908 let payload = payload
909 .strip_prefix(CACHE_V3_MAGIC)
910 .expect("writer must prefix V3 cache payloads");
911 let config = bincode::config::standard().with_variable_int_encoding();
912 let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config)
913 .unwrap()
914 .0;
915 assert_eq!(versioned.into_cache().unwrap().pg_version_num, Some(180002));
916 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
917 }
918
919 #[test]
920 fn production_cache_writer_preserves_old_bytes_after_pre_install_failure() {
921 let temp_dir = tempfile::tempdir().unwrap();
922 let cache_path = temp_dir.path().join("baseline.cache");
923 fs::write(&cache_path, b"known-good-cache").unwrap();
924
925 let error = write_cache_with_protection(&cache_path, DbCache::new(), |_| {
926 Err(anyhow::anyhow!("injected payload-protection failure"))
927 })
928 .unwrap_err();
929
930 assert!(
931 error
932 .to_string()
933 .contains("injected payload-protection failure")
934 );
935 assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
936 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
937 }
938}