1use crate::ast::identifiers::ObjectId;
4use crate::db::cache::{CACHE_V5_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 parse_search_path_setting(setting: &str) -> Vec<String> {
88 let mut entries = Vec::new();
89 let mut current = String::new();
90 let mut chars = setting.chars().peekable();
91 let mut quoted = false;
92
93 while let Some(ch) = chars.next() {
94 match ch {
95 '"' if quoted && chars.peek() == Some(&'"') => {
96 current.push('"');
97 chars.next();
98 }
99 '"' => quoted = !quoted,
100 ',' if !quoted => {
101 let entry = current.trim();
102 if !entry.is_empty() {
103 entries.push(entry.to_string());
104 }
105 current.clear();
106 }
107 _ => current.push(ch),
108 }
109 }
110
111 let entry = current.trim();
112 if !entry.is_empty() {
113 entries.push(entry.to_string());
114 }
115 entries
116}
117
118pub(crate) fn relation_owner_id(owner_name: impl Into<String>) -> ObjectId {
119 ObjectId::new("", owner_name)
120}
121
122pub(crate) fn is_system_schema(schema: &str) -> bool {
123 schema == "information_schema" || schema.starts_with("pg_")
124}
125
126fn write_cache(out_path: &Path, cache: DbCache, cache_encryption: bool) -> Result<()> {
127 write_cache_with_protection(out_path, cache, |compressed| {
128 protect_cache_bytes(compressed, cache_encryption)
129 })
130}
131
132fn write_cache_with_protection(
133 out_path: &Path,
134 cache: DbCache,
135 protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
136) -> Result<()> {
137 let parent = out_path.parent().unwrap_or_else(|| Path::new("."));
138 let mut temp_file = NamedTempFile::new_in(parent).with_context(|| {
139 format!(
140 "Failed to create temporary cache file beside {}",
141 out_path.display()
142 )
143 })?;
144 let mut compressed = Vec::new();
145 let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3)
146 .context("Failed to init zstd compression")?;
147
148 encoder
149 .write_all(CACHE_V5_MAGIC)
150 .context("Failed to write cache V5 payload header")?;
151
152 let versioned = DbCacheVersioned::V5(Box::new(cache));
153 let bincode_config = bincode::config::standard().with_variable_int_encoding();
154
155 bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config)
156 .context("Failed bincode schema compilation and write")?;
157
158 encoder
159 .finish()
160 .context("Failed to flush final zstd stream to disk")?;
161
162 let cache_bytes = protect(compressed)?;
163 temp_file
164 .write_all(&cache_bytes)
165 .context("Failed to write cache payload")?;
166 temp_file.flush().context("Failed to flush cache payload")?;
167
168 replace_cache(temp_file, out_path)?;
169
170 Ok(())
171}
172
173#[cfg(not(windows))]
174fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
175 temp_file
176 .persist(out_path)
177 .map_err(|error| error.error)
178 .with_context(|| {
179 format!(
180 "Failed to atomically replace cache file: {}",
181 out_path.display()
182 )
183 })?;
184 Ok(())
185}
186
187#[cfg(windows)]
188fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
189 if !out_path.exists() {
190 temp_file
191 .persist(out_path)
192 .map_err(|error| error.error)
193 .with_context(|| format!("Failed to install cache file: {}", out_path.display()))?;
194 return Ok(());
195 }
196
197 let backup = out_path.with_extension("safe-migrate.backup");
198 fs::rename(out_path, &backup).with_context(|| {
199 format!(
200 "Failed to stage existing cache for replacement: {}",
201 out_path.display()
202 )
203 })?;
204
205 match temp_file.persist(out_path) {
206 Ok(_) => {
207 fs::remove_file(&backup).with_context(|| {
208 format!(
209 "Installed new cache but failed to remove backup: {}",
210 backup.display()
211 )
212 })?;
213 Ok(())
214 }
215 Err(error) => {
216 let restore_result = fs::rename(&backup, out_path);
217 let message = if let Err(restore_error) = restore_result {
218 format!(
219 "Failed to install new cache: {}. The old cache could not be restored: {}",
220 error.error, restore_error
221 )
222 } else {
223 format!(
224 "Failed to install new cache; restored the previous cache: {}",
225 error.error
226 )
227 };
228 Err(anyhow::anyhow!(message))
229 }
230 }
231}
232
233pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result<DbCache> {
234 let mut cache = DbCache::new();
235 let schema_values = schemas.map(|items| items.to_vec());
236 cache.metadata.created_at_unix_secs = Some(
237 SystemTime::now()
238 .duration_since(UNIX_EPOCH)
239 .unwrap_or_default()
240 .as_secs(),
241 );
242 cache.metadata.schemas = schema_values.clone();
243
244 let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1))";
245 let schema_filter_with_fk = r#"
246 AND (
247 $1::text[] IS NULL
248 OR n.nspname = ANY($1)
249 OR c.oid IN (
250 SELECT conrelid FROM pg_constraint cst
251 JOIN pg_class c2 ON c2.oid = cst.confrelid
252 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
253 WHERE n2.nspname = ANY($1)
254 )
255 OR c.oid IN (
256 SELECT confrelid FROM pg_constraint cst
257 JOIN pg_class c2 ON c2.oid = cst.conrelid
258 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
259 WHERE n2.nspname = ANY($1)
260 )
261 )
262 "#;
263 let schema_filter_n1_or_n2 =
264 "AND ($1::text[] IS NULL OR n1.nspname = ANY($1) OR n2.nspname = ANY($1))";
265 let schema_filter_nt = r#"
266 AND (
267 $1::text[] IS NULL
268 OR n_t.nspname = ANY($1)
269 OR t.oid IN (
270 SELECT conrelid FROM pg_constraint cst
271 JOIN pg_class c2 ON c2.oid = cst.confrelid
272 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
273 WHERE n2.nspname = ANY($1)
274 )
275 OR t.oid IN (
276 SELECT confrelid FROM pg_constraint cst
277 JOIN pg_class c2 ON c2.oid = cst.conrelid
278 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
279 WHERE n2.nspname = ANY($1)
280 )
281 )
282 "#;
283
284 let version_row = client.query_one("SHOW server_version_num;", &[])?;
286 let version_str: String = version_row.get(0);
287 cache.pg_version_num = version_str.parse::<u32>().ok();
288
289 let provenance_row = client.query_one(
290 "SELECT current_database(), current_user, session_user, current_setting('search_path');",
291 &[],
292 )?;
293 cache.metadata.source_database = Some(provenance_row.get(0));
294 cache.metadata.source_role = Some(provenance_row.get(1));
295 cache.metadata.source_session_role = Some(provenance_row.get(2));
296 let search_path_setting: String = provenance_row.get(3);
297 cache.metadata.source_search_path = Some(parse_search_path_setting(&search_path_setting));
298
299 let search_path_row = client.query_one("SELECT current_schemas(false);", &[])?;
304 cache.search_path = cache_search_path(search_path_row.get(0), schemas);
305
306 let schema_query = format!(
309 "SELECT n.nspname, pg_catalog.pg_get_userbyid(n.nspowner)
310 FROM pg_namespace n
311 WHERE n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
312 AND n.nspname <> 'information_schema'
313 {schema_filter}
314 ORDER BY n.nspname;"
315 );
316 for row in client.query(&schema_query, &[&schema_values])? {
317 let name: String = row.get(0);
318 let owner: String = row.get(1);
319 cache.schemas.insert(
320 name.clone(),
321 crate::model::schema::SchemaState {
322 name,
323 owner: relation_owner_id(owner),
324 generation: 0,
325 },
326 );
327 }
328 cache
332 .search_path
333 .retain(|schema| cache.schemas.contains_key(schema));
334
335 let sequence_query = format!(
340 "SELECT
341 n.nspname AS sequence_schema,
342 s.relname AS sequence_name,
343 pg_catalog.pg_get_userbyid(s.relowner) AS owner_name,
344 tn.nspname AS table_schema,
345 t.relname AS table_name,
346 a.attname AS column_name,
347 d.deptype::text AS dependency_type,
348 CASE WHEN ad.adbin IS NULL THEN false
349 ELSE pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval(%'
350 END AS has_nextval_default
351 FROM pg_class s
352 JOIN pg_namespace n ON n.oid = s.relnamespace
353 LEFT JOIN pg_depend d
354 ON d.classid = 'pg_class'::regclass
355 AND d.objid = s.oid
356 AND d.objsubid = 0
357 AND d.refclassid = 'pg_class'::regclass
358 AND d.deptype IN ('a', 'i')
359 LEFT JOIN pg_class t ON t.oid = d.refobjid
360 LEFT JOIN pg_namespace tn ON tn.oid = t.relnamespace
361 LEFT JOIN pg_attribute a
362 ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
363 LEFT JOIN pg_attrdef ad
364 ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
365 WHERE s.relkind = 'S'
366 AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
367 AND n.nspname <> 'information_schema'
368 {schema_filter}
369 ORDER BY n.nspname, s.relname;"
370 );
371 for row in client.query(&sequence_query, &[&schema_values])? {
372 let id = ObjectId::new(row.get::<_, String>(0), row.get::<_, String>(1));
373 let owner = relation_owner_id(row.get::<_, String>(2));
374 let table_schema: Option<String> = row.get(3);
375 let table_name: Option<String> = row.get(4);
376 let column_name: Option<String> = row.get(5);
377 let dependency_type: Option<String> = row.get(6);
378 let has_nextval_default: bool = row.get(7);
379 let owned_by = table_schema
380 .zip(table_name)
381 .zip(column_name)
382 .map(|((schema, table), column)| (ObjectId::new(schema, table), column));
383 let kind = match dependency_type.as_deref() {
384 Some("i") => crate::model::sequence::SequenceKind::Identity,
385 Some("a") if has_nextval_default => crate::model::sequence::SequenceKind::SerialLike,
386 Some("a") => crate::model::sequence::SequenceKind::Owned,
387 _ => crate::model::sequence::SequenceKind::Standalone,
388 };
389 cache.sequences.insert(
390 id.clone(),
391 crate::model::sequence::SequenceState {
392 id,
393 owner,
394 owned_by,
395 kind,
396 generation: 0,
397 },
398 );
399 }
400
401 let table_query = format!(
403 "
404 SELECT
405 n.nspname AS schema_name,
406 c.relname AS relation_name,
407 c.relkind AS relation_kind,
408 c.relpersistence AS persistence,
409 pg_catalog.pg_get_userbyid(c.relowner) AS owner_name,
410 CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
411 c.relpages::bigint AS relpages,
412 to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
413 to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
414 p.partstrat::text AS partition_strategy
415 FROM pg_class c
416 JOIN pg_namespace n ON n.oid = c.relnamespace
417 LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
418 LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
419 WHERE c.relkind IN ('r', 'p', 'v', 'm')
420 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
421 {schema_filter_with_fk};
422 "
423 );
424
425 for row in client.query(&table_query, &[&schema_values])? {
426 let schema_name: String = row.get("schema_name");
427 let relation_name: String = row.get("relation_name");
428 let relkind: i8 = row.get("relation_kind");
429 let persistence_char: i8 = row.get("persistence");
430 let owner_name: String = row.get("owner_name");
431 let raw_rows: i64 = row.get("estimated_rows");
432 let relpages: i64 = row.get("relpages");
433
434 let last_analyze: Option<String> = row.get("last_analyze");
435 let last_autoanalyze: Option<String> = row.get("last_autoanalyze");
436
437 let object_id = ObjectId::new(&schema_name, &relation_name);
438
439 let kind = match relkind as u8 {
440 b'v' => RelationKind::View,
441 b'm' => RelationKind::MaterializedView,
442 _ => RelationKind::Table,
443 };
444
445 let persistence = match persistence_char as u8 {
446 b't' => Persistence::Temporary,
447 b'u' => Persistence::Unlogged,
448 _ => Persistence::Permanent,
449 };
450
451 let estimated_rows = if raw_rows < 0 {
452 None
453 } else {
454 Some(raw_rows as u64)
455 };
456
457 let mut state = RelationState::new(
458 object_id.clone(),
459 relation_owner_id(owner_name),
460 0,
461 estimated_rows,
462 kind,
463 persistence,
464 0,
465 );
466 state.relpages = Some(relpages as u64);
467 state.last_analyze = last_analyze;
468 state.last_autoanalyze = last_autoanalyze;
469
470 let partition_strategy: Option<String> = row.get("partition_strategy");
471 if let Some(ref strat) = partition_strategy {
472 state.partition_type = Some(match strat.as_str() {
473 "r" => "RANGE".to_string(),
474 "l" => "LIST".to_string(),
475 "h" => "HASH".to_string(),
476 _ => strat.to_uppercase(),
477 });
478 }
479
480 if let Some(s) = schemas
481 && !s.contains(&schema_name)
482 {
483 state.mark_fk_dependency();
484 }
485
486 cache.insert_baseline(object_id, state);
487 }
488
489 let col_query = format!("
491 SELECT
492 n.nspname AS schema_name,
493 c.relname AS relation_name,
494 a.attname AS column_name,
495 pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
496 a.attnotnull AS not_null,
497 s.avg_width AS avg_width,
498 pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
499 a.atttypmod AS type_modifier
500 FROM pg_attribute a
501 JOIN pg_class c ON a.attrelid = c.oid
502 JOIN pg_namespace n ON n.oid = c.relnamespace
503 LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
504 LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
505 WHERE a.attnum > 0 AND NOT a.attisdropped
506 AND c.relkind IN ('r', 'p', 'v', 'm')
507 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
508 {schema_filter_with_fk}
509 ORDER BY n.nspname, c.relname;
510 ");
511
512 for row in client.query(&col_query, &[&schema_values])? {
513 let schema_name: String = row.get("schema_name");
514 let relation_name: String = row.get("relation_name");
515 let column_name: String = row.get("column_name");
516 let type_name: String = row.get("type_name");
517 let not_null: bool = row.get("not_null");
518 let avg_width: Option<i32> = row.get("avg_width");
519 let default_expr_text: Option<String> = row.get("default_expr_text");
520 let type_modifier: Option<i32> = row.get("type_modifier");
521
522 let relation_id = ObjectId::new(&schema_name, &relation_name);
523 if let Some(rel) = cache.relations.get_mut(&relation_id) {
524 rel.columns.push(crate::model::column::Column {
525 name: column_name,
526 data_type: Some(type_name),
527 type_id: None,
528 is_nullable: !not_null,
529 default: None,
530 avg_width,
531 default_expr_text,
532 type_modifier,
533 });
534 }
535 }
536
537 let tp_query = format!("
539 SELECT
540 n.nspname AS schema_name,
541 c.relname AS relation_name,
542 COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
543 COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
544 FROM pg_class c
545 JOIN pg_namespace n ON n.oid = c.relnamespace
546 LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
547 LEFT JOIN pg_policy p ON p.polrelid = c.oid
548 WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
549 {schema_filter_with_fk}
550 GROUP BY n.nspname, c.relname;
551 ");
552
553 for row in client.query(&tp_query, &[&schema_values])? {
554 let schema_name: String = row.get("schema_name");
555 let relation_name: String = row.get("relation_name");
556 let triggers: Vec<String> = row.get("triggers");
557 let policies: Vec<String> = row.get("policies");
558
559 let object_id = ObjectId::new(&schema_name, &relation_name);
560
561 if let Some(rel) = cache.relations.get_mut(&object_id) {
562 rel.triggers.extend(triggers);
563 rel.policies.extend(policies);
564 }
565 }
566
567 let acl_query = format!(
569 "
570 SELECT
571 n.nspname AS schema_name,
572 c.relname AS relation_name,
573 CASE
574 WHEN acl.grantee = 0 THEN 'public'
575 ELSE pg_catalog.pg_get_userbyid(acl.grantee)
576 END AS grantee,
577 acl.privilege_type
578 FROM pg_class c
579 JOIN pg_namespace n ON n.oid = c.relnamespace
580 CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
581 WHERE c.relkind IN ('r', 'p', 'v', 'm')
582 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
583 AND acl.grantee <> c.relowner
584 {schema_filter_with_fk};
585 "
586 );
587
588 for row in client.query(&acl_query, &[&schema_values])? {
589 let schema_name: String = row.get("schema_name");
590 let relation_name: String = row.get("relation_name");
591 let grantee: String = row.get("grantee");
592 let privilege_type: String = row.get("privilege_type");
593 let privilege = match privilege_type.as_str() {
594 "SELECT" => crate::model::relation::Privilege::Select,
595 "INSERT" => crate::model::relation::Privilege::Insert,
596 "UPDATE" => crate::model::relation::Privilege::Update,
597 "DELETE" => crate::model::relation::Privilege::Delete,
598 "TRUNCATE" => crate::model::relation::Privilege::Truncate,
599 "REFERENCES" => crate::model::relation::Privilege::References,
600 "TRIGGER" => crate::model::relation::Privilege::Trigger,
601 _ => continue,
602 };
603 if let Some(relation) = cache
604 .relations
605 .get_mut(&ObjectId::new(&schema_name, &relation_name))
606 {
607 relation.privileges.grant(
608 ObjectId::new("", grantee),
609 [privilege].into_iter().collect(),
610 );
611 }
612 }
613
614 let trig_query = format!(
616 "
617 SELECT
618 n.nspname AS table_schema,
619 c.relname AS table_name,
620 t.tgname AS trigger_name,
621 t.tgenabled::text AS enabled_mode,
622 fn.nspname AS function_schema,
623 f.proname || '()' AS function_name
624 FROM pg_trigger t
625 JOIN pg_class c ON c.oid = t.tgrelid
626 JOIN pg_namespace n ON n.oid = c.relnamespace
627 JOIN pg_proc f ON f.oid = t.tgfoid
628 JOIN pg_namespace fn ON fn.oid = f.pronamespace
629 WHERE t.tgisinternal = false
630 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
631 {schema_filter_with_fk};
632 "
633 );
634
635 for row in client.query(&trig_query, &[&schema_values])? {
636 let table_schema: String = row.get("table_schema");
637 let table_name: String = row.get("table_name");
638 let trigger_name: String = row.get("trigger_name");
639 let enabled_mode: String = row.get("enabled_mode");
640 let function_schema: String = row.get("function_schema");
641 let function_name: String = row.get("function_name");
642
643 cache.triggers.push(crate::db::cache::TriggerCache {
644 trigger_id: ObjectId::new(&table_schema, &trigger_name),
645 table_id: ObjectId::new(&table_schema, &table_name),
646 function_id: ObjectId::new(&function_schema, &function_name),
647 enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode)
648 .ok_or_else(|| {
649 anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}")
650 })?,
651 });
652 }
653
654 let constraint_query = format!(
656 "
657 SELECT
658 n.nspname AS table_schema,
659 c.relname AS table_name,
660 con.conname AS constraint_name,
661 con.contype::text AS constraint_type,
662 con.convalidated AS validated
663 FROM pg_constraint con
664 JOIN pg_class c ON c.oid = con.conrelid
665 JOIN pg_namespace n ON n.oid = c.relnamespace
666 WHERE con.contype IN ('c', 'f', 'p', 'u', 'x')
667 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
668 {schema_filter};
669 "
670 );
671
672 for row in client.query(&constraint_query, &[&schema_values])? {
673 let table_schema: String = row.get("table_schema");
674 let table_name: String = row.get("table_name");
675 let constraint_name: String = row.get("constraint_name");
676 let constraint_type: String = row.get("constraint_type");
677 let validated: bool = row.get("validated");
678 let kind = match constraint_type.as_str() {
679 "c" => crate::model::constraint::ConstraintKind::Check,
680 "f" => crate::model::constraint::ConstraintKind::ForeignKey,
681 "p" => crate::model::constraint::ConstraintKind::PrimaryKey,
682 "u" => crate::model::constraint::ConstraintKind::Unique,
683 "x" => crate::model::constraint::ConstraintKind::Exclusion,
684 _ => continue,
685 };
686 cache
687 .constraints
688 .push(crate::model::constraint::ConstraintState {
689 table_id: ObjectId::new(&table_schema, &table_name),
690 name: constraint_name,
691 kind,
692 validated,
693 });
694 }
695
696 let fk_query = format!(
698 "
699 SELECT
700 c.conname AS constraint_name,
701 n1.nspname AS from_schema, t1.relname AS from_table,
702 n2.nspname AS to_schema, t2.relname AS to_table
703 FROM pg_constraint c
704 JOIN pg_class t1 ON t1.oid = c.conrelid
705 JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
706 JOIN pg_class t2 ON t2.oid = c.confrelid
707 JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
708 WHERE c.contype = 'f'
709 {schema_filter_n1_or_n2};
710 "
711 );
712
713 for row in client.query(&fk_query, &[&schema_values])? {
714 let constraint_name: String = row.get("constraint_name");
715 let from_schema: String = row.get("from_schema");
716 let from_table: String = row.get("from_table");
717 let to_schema: String = row.get("to_schema");
718 let to_table: String = row.get("to_table");
719
720 if let Some(s) = schemas
721 && (!s.contains(&from_schema) || !s.contains(&to_schema))
722 {
723 let out_of_scope_schema = if !s.contains(&from_schema) {
725 &from_schema
726 } else {
727 &to_schema
728 };
729 let out_of_scope_table = if !s.contains(&from_schema) {
730 &from_table
731 } else {
732 &to_table
733 };
734 eprintln!(
735 "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
736 constraint_name, out_of_scope_schema, out_of_scope_table
737 );
738 }
739
740 cache.foreign_keys.push(ForeignKeyCache {
741 constraint_name,
742 from_table: ObjectId::new(&from_schema, &from_table),
743 to_table: ObjectId::new(&to_schema, &to_table),
744 });
745 }
746
747 let idx_query = format!(
749 "
750 SELECT
751 n_i.nspname AS index_schema, i.relname AS index_name,
752 n_t.nspname AS table_schema, t.relname AS table_name
753 FROM pg_index x
754 JOIN pg_class i ON i.oid = x.indexrelid
755 JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
756 JOIN pg_class t ON t.oid = x.indrelid
757 JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
758 WHERE x.indisvalid = true
759 AND n_i.nspname !~ '^pg_'
760 AND n_i.nspname <> 'information_schema'
761 AND n_t.nspname !~ '^pg_'
762 AND n_t.nspname <> 'information_schema'
763 {schema_filter_nt};
764 "
765 );
766
767 for row in client.query(&idx_query, &[&schema_values])? {
768 let index_schema: String = row.get("index_schema");
769 let index_name: String = row.get("index_name");
770 let table_schema: String = row.get("table_schema");
771 let table_name: String = row.get("table_name");
772
773 if is_system_schema(&index_schema) || is_system_schema(&table_schema) {
774 continue;
775 }
776
777 cache.indexes.push(IndexCache {
778 index_id: ObjectId::new(&index_schema, &index_name),
779 table_id: ObjectId::new(&table_schema, &table_name),
780 });
781 }
782
783 let func_query = format!(
785 "
786 SELECT
787 n.nspname AS schema_name,
788 p.proname AS func_name,
789 COALESCE(
790 (SELECT string_agg(pg_catalog.format_type(t, NULL), ',' ORDER BY n)
791 FROM unnest(p.proargtypes::int[]) WITH ORDINALITY AS u(t, n)),
792 ''
793 ) AS arg_types,
794 pg_catalog.pg_get_function_result(p.oid) AS return_type,
795 p.provolatile::text AS volatility,
796 l.lanname AS language,
797 p.prosecdef AS security_definer
798 FROM pg_proc p
799 JOIN pg_namespace n ON n.oid = p.pronamespace
800 JOIN pg_language l ON l.oid = p.prolang
801 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
802 AND p.prokind = 'f'
803 {schema_filter};
804 "
805 );
806
807 for row in client.query(&func_query, &[&schema_values])? {
808 let schema_name: String = row.get("schema_name");
809 let func_name: String = row.get("func_name");
810 let arg_types_str: String = row.get("arg_types");
811 let return_type: Option<String> = row.get("return_type");
812 let volatility_char: String = row.get("volatility");
813 let language: String = row.get("language");
814 let security_definer: bool = row.get("security_definer");
815
816 let volatility = match volatility_char.as_str() {
817 "v" => crate::model::function::Volatility::Volatile,
818 "s" => crate::model::function::Volatility::Stable,
819 "i" => crate::model::function::Volatility::Immutable,
820 _ => crate::model::function::Volatility::Volatile,
821 };
822
823 let security = if security_definer {
824 crate::model::function::SecurityMode::Definer
825 } else {
826 crate::model::function::SecurityMode::Invoker
827 };
828
829 let arg_types_str = arg_types_str
831 .split(',')
832 .map(crate::analysis::resolver::Resolver::normalize_function_arg_type)
833 .collect::<Vec<_>>()
834 .join(",");
835
836 let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str));
837
838 let arg_types = if arg_types_str.is_empty() {
839 Vec::new()
840 } else {
841 arg_types_str.split(',').map(|s| s.to_string()).collect()
842 };
843
844 cache.functions.insert(
845 id.clone(),
846 crate::model::function::FunctionState {
847 id,
848 arg_types,
849 arg_type_ids: Vec::new(),
850 return_type: return_type.unwrap_or_default(),
851 return_type_id: None,
852 volatility,
853 language,
854 security,
855 },
856 );
857 }
858
859 let type_query = format!(
861 "
862 SELECT
863 n.nspname AS schema_name,
864 t.typname AS type_name,
865 t.typtype::text AS type_kind,
866 CASE WHEN t.typtype = 'd'
867 THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
868 ELSE NULL
869 END AS domain_base_type,
870 COALESCE(
871 array_agg(e.enumlabel ORDER BY e.enumsortorder)
872 FILTER (WHERE e.enumlabel IS NOT NULL),
873 ARRAY[]::text[]
874 ) AS enum_labels
875 FROM pg_type t
876 JOIN pg_namespace n ON n.oid = t.typnamespace
877 LEFT JOIN pg_enum e ON e.enumtypid = t.oid
878 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
879 AND t.typtype IN ('e', 'd')
880 {schema_filter}
881 GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod;
882 "
883 );
884
885 for row in client.query(&type_query, &[&schema_values])? {
886 let schema_name: String = row.get("schema_name");
887 let type_name: String = row.get("type_name");
888 let type_kind: String = row.get("type_kind");
889 let domain_base_type: Option<String> = row.get("domain_base_type");
890 let enum_labels: Vec<String> = row.get("enum_labels");
891 let kind = match type_kind.as_str() {
892 "e" => crate::model::types::TypeKind::Enum {
893 variants: enum_labels,
894 },
895 "d" => crate::model::types::TypeKind::Domain {
896 base_type: domain_base_type.unwrap_or_default(),
897 base_type_id: None,
898 },
899 _ => continue,
900 };
901 let id = ObjectId::new(&schema_name, &type_name);
902 cache.types.insert(
903 id.clone(),
904 crate::model::types::TypeState {
905 id,
906 generation: 0,
907 kind,
908 },
909 );
910 }
911
912 let depend_query = r#"
914 SELECT
915 d.classid, d.objid, d.objsubid,
916 d.refclassid, d.refobjid, d.refobjsubid,
917 d.deptype::text,
918 COALESCE(n1.nspname, n1p.nspname, n1t.nspname) AS obj_schema,
919 COALESCE(c1.relname, p1.proname, t1.typname) AS obj_name,
920 COALESCE(n2.nspname, n2p.nspname, n2t.nspname) AS ref_schema,
921 COALESCE(c2.relname, p2.proname, t2.typname) AS ref_name
922 FROM pg_depend d
923 LEFT JOIN pg_class c1 ON c1.oid = d.objid AND d.classid = 'pg_class'::regclass
924 LEFT JOIN pg_namespace n1 ON n1.oid = c1.relnamespace
925 LEFT JOIN pg_proc p1 ON p1.oid = d.objid AND d.classid = 'pg_proc'::regclass
926 LEFT JOIN pg_namespace n1p ON n1p.oid = p1.pronamespace
927 LEFT JOIN pg_type t1 ON t1.oid = d.objid AND d.classid = 'pg_type'::regclass
928 LEFT JOIN pg_namespace n1t ON n1t.oid = t1.typnamespace
929 LEFT JOIN pg_class c2 ON c2.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
930 LEFT JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
931 LEFT JOIN pg_proc p2 ON p2.oid = d.refobjid AND d.refclassid = 'pg_proc'::regclass
932 LEFT JOIN pg_namespace n2p ON n2p.oid = p2.pronamespace
933 LEFT JOIN pg_type t2 ON t2.oid = d.refobjid AND d.refclassid = 'pg_type'::regclass
934 LEFT JOIN pg_namespace n2t ON n2t.oid = t2.typnamespace
935 WHERE d.deptype IN ('n', 'a', 'i')
936 AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname) IS NOT NULL
937 AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname)
938 NOT IN ('pg_catalog', 'information_schema')
939 AND (
940 $1::text[] IS NULL
941 OR COALESCE(n1.nspname, n1p.nspname, n1t.nspname) = ANY($1)
942 )
943 "#;
944
945 for row in client.query(depend_query, &[&schema_values])? {
946 let classid: u32 = row.get(0);
947 let objid: u32 = row.get(1);
948 let objsubid: i32 = row.get(2);
949 let refclassid: u32 = row.get(3);
950 let refobjid: u32 = row.get(4);
951 let refobjsubid: i32 = row.get(5);
952 let deptype: String = row.get(6);
953 let obj_schema: Option<String> = row.get(7);
954 let obj_name: Option<String> = row.get(8);
955 let ref_schema: Option<String> = row.get(9);
956 let ref_name: Option<String> = row.get(10);
957
958 cache.dependencies.push(crate::db::cache::DependencyCache {
959 classid,
960 objid,
961 objsubid,
962 refclassid,
963 refobjid,
964 refobjsubid,
965 deptype,
966 obj_schema,
967 obj_name,
968 ref_schema,
969 ref_name,
970 });
971 }
972
973 let view_depend_query = r#"
976 SELECT DISTINCT
977 'pg_class'::regclass::oid AS classid,
978 vc.oid AS objid,
979 0 AS objsubid,
980 'pg_class'::regclass::oid AS refclassid,
981 tc.oid AS refobjid,
982 0 AS refobjsubid,
983 vn.nspname AS obj_schema,
984 vc.relname AS obj_name,
985 tn.nspname AS ref_schema,
986 tc.relname AS ref_name
987 FROM pg_rewrite rw
988 JOIN pg_class vc ON vc.oid = rw.ev_class
989 JOIN pg_namespace vn ON vn.oid = vc.relnamespace
990 JOIN pg_depend d ON d.objid = rw.oid
991 JOIN pg_class tc ON tc.oid = d.refobjid
992 JOIN pg_namespace tn ON tn.oid = tc.relnamespace
993 WHERE vc.relkind IN ('v', 'm')
994 AND d.deptype = 'n'
995 -- PostgreSQL 14/15 expose an internal rewrite-rule self-edge. It is
996 -- not a dependency of the view definition and must not enter the
997 -- modeled dependency graph.
998 AND tc.oid <> vc.oid
999 AND (
1000 $1::text[] IS NULL
1001 OR (vn.nspname = ANY($1) AND tn.nspname = ANY($1))
1002 )
1003 "#;
1004
1005 for row in client.query(view_depend_query, &[&schema_values])? {
1006 cache.dependencies.push(crate::db::cache::DependencyCache {
1007 classid: row.get(0),
1008 objid: row.get(1),
1009 objsubid: row.get(2),
1010 refclassid: row.get(3),
1011 refobjid: row.get(4),
1012 refobjsubid: row.get(5),
1013 deptype: "view".to_string(),
1014 obj_schema: Some(row.get(6)),
1015 obj_name: Some(row.get(7)),
1016 ref_schema: Some(row.get(8)),
1017 ref_name: Some(row.get(9)),
1018 });
1019 }
1020
1021 for row in client.query(
1025 "SELECT rolname, rolcanlogin, rolsuper FROM pg_roles ORDER BY rolname;",
1026 &[],
1027 )? {
1028 let name: String = row.get(0);
1029 let id = ObjectId::new("", &name);
1030 cache.roles.insert(
1031 id.clone(),
1032 crate::model::role::RoleState {
1033 id,
1034 can_login: row.get(1),
1035 is_superuser: row.get(2),
1036 member_of: Vec::new(),
1037 can_set_role_to: Vec::new(),
1038 granted_privileges: Vec::new(),
1039 },
1040 );
1041 }
1042
1043 let membership_query = if cache.pg_version_num.unwrap_or_default() >= 160_000 {
1044 "SELECT member.rolname, parent.rolname, membership.set_option
1045 FROM pg_auth_members membership
1046 JOIN pg_roles member ON member.oid = membership.member
1047 JOIN pg_roles parent ON parent.oid = membership.roleid;"
1048 } else {
1049 "SELECT member.rolname, parent.rolname, true AS set_option
1050 FROM pg_auth_members membership
1051 JOIN pg_roles member ON member.oid = membership.member
1052 JOIN pg_roles parent ON parent.oid = membership.roleid;"
1053 };
1054 for row in client.query(membership_query, &[])? {
1055 let member = ObjectId::new("", row.get::<_, String>(0));
1056 let parent = ObjectId::new("", row.get::<_, String>(1));
1057 let set_option: bool = row.get(2);
1058 if let Some(role) = cache.roles.get_mut(&member) {
1059 role.member_of.push(parent.clone());
1060 if set_option {
1061 role.can_set_role_to.push(parent);
1062 }
1063 }
1064 }
1065
1066 Ok(cache)
1067}
1068
1069#[cfg(test)]
1070mod atomic_write_tests {
1071 use super::*;
1072 use crate::db::cache::DbCacheVersioned;
1073 use std::fs;
1074 use std::io::Read;
1075
1076 #[test]
1077 fn production_cache_writer_atomically_replaces_and_decodes() {
1078 let temp_dir = tempfile::tempdir().unwrap();
1079 let cache_path = temp_dir.path().join("baseline.cache");
1080 fs::write(&cache_path, b"old-cache").unwrap();
1081
1082 let mut cache = DbCache::new();
1083 cache.pg_version_num = Some(180002);
1084 write_cache(&cache_path, cache, false).unwrap();
1085
1086 let encoded = fs::read(&cache_path).unwrap();
1087 assert_ne!(encoded, b"old-cache");
1088 let reader = std::io::Cursor::new(encoded);
1089 let mut decoder = zstd::stream::Decoder::new(reader).unwrap();
1090 let mut payload = Vec::new();
1091 decoder.read_to_end(&mut payload).unwrap();
1092 let payload = payload
1093 .strip_prefix(CACHE_V5_MAGIC)
1094 .expect("writer must prefix V5 cache payloads");
1095 let config = bincode::config::standard().with_variable_int_encoding();
1096 let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config)
1097 .unwrap()
1098 .0;
1099 assert_eq!(versioned.into_cache().unwrap().pg_version_num, Some(180002));
1100 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
1101 }
1102
1103 #[test]
1104 fn production_cache_writer_preserves_old_bytes_after_pre_install_failure() {
1105 let temp_dir = tempfile::tempdir().unwrap();
1106 let cache_path = temp_dir.path().join("baseline.cache");
1107 fs::write(&cache_path, b"known-good-cache").unwrap();
1108
1109 let error = write_cache_with_protection(&cache_path, DbCache::new(), |_| {
1110 Err(anyhow::anyhow!("injected payload-protection failure"))
1111 })
1112 .unwrap_err();
1113
1114 assert!(
1115 error
1116 .to_string()
1117 .contains("injected payload-protection failure")
1118 );
1119 assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
1120 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
1121 }
1122}