1use crate::ast::identifiers::ObjectId;
2use crate::db::cache::{CACHE_V6_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache};
3use crate::db::cache_file::{MAX_CACHE_DECODE_BYTES, MAX_CACHE_FILE_BYTES, protect_cache_bytes};
4use crate::model::relation::{Persistence, RelationKind, RelationState};
5use anyhow::{Context, Result};
6use postgres::config::Host;
7use postgres::{Client, Config as PostgresConfig, GenericClient, IsolationLevel, NoTls};
8use std::io::{self, Write};
9use std::path::Path;
10use std::time::{SystemTime, UNIX_EPOCH};
11use tempfile::NamedTempFile;
12
13#[cfg(windows)]
14use std::fs;
15
16const MIN_POSTGRES_VERSION_NUM: u32 = 140_000;
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 if db_url.trim().is_empty() {
27 anyhow::bail!("DATABASE_URL must not be empty or whitespace");
28 }
29
30 let mut client = connect_database(&db_url)?;
31
32 let cache = populate_cache(&mut client, schemas)?;
33
34 write_cache(out_path, cache, cache_encryption)
35}
36
37fn connect_database(db_url: &str) -> Result<Client> {
38 let config: PostgresConfig = db_url
39 .parse()
40 .context("DATABASE_URL is not a valid PostgreSQL connection string")?;
41
42 if !database_config_is_local(&config) {
43 anyhow::bail!(
44 "Remote DATABASE_URL connections are not supported by this build. Use an SSH tunnel and connect through localhost or a Unix socket."
45 );
46 }
47
48 config
49 .connect(NoTls)
50 .context("Failed to connect to PostgreSQL")
51}
52
53pub(crate) fn database_config_is_local(config: &PostgresConfig) -> bool {
54 config
55 .get_hostaddrs()
56 .iter()
57 .all(|address| address.is_loopback())
58 && config.get_hosts().iter().all(|host| match host {
59 #[cfg(unix)]
60 Host::Unix(_) => true,
61 Host::Tcp(name) => is_local_host(name),
62 })
63}
64
65pub(crate) fn ensure_supported_postgres_version(version: u32) -> Result<()> {
66 if version < MIN_POSTGRES_VERSION_NUM {
67 anyhow::bail!(
68 "PostgreSQL {} is unsupported; safe-migrate sync requires PostgreSQL 14 or newer",
69 version / 10_000
70 );
71 }
72 Ok(())
73}
74
75pub(crate) fn is_local_host(host: &str) -> bool {
76 if host.starts_with('/') || host.eq_ignore_ascii_case("localhost") {
77 return true;
78 }
79 host.trim_start_matches('[')
80 .trim_end_matches(']')
81 .parse::<std::net::IpAddr>()
82 .is_ok_and(|address| address.is_loopback())
83}
84
85pub(crate) fn cache_search_path(
86 database_search_path: Vec<String>,
87 schemas: Option<&[String]>,
88) -> Vec<String> {
89 let Some(schemas) = schemas else {
90 return database_search_path;
91 };
92
93 let mut scoped_search_path = Vec::new();
94 for schema in database_search_path
95 .into_iter()
96 .filter(|schema| schemas.contains(schema))
97 .chain(schemas.iter().cloned())
98 {
99 if !scoped_search_path.contains(&schema) {
100 scoped_search_path.push(schema);
101 }
102 }
103 scoped_search_path
104}
105
106pub(crate) fn parse_search_path_setting(setting: &str) -> Vec<String> {
109 let mut entries = Vec::new();
110 let mut current = String::new();
111 let mut chars = setting.chars().peekable();
112 let mut quoted = false;
113
114 while let Some(ch) = chars.next() {
115 match ch {
116 '"' if quoted && chars.peek() == Some(&'"') => {
117 current.push('"');
118 chars.next();
119 }
120 '"' => quoted = !quoted,
121 ',' if !quoted => {
122 let entry = current.trim();
123 if !entry.is_empty() {
124 entries.push(entry.to_string());
125 }
126 current.clear();
127 }
128 _ => current.push(ch),
129 }
130 }
131
132 let entry = current.trim();
133 if !entry.is_empty() {
134 entries.push(entry.to_string());
135 }
136 entries
137}
138
139pub(crate) fn relation_owner_id(owner_name: impl Into<String>) -> ObjectId {
140 ObjectId::new("", owner_name)
141}
142
143pub(crate) fn is_system_schema(schema: &str) -> bool {
144 schema == "information_schema" || schema.starts_with("pg_")
145}
146
147fn write_cache(out_path: &Path, cache: DbCache, cache_encryption: bool) -> Result<()> {
148 write_cache_with_protection(out_path, cache, |compressed| {
149 protect_cache_bytes(compressed, cache_encryption)
150 })
151}
152
153fn write_cache_with_protection(
154 out_path: &Path,
155 cache: DbCache,
156 protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
157) -> Result<()> {
158 write_cache_with_protection_and_limits(
159 out_path,
160 cache,
161 protect,
162 MAX_CACHE_FILE_BYTES,
163 MAX_CACHE_DECODE_BYTES,
164 )
165}
166
167fn write_cache_with_protection_and_limits(
168 out_path: &Path,
169 cache: DbCache,
170 protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
171 max_file_bytes: u64,
172 max_decode_bytes: usize,
173) -> Result<()> {
174 let parent = out_path.parent().unwrap_or_else(|| Path::new("."));
175 let mut temp_file = NamedTempFile::new_in(parent).with_context(|| {
176 format!(
177 "Failed to create temporary cache file beside {}",
178 out_path.display()
179 )
180 })?;
181 let mut compressed = Vec::new();
182 let encoder = zstd::stream::Encoder::new(&mut compressed, 3)
183 .context("Failed to init zstd compression")?;
184 let mut encoder = SizeLimitedWriter::new(encoder, max_decode_bytes);
185
186 if let Err(error) = encoder.write_all(CACHE_V6_MAGIC) {
187 if encoder.limit_exceeded() {
188 anyhow::bail!(
189 "Cache payload exceeds the {} MiB decoded-size limit",
190 max_decode_bytes / (1024 * 1024)
191 );
192 }
193 return Err(error).context("Failed to write cache V6 payload header");
194 }
195
196 let versioned = DbCacheVersioned::V6(Box::new(cache));
197 let bincode_config = bincode::config::standard().with_variable_int_encoding();
198
199 let encode_result =
200 bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config);
201 if encoder.limit_exceeded() {
202 anyhow::bail!(
203 "Cache payload exceeds the {} MiB decoded-size limit",
204 max_decode_bytes / (1024 * 1024)
205 );
206 }
207 encode_result.context("Failed bincode schema compilation and write")?;
208
209 let encoder = encoder.into_inner();
210 encoder
211 .finish()
212 .context("Failed to flush final zstd stream to disk")?;
213
214 let cache_bytes = protect(compressed)?;
215 let cache_file_bytes = u64::try_from(cache_bytes.len()).unwrap_or(u64::MAX);
216 if cache_file_bytes > max_file_bytes {
217 anyhow::bail!(
218 "Cache payload exceeds the {} MiB encoded-size limit",
219 max_file_bytes / (1024 * 1024)
220 );
221 }
222 temp_file
223 .write_all(&cache_bytes)
224 .context("Failed to write cache payload")?;
225 temp_file.flush().context("Failed to flush cache payload")?;
226
227 replace_cache(temp_file, out_path)?;
228
229 Ok(())
230}
231
232struct SizeLimitedWriter<W> {
234 inner: W,
235 bytes_written: usize,
236 max_bytes: usize,
237 limit_exceeded: bool,
238}
239
240impl<W> SizeLimitedWriter<W> {
241 fn new(inner: W, max_bytes: usize) -> Self {
242 Self {
243 inner,
244 bytes_written: 0,
245 max_bytes,
246 limit_exceeded: false,
247 }
248 }
249
250 fn limit_exceeded(&self) -> bool {
251 self.limit_exceeded
252 }
253
254 fn into_inner(self) -> W {
255 self.inner
256 }
257}
258
259impl<W: Write> Write for SizeLimitedWriter<W> {
260 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
261 if bytes.len() > self.max_bytes.saturating_sub(self.bytes_written) {
262 self.limit_exceeded = true;
263 return Err(io::Error::new(
264 io::ErrorKind::InvalidData,
265 "cache decoded-size limit exceeded",
266 ));
267 }
268
269 let written = self.inner.write(bytes)?;
270 self.bytes_written = self.bytes_written.saturating_add(written);
271 Ok(written)
272 }
273
274 fn flush(&mut self) -> io::Result<()> {
275 self.inner.flush()
276 }
277}
278
279#[cfg(not(windows))]
280fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
281 temp_file
282 .persist(out_path)
283 .map_err(|error| error.error)
284 .with_context(|| {
285 format!(
286 "Failed to atomically replace cache file: {}",
287 out_path.display()
288 )
289 })?;
290 Ok(())
291}
292
293#[cfg(windows)]
294fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
295 if !out_path.exists() {
296 temp_file
297 .persist(out_path)
298 .map_err(|error| error.error)
299 .with_context(|| format!("Failed to install cache file: {}", out_path.display()))?;
300 return Ok(());
301 }
302
303 let backup = out_path.with_extension("safe-migrate.backup");
304 fs::rename(out_path, &backup).with_context(|| {
305 format!(
306 "Failed to stage existing cache for replacement: {}",
307 out_path.display()
308 )
309 })?;
310
311 match temp_file.persist(out_path) {
312 Ok(_) => {
313 fs::remove_file(&backup).with_context(|| {
314 format!(
315 "Installed new cache but failed to remove backup: {}",
316 backup.display()
317 )
318 })?;
319 Ok(())
320 }
321 Err(error) => {
322 let restore_result = fs::rename(&backup, out_path);
323 let message = if let Err(restore_error) = restore_result {
324 format!(
325 "Failed to install new cache: {}. The old cache could not be restored: {}",
326 error.error, restore_error
327 )
328 } else {
329 format!(
330 "Failed to install new cache; restored the previous cache: {}",
331 error.error
332 )
333 };
334 Err(anyhow::anyhow!(message))
335 }
336 }
337}
338
339pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result<DbCache> {
340 let mut transaction = client
341 .build_transaction()
342 .isolation_level(IsolationLevel::RepeatableRead)
343 .read_only(true)
344 .start()
345 .context("Failed to start read-only cache synchronization transaction")?;
346 let cache = populate_cache_from_client(&mut transaction, schemas)?;
347 transaction
348 .commit()
349 .context("Failed to commit cache synchronization transaction")?;
350 Ok(cache)
351}
352
353#[doc(hidden)]
354pub fn populate_cache_in_current_transaction(
355 client: &mut Client,
356 schemas: Option<&[String]>,
357) -> Result<DbCache> {
358 populate_cache_from_client(client, schemas)
359}
360
361fn populate_cache_from_client(
362 client: &mut impl GenericClient,
363 schemas: Option<&[String]>,
364) -> Result<DbCache> {
365 let mut cache = DbCache::new();
366 let schema_values = schemas.map(|items| items.to_vec());
367 cache.metadata.created_at_unix_secs = Some(
368 SystemTime::now()
369 .duration_since(UNIX_EPOCH)
370 .unwrap_or_default()
371 .as_secs(),
372 );
373 cache.metadata.schemas = schema_values.clone();
374
375 let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1))";
376 let schema_filter_with_fk = r#"
377 AND (
378 $1::text[] IS NULL
379 OR n.nspname = ANY($1)
380 OR c.oid IN (
381 SELECT conrelid FROM pg_constraint cst
382 JOIN pg_class c2 ON c2.oid = cst.confrelid
383 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
384 WHERE n2.nspname = ANY($1)
385 )
386 OR c.oid IN (
387 SELECT confrelid FROM pg_constraint cst
388 JOIN pg_class c2 ON c2.oid = cst.conrelid
389 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
390 WHERE n2.nspname = ANY($1)
391 )
392 )
393 "#;
394 let schema_filter_n1_or_n2 =
395 "AND ($1::text[] IS NULL OR n1.nspname = ANY($1) OR n2.nspname = ANY($1))";
396 let schema_filter_nt = r#"
397 AND (
398 $1::text[] IS NULL
399 OR n_t.nspname = ANY($1)
400 OR t.oid IN (
401 SELECT conrelid FROM pg_constraint cst
402 JOIN pg_class c2 ON c2.oid = cst.confrelid
403 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
404 WHERE n2.nspname = ANY($1)
405 )
406 OR t.oid IN (
407 SELECT confrelid FROM pg_constraint cst
408 JOIN pg_class c2 ON c2.oid = cst.conrelid
409 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
410 WHERE n2.nspname = ANY($1)
411 )
412 )
413 "#;
414
415 let version_row = client.query_one("SHOW server_version_num;", &[])?;
417 let version_str: String = version_row.get(0);
418 let version = version_str
419 .parse::<u32>()
420 .context("PostgreSQL returned an invalid server_version_num")?;
421 ensure_supported_postgres_version(version)?;
422 cache.pg_version_num = Some(version);
423
424 let provenance_row = client.query_one(
425 "SELECT current_database(), current_user, session_user, current_setting('search_path'),
426 (SELECT setting::bigint FROM pg_settings WHERE name = 'lock_timeout'),
427 (SELECT setting::bigint FROM pg_settings WHERE name = 'statement_timeout');",
428 &[],
429 )?;
430 cache.metadata.source_database = Some(provenance_row.get(0));
431 cache.metadata.source_role = Some(provenance_row.get(1));
432 cache.metadata.source_session_role = Some(provenance_row.get(2));
433 let search_path_setting: String = provenance_row.get(3);
434 cache.metadata.source_search_path = Some(parse_search_path_setting(&search_path_setting));
435 let lock_timeout_ms = provenance_row
436 .try_get::<_, Option<i64>>(4)?
437 .context("PostgreSQL did not report lock_timeout")?;
438 let statement_timeout_ms = provenance_row
439 .try_get::<_, Option<i64>>(5)?
440 .context("PostgreSQL did not report statement_timeout")?;
441 cache.metadata.source_lock_timeout_ms = lock_timeout_ms
442 .try_into()
443 .context("PostgreSQL returned a negative lock_timeout")?;
444 cache.metadata.source_statement_timeout_ms = statement_timeout_ms
445 .try_into()
446 .context("PostgreSQL returned a negative statement_timeout")?;
447
448 let search_path_row = client.query_one("SELECT current_schemas(false);", &[])?;
453 cache.search_path = cache_search_path(search_path_row.get(0), schemas);
454
455 let schema_query = format!(
458 "SELECT n.nspname, pg_catalog.pg_get_userbyid(n.nspowner)
459 FROM pg_namespace n
460 WHERE n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
461 AND n.nspname <> 'information_schema'
462 {schema_filter}
463 ORDER BY n.nspname;"
464 );
465 for row in client.query(&schema_query, &[&schema_values])? {
466 let name: String = row.get(0);
467 let owner: String = row.get(1);
468 cache.schemas.insert(
469 name.clone(),
470 crate::model::schema::SchemaState {
471 name,
472 owner: relation_owner_id(owner),
473 generation: 0,
474 },
475 );
476 }
477 cache
481 .search_path
482 .retain(|schema| cache.schemas.contains_key(schema));
483
484 let sequence_query = format!(
489 "SELECT
490 n.nspname AS sequence_schema,
491 s.relname AS sequence_name,
492 pg_catalog.pg_get_userbyid(s.relowner) AS owner_name,
493 tn.nspname AS table_schema,
494 t.relname AS table_name,
495 a.attname AS column_name,
496 d.deptype::text AS dependency_type,
497 CASE WHEN ad.adbin IS NULL THEN false
498 ELSE pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval(%'
499 END AS has_nextval_default
500 FROM pg_class s
501 JOIN pg_namespace n ON n.oid = s.relnamespace
502 LEFT JOIN pg_depend d
503 ON d.classid = 'pg_class'::regclass
504 AND d.objid = s.oid
505 AND d.objsubid = 0
506 AND d.refclassid = 'pg_class'::regclass
507 AND d.deptype IN ('a', 'i')
508 LEFT JOIN pg_class t ON t.oid = d.refobjid
509 LEFT JOIN pg_namespace tn ON tn.oid = t.relnamespace
510 LEFT JOIN pg_attribute a
511 ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
512 LEFT JOIN pg_attrdef ad
513 ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
514 WHERE s.relkind = 'S'
515 AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
516 AND n.nspname <> 'information_schema'
517 {schema_filter}
518 ORDER BY n.nspname, s.relname;"
519 );
520 for row in client.query(&sequence_query, &[&schema_values])? {
521 let id = ObjectId::new(row.get::<_, String>(0), row.get::<_, String>(1));
522 let owner = relation_owner_id(row.get::<_, String>(2));
523 let table_schema: Option<String> = row.get(3);
524 let table_name: Option<String> = row.get(4);
525 let column_name: Option<String> = row.get(5);
526 let dependency_type: Option<String> = row.get(6);
527 let has_nextval_default: bool = row.get(7);
528 let owned_by = table_schema
529 .zip(table_name)
530 .zip(column_name)
531 .map(|((schema, table), column)| (ObjectId::new(schema, table), column));
532 let kind = match dependency_type.as_deref() {
533 Some("i") => crate::model::sequence::SequenceKind::Identity,
534 Some("a") if has_nextval_default => crate::model::sequence::SequenceKind::SerialLike,
535 Some("a") => crate::model::sequence::SequenceKind::Owned,
536 _ => crate::model::sequence::SequenceKind::Standalone,
537 };
538 cache.sequences.insert(
539 id.clone(),
540 crate::model::sequence::SequenceState {
541 id,
542 owner,
543 owned_by,
544 kind,
545 generation: 0,
546 },
547 );
548 }
549
550 let table_query = format!(
552 "
553 SELECT
554 n.nspname AS schema_name,
555 c.relname AS relation_name,
556 c.relkind AS relation_kind,
557 c.relpersistence AS persistence,
558 pg_catalog.pg_get_userbyid(c.relowner) AS owner_name,
559 CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
560 c.relpages::bigint AS relpages,
561 to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
562 to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
563 p.partstrat::text AS partition_strategy
564 FROM pg_class c
565 JOIN pg_namespace n ON n.oid = c.relnamespace
566 LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
567 LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
568 WHERE c.relkind IN ('r', 'p', 'v', 'm')
569 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
570 {schema_filter_with_fk};
571 "
572 );
573
574 for row in client.query(&table_query, &[&schema_values])? {
575 let schema_name: String = row.get("schema_name");
576 let relation_name: String = row.get("relation_name");
577 let relkind: i8 = row.get("relation_kind");
578 let persistence_char: i8 = row.get("persistence");
579 let owner_name: String = row.get("owner_name");
580 let raw_rows: i64 = row.get("estimated_rows");
581 let relpages: i64 = row.get("relpages");
582
583 let last_analyze: Option<String> = row.get("last_analyze");
584 let last_autoanalyze: Option<String> = row.get("last_autoanalyze");
585
586 let object_id = ObjectId::new(&schema_name, &relation_name);
587
588 let kind = match relkind as u8 {
589 b'v' => RelationKind::View,
590 b'm' => RelationKind::MaterializedView,
591 _ => RelationKind::Table,
592 };
593
594 let persistence = match persistence_char as u8 {
595 b't' => Persistence::Temporary,
596 b'u' => Persistence::Unlogged,
597 _ => Persistence::Permanent,
598 };
599
600 let estimated_rows = if raw_rows < 0 {
601 None
602 } else {
603 Some(raw_rows as u64)
604 };
605
606 let mut state = RelationState::new(
607 object_id.clone(),
608 relation_owner_id(owner_name),
609 0,
610 estimated_rows,
611 kind,
612 persistence,
613 0,
614 );
615 state.relpages = Some(relpages as u64);
616 state.last_analyze = last_analyze;
617 state.last_autoanalyze = last_autoanalyze;
618
619 let partition_strategy: Option<String> = row.get("partition_strategy");
620 if let Some(ref strat) = partition_strategy {
621 state.partition_type = Some(match strat.as_str() {
622 "r" => "RANGE".to_string(),
623 "l" => "LIST".to_string(),
624 "h" => "HASH".to_string(),
625 _ => strat.to_uppercase(),
626 });
627 }
628
629 if let Some(s) = schemas
630 && !s.contains(&schema_name)
631 {
632 state.mark_fk_dependency();
633 }
634
635 cache.insert_baseline(object_id, state);
636 }
637
638 let col_query = format!("
640 SELECT
641 n.nspname AS schema_name,
642 c.relname AS relation_name,
643 a.attname AS column_name,
644 pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
645 a.attnotnull AS not_null,
646 s.avg_width AS avg_width,
647 pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
648 a.atttypmod AS type_modifier
649 FROM pg_attribute a
650 JOIN pg_class c ON a.attrelid = c.oid
651 JOIN pg_namespace n ON n.oid = c.relnamespace
652 LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
653 LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
654 WHERE a.attnum > 0 AND NOT a.attisdropped
655 AND c.relkind IN ('r', 'p', 'v', 'm')
656 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
657 {schema_filter_with_fk}
658 ORDER BY n.nspname, c.relname;
659 ");
660
661 for row in client.query(&col_query, &[&schema_values])? {
662 let schema_name: String = row.get("schema_name");
663 let relation_name: String = row.get("relation_name");
664 let column_name: String = row.get("column_name");
665 let type_name: String = row.get("type_name");
666 let not_null: bool = row.get("not_null");
667 let avg_width: Option<i32> = row.get("avg_width");
668 let default_expr_text: Option<String> = row.get("default_expr_text");
669 let type_modifier: Option<i32> = row.get("type_modifier");
670
671 let relation_id = ObjectId::new(&schema_name, &relation_name);
672 if let Some(rel) = cache.relations.get_mut(&relation_id) {
673 rel.columns.push(crate::model::column::Column {
674 name: column_name,
675 data_type: Some(type_name),
676 type_id: None,
677 is_nullable: !not_null,
678 default: None,
679 avg_width,
680 default_expr_text,
681 type_modifier,
682 });
683 }
684 }
685
686 let tp_query = format!("
688 SELECT
689 n.nspname AS schema_name,
690 c.relname AS relation_name,
691 COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
692 COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
693 FROM pg_class c
694 JOIN pg_namespace n ON n.oid = c.relnamespace
695 LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
696 LEFT JOIN pg_policy p ON p.polrelid = c.oid
697 WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
698 {schema_filter_with_fk}
699 GROUP BY n.nspname, c.relname;
700 ");
701
702 for row in client.query(&tp_query, &[&schema_values])? {
703 let schema_name: String = row.get("schema_name");
704 let relation_name: String = row.get("relation_name");
705 let triggers: Vec<String> = row.get("triggers");
706 let policies: Vec<String> = row.get("policies");
707
708 let object_id = ObjectId::new(&schema_name, &relation_name);
709
710 if let Some(rel) = cache.relations.get_mut(&object_id) {
711 rel.triggers.extend(triggers);
712 rel.policies.extend(policies);
713 }
714 }
715
716 let acl_query = format!(
718 "
719 SELECT
720 n.nspname AS schema_name,
721 c.relname AS relation_name,
722 CASE
723 WHEN acl.grantee = 0 THEN 'public'
724 ELSE pg_catalog.pg_get_userbyid(acl.grantee)
725 END AS grantee,
726 acl.privilege_type
727 FROM pg_class c
728 JOIN pg_namespace n ON n.oid = c.relnamespace
729 CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
730 WHERE c.relkind IN ('r', 'p', 'v', 'm')
731 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
732 AND acl.grantee <> c.relowner
733 {schema_filter_with_fk};
734 "
735 );
736
737 for row in client.query(&acl_query, &[&schema_values])? {
738 let schema_name: String = row.get("schema_name");
739 let relation_name: String = row.get("relation_name");
740 let grantee: String = row.get("grantee");
741 let privilege_type: String = row.get("privilege_type");
742 let privilege = match privilege_type.as_str() {
743 "SELECT" => crate::model::relation::Privilege::Select,
744 "INSERT" => crate::model::relation::Privilege::Insert,
745 "UPDATE" => crate::model::relation::Privilege::Update,
746 "DELETE" => crate::model::relation::Privilege::Delete,
747 "TRUNCATE" => crate::model::relation::Privilege::Truncate,
748 "REFERENCES" => crate::model::relation::Privilege::References,
749 "TRIGGER" => crate::model::relation::Privilege::Trigger,
750 _ => continue,
751 };
752 if let Some(relation) = cache
753 .relations
754 .get_mut(&ObjectId::new(&schema_name, &relation_name))
755 {
756 relation.privileges.grant(
757 ObjectId::new("", grantee),
758 [privilege].into_iter().collect(),
759 );
760 }
761 }
762
763 let trig_query = format!(
765 "
766 SELECT
767 n.nspname AS table_schema,
768 c.relname AS table_name,
769 t.tgname AS trigger_name,
770 t.tgenabled::text AS enabled_mode,
771 fn.nspname AS function_schema,
772 f.proname || '()' AS function_name
773 FROM pg_trigger t
774 JOIN pg_class c ON c.oid = t.tgrelid
775 JOIN pg_namespace n ON n.oid = c.relnamespace
776 JOIN pg_proc f ON f.oid = t.tgfoid
777 JOIN pg_namespace fn ON fn.oid = f.pronamespace
778 WHERE t.tgisinternal = false
779 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
780 {schema_filter_with_fk};
781 "
782 );
783
784 for row in client.query(&trig_query, &[&schema_values])? {
785 let table_schema: String = row.get("table_schema");
786 let table_name: String = row.get("table_name");
787 let trigger_name: String = row.get("trigger_name");
788 let enabled_mode: String = row.get("enabled_mode");
789 let function_schema: String = row.get("function_schema");
790 let function_name: String = row.get("function_name");
791
792 cache.triggers.push(crate::db::cache::TriggerCache {
793 trigger_id: ObjectId::new(&table_schema, &trigger_name),
794 table_id: ObjectId::new(&table_schema, &table_name),
795 function_id: ObjectId::new(&function_schema, &function_name),
796 enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode)
797 .ok_or_else(|| {
798 anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}")
799 })?,
800 });
801 }
802
803 let constraint_query = format!(
805 "
806 SELECT
807 n.nspname AS table_schema,
808 c.relname AS table_name,
809 con.conname AS constraint_name,
810 con.contype::text AS constraint_type,
811 con.convalidated AS validated
812 FROM pg_constraint con
813 JOIN pg_class c ON c.oid = con.conrelid
814 JOIN pg_namespace n ON n.oid = c.relnamespace
815 WHERE con.contype IN ('c', 'f', 'p', 'u', 'x')
816 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
817 {schema_filter};
818 "
819 );
820
821 for row in client.query(&constraint_query, &[&schema_values])? {
822 let table_schema: String = row.get("table_schema");
823 let table_name: String = row.get("table_name");
824 let constraint_name: String = row.get("constraint_name");
825 let constraint_type: String = row.get("constraint_type");
826 let validated: bool = row.get("validated");
827 let kind = match constraint_type.as_str() {
828 "c" => crate::model::constraint::ConstraintKind::Check,
829 "f" => crate::model::constraint::ConstraintKind::ForeignKey,
830 "p" => crate::model::constraint::ConstraintKind::PrimaryKey,
831 "u" => crate::model::constraint::ConstraintKind::Unique,
832 "x" => crate::model::constraint::ConstraintKind::Exclusion,
833 _ => continue,
834 };
835 cache
836 .constraints
837 .push(crate::model::constraint::ConstraintState {
838 table_id: ObjectId::new(&table_schema, &table_name),
839 name: constraint_name,
840 kind,
841 validated,
842 });
843 }
844
845 let fk_query = format!(
847 "
848 SELECT
849 c.conname AS constraint_name,
850 n1.nspname AS from_schema, t1.relname AS from_table,
851 n2.nspname AS to_schema, t2.relname AS to_table
852 FROM pg_constraint c
853 JOIN pg_class t1 ON t1.oid = c.conrelid
854 JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
855 JOIN pg_class t2 ON t2.oid = c.confrelid
856 JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
857 WHERE c.contype = 'f'
858 {schema_filter_n1_or_n2};
859 "
860 );
861
862 for row in client.query(&fk_query, &[&schema_values])? {
863 let constraint_name: String = row.get("constraint_name");
864 let from_schema: String = row.get("from_schema");
865 let from_table: String = row.get("from_table");
866 let to_schema: String = row.get("to_schema");
867 let to_table: String = row.get("to_table");
868
869 if let Some(s) = schemas
870 && (!s.contains(&from_schema) || !s.contains(&to_schema))
871 {
872 let out_of_scope_schema = if !s.contains(&from_schema) {
874 &from_schema
875 } else {
876 &to_schema
877 };
878 let out_of_scope_table = if !s.contains(&from_schema) {
879 &from_table
880 } else {
881 &to_table
882 };
883 eprintln!(
884 "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
885 constraint_name, out_of_scope_schema, out_of_scope_table
886 );
887 }
888
889 cache.foreign_keys.push(ForeignKeyCache {
890 constraint_name,
891 from_table: ObjectId::new(&from_schema, &from_table),
892 to_table: ObjectId::new(&to_schema, &to_table),
893 });
894 }
895
896 let idx_query = format!(
898 "
899 SELECT
900 n_i.nspname AS index_schema, i.relname AS index_name,
901 n_t.nspname AS table_schema, t.relname AS table_name
902 FROM pg_index x
903 JOIN pg_class i ON i.oid = x.indexrelid
904 JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
905 JOIN pg_class t ON t.oid = x.indrelid
906 JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
907 WHERE x.indisvalid = true
908 AND n_i.nspname !~ '^pg_'
909 AND n_i.nspname <> 'information_schema'
910 AND n_t.nspname !~ '^pg_'
911 AND n_t.nspname <> 'information_schema'
912 {schema_filter_nt};
913 "
914 );
915
916 for row in client.query(&idx_query, &[&schema_values])? {
917 let index_schema: String = row.get("index_schema");
918 let index_name: String = row.get("index_name");
919 let table_schema: String = row.get("table_schema");
920 let table_name: String = row.get("table_name");
921
922 if is_system_schema(&index_schema) || is_system_schema(&table_schema) {
923 continue;
924 }
925
926 cache.indexes.push(IndexCache {
927 index_id: ObjectId::new(&index_schema, &index_name),
928 table_id: ObjectId::new(&table_schema, &table_name),
929 });
930 }
931
932 let func_query = format!(
934 "
935 SELECT
936 n.nspname AS schema_name,
937 p.proname AS func_name,
938 ARRAY(
939 SELECT pg_catalog.format_type(t, NULL)
940 FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS u(t, n)
941 ORDER BY n
942 )::text[] AS arg_types,
943 pg_catalog.pg_get_function_result(p.oid) AS return_type,
944 p.provolatile::text AS volatility,
945 p.prokind::text AS routine_kind,
946 l.lanname AS language,
947 p.prosecdef AS security_definer
948 FROM pg_proc p
949 JOIN pg_namespace n ON n.oid = p.pronamespace
950 JOIN pg_language l ON l.oid = p.prolang
951 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
952 AND p.prokind IN ('f', 'p', 'a', 'w')
953 {schema_filter};
954 "
955 );
956
957 for row in client.query(&func_query, &[&schema_values])? {
958 let schema_name: String = row.get("schema_name");
959 let func_name: String = row.get("func_name");
960 let arg_types: Vec<String> = row.get("arg_types");
961 let return_type: Option<String> = row.get("return_type");
962 let volatility_char: String = row.get("volatility");
963 let routine_kind_char: String = row.get("routine_kind");
964 let language: String = row.get("language");
965 let security_definer: bool = row.get("security_definer");
966
967 let volatility = match volatility_char.as_str() {
968 "v" => crate::model::function::Volatility::Volatile,
969 "s" => crate::model::function::Volatility::Stable,
970 "i" => crate::model::function::Volatility::Immutable,
971 _ => crate::model::function::Volatility::Volatile,
972 };
973
974 let security = if security_definer {
975 crate::model::function::SecurityMode::Definer
976 } else {
977 crate::model::function::SecurityMode::Invoker
978 };
979
980 let routine_kind = match routine_kind_char.as_str() {
981 "f" => crate::model::function::RoutineKind::Function,
982 "p" => crate::model::function::RoutineKind::Procedure,
983 "a" => crate::model::function::RoutineKind::Aggregate,
984 "w" => crate::model::function::RoutineKind::Window,
985 other => anyhow::bail!("PostgreSQL returned unknown pg_proc.prokind '{other}'"),
986 };
987
988 let arg_types = arg_types
989 .iter()
990 .map(|arg_type| {
991 crate::analysis::resolver::Resolver::normalize_function_arg_type(arg_type)
992 })
993 .collect::<Vec<_>>();
994 let arg_types_str = arg_types.join(",");
995
996 let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str));
997
998 cache.functions.insert(
999 id.clone(),
1000 crate::model::function::FunctionState {
1001 id,
1002 routine_kind,
1003 arg_types,
1004 arg_type_ids: Vec::new(),
1005 return_type: return_type.unwrap_or_default(),
1006 return_type_id: None,
1007 volatility,
1008 language,
1009 security,
1010 },
1011 );
1012 }
1013
1014 let publication_query = if cache.pg_version_num.unwrap_or_default() >= 180_000 {
1017 r#"
1018 SELECT p.oid, p.pubname::text AS publication_name,
1019 pg_catalog.pg_get_userbyid(p.pubowner) AS owner_name,
1020 p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete,
1021 p.pubtruncate, p.pubviaroot, p.pubgencols::text AS generated_columns
1022 FROM pg_publication p
1023 ORDER BY p.oid
1024 "#
1025 } else {
1026 r#"
1027 SELECT p.oid, p.pubname::text AS publication_name,
1028 pg_catalog.pg_get_userbyid(p.pubowner) AS owner_name,
1029 p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete,
1030 p.pubtruncate, p.pubviaroot, NULL::text AS generated_columns
1031 FROM pg_publication p
1032 ORDER BY p.oid
1033 "#
1034 };
1035 let mut publication_names = std::collections::HashMap::<u32, String>::new();
1036 for row in client.query(publication_query, &[])? {
1037 let oid: u32 = row.get("oid");
1038 let name: String = row.get("publication_name");
1039 let mut operations = Vec::new();
1040 if row.get::<_, bool>("pubinsert") {
1041 operations.push("insert");
1042 }
1043 if row.get::<_, bool>("pubupdate") {
1044 operations.push("update");
1045 }
1046 if row.get::<_, bool>("pubdelete") {
1047 operations.push("delete");
1048 }
1049 if row.get::<_, bool>("pubtruncate") {
1050 operations.push("truncate");
1051 }
1052 let mut params = vec![
1053 crate::analysis::facts::AttributeFact {
1054 name: "publish".to_string(),
1055 value: operations.join(", "),
1056 },
1057 crate::analysis::facts::AttributeFact {
1058 name: "publish_via_partition_root".to_string(),
1059 value: row.get::<_, bool>("pubviaroot").to_string(),
1060 },
1061 ];
1062 if let Some(generated_columns) = row.get::<_, Option<String>>("generated_columns") {
1063 let value = match generated_columns.as_str() {
1064 "n" => "none",
1065 "s" => "stored",
1066 other => other,
1067 };
1068 params.push(crate::analysis::facts::AttributeFact {
1069 name: "publish_generated_columns".to_string(),
1070 value: value.to_string(),
1071 });
1072 }
1073 let scope = if row.get::<_, bool>("puballtables") {
1074 crate::analysis::facts::PublicationScope::AllTables { except: Vec::new() }
1075 } else {
1076 crate::analysis::facts::PublicationScope::Explicit(Vec::new())
1077 };
1078 publication_names.insert(oid, name.clone());
1079 cache.publications.insert(
1080 name.clone(),
1081 crate::model::replication::PublicationState {
1082 name,
1083 owner: Some(row.get("owner_name")),
1084 scope,
1085 params,
1086 generation: 0,
1087 },
1088 );
1089 }
1090
1091 let publication_rel_query = if cache.pg_version_num.unwrap_or_default() >= 150_000 {
1092 r#"
1093 SELECT pr.prpubid, n.nspname::text AS schema_name,
1094 c.relname::text AS relation_name,
1095 pg_catalog.pg_get_expr(pr.prqual, pr.prrelid) AS row_filter,
1096 CASE WHEN pr.prattrs IS NULL THEN NULL ELSE ARRAY(
1097 SELECT a.attname::text
1098 FROM pg_attribute a
1099 WHERE a.attrelid = pr.prrelid
1100 AND a.attnum = ANY(pr.prattrs::smallint[])
1101 ORDER BY array_position(pr.prattrs::smallint[], a.attnum)
1102 ) END AS columns
1103 FROM pg_publication_rel pr
1104 JOIN pg_class c ON c.oid = pr.prrelid
1105 JOIN pg_namespace n ON n.oid = c.relnamespace
1106 ORDER BY pr.prpubid, pr.oid
1107 "#
1108 } else {
1109 r#"
1110 SELECT pr.prpubid, n.nspname::text AS schema_name,
1111 c.relname::text AS relation_name,
1112 NULL::text AS row_filter, NULL::text[] AS columns
1113 FROM pg_publication_rel pr
1114 JOIN pg_class c ON c.oid = pr.prrelid
1115 JOIN pg_namespace n ON n.oid = c.relnamespace
1116 ORDER BY pr.prpubid, pr.oid
1117 "#
1118 };
1119 for row in client.query(publication_rel_query, &[])? {
1120 let publication_oid: u32 = row.get("prpubid");
1121 let Some(publication_name) = publication_names.get(&publication_oid) else {
1122 anyhow::bail!(
1123 "publication membership references unknown publication OID {publication_oid}"
1124 );
1125 };
1126 let Some(publication) = cache.publications.get_mut(publication_name) else {
1127 anyhow::bail!("publication '{publication_name}' disappeared during synchronization");
1128 };
1129 let crate::analysis::facts::PublicationScope::Explicit(objects) = &mut publication.scope
1130 else {
1131 continue;
1132 };
1133 let schema_name: String = row.get("schema_name");
1134 let relation_name: String = row.get("relation_name");
1135 objects.push(crate::analysis::facts::PublicationObjectFact::Table {
1136 name: crate::ast::identifiers::QualifiedName::new(
1137 Some(crate::ast::identifiers::Ident::new(schema_name, true)),
1138 crate::ast::identifiers::Ident::new(relation_name, true),
1139 ),
1140 only: true,
1141 include_partitions: false,
1142 columns: row.get("columns"),
1143 row_filter: row
1144 .get::<_, Option<String>>("row_filter")
1145 .map(crate::analysis::facts::PublicationRowFilter::CatalogSql),
1146 });
1147 }
1148
1149 if cache.pg_version_num.unwrap_or_default() >= 150_000 {
1150 for row in client.query(
1151 r#"
1152 SELECT pn.pnpubid, n.nspname::text AS schema_name
1153 FROM pg_publication_namespace pn
1154 JOIN pg_namespace n ON n.oid = pn.pnnspid
1155 ORDER BY pn.pnpubid, pn.oid
1156 "#,
1157 &[],
1158 )? {
1159 let publication_oid: u32 = row.get("pnpubid");
1160 let Some(publication_name) = publication_names.get(&publication_oid) else {
1161 anyhow::bail!(
1162 "publication schema membership references unknown publication OID {publication_oid}"
1163 );
1164 };
1165 let Some(publication) = cache.publications.get_mut(publication_name) else {
1166 anyhow::bail!(
1167 "publication '{publication_name}' disappeared during synchronization"
1168 );
1169 };
1170 let crate::analysis::facts::PublicationScope::Explicit(objects) =
1171 &mut publication.scope
1172 else {
1173 continue;
1174 };
1175 objects.push(
1176 crate::analysis::facts::PublicationObjectFact::SchemaTables {
1177 schema: row.get("schema_name"),
1178 row_filter: None,
1179 },
1180 );
1181 }
1182 }
1183
1184 let subscription_query = match cache.pg_version_num.unwrap_or_default() {
1187 170_000.. => {
1188 r#"
1189 SELECT s.subname::text AS subscription_name,
1190 pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1191 s.subenabled, s.subbinary, s.subslotname::text,
1192 s.subsynccommit, s.subpublications,
1193 s.substream::text AS streaming,
1194 s.subtwophasestate::text AS two_phase_state,
1195 s.subdisableonerr AS disable_on_error,
1196 s.subpasswordrequired AS password_required,
1197 s.subrunasowner AS run_as_owner,
1198 s.subfailover AS failover,
1199 s.suborigin AS origin,
1200 s.subskiplsn::text AS skip_lsn
1201 FROM pg_subscription s
1202 WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1203 ORDER BY s.oid
1204 "#
1205 }
1206 160_000.. => {
1207 r#"
1208 SELECT s.subname::text AS subscription_name,
1209 pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1210 s.subenabled, s.subbinary, s.subslotname::text,
1211 s.subsynccommit, s.subpublications,
1212 s.substream::text AS streaming,
1213 s.subtwophasestate::text AS two_phase_state,
1214 s.subdisableonerr AS disable_on_error,
1215 s.subpasswordrequired AS password_required,
1216 s.subrunasowner AS run_as_owner,
1217 NULL::bool AS failover,
1218 s.suborigin AS origin,
1219 s.subskiplsn::text AS skip_lsn
1220 FROM pg_subscription s
1221 WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1222 ORDER BY s.oid
1223 "#
1224 }
1225 150_000.. => {
1226 r#"
1227 SELECT s.subname::text AS subscription_name,
1228 pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1229 s.subenabled, s.subbinary, s.subslotname::text,
1230 s.subsynccommit, s.subpublications,
1231 s.substream::text AS streaming,
1232 s.subtwophasestate::text AS two_phase_state,
1233 s.subdisableonerr AS disable_on_error,
1234 NULL::bool AS password_required,
1235 NULL::bool AS run_as_owner,
1236 NULL::bool AS failover,
1237 NULL::text AS origin,
1238 s.subskiplsn::text AS skip_lsn
1239 FROM pg_subscription s
1240 WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1241 ORDER BY s.oid
1242 "#
1243 }
1244 _ => {
1245 r#"
1246 SELECT s.subname::text AS subscription_name,
1247 pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1248 s.subenabled, s.subbinary, s.subslotname::text,
1249 s.subsynccommit, s.subpublications,
1250 s.substream::text AS streaming,
1251 NULL::text AS two_phase_state,
1252 NULL::bool AS disable_on_error,
1253 NULL::bool AS password_required,
1254 NULL::bool AS run_as_owner,
1255 NULL::bool AS failover,
1256 NULL::text AS origin,
1257 NULL::text AS skip_lsn
1258 FROM pg_subscription s
1259 WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1260 ORDER BY s.oid
1261 "#
1262 }
1263 };
1264 for row in client.query(subscription_query, &[])? {
1265 let name: String = row.get("subscription_name");
1266 let mut params = vec![
1267 crate::analysis::facts::AttributeFact {
1268 name: "binary".to_string(),
1269 value: row.get::<_, bool>("subbinary").to_string(),
1270 },
1271 crate::analysis::facts::AttributeFact {
1272 name: "streaming".to_string(),
1273 value: match row.get::<_, String>("streaming").as_str() {
1274 "t" | "true" => "true".to_string(),
1275 "f" | "false" => "false".to_string(),
1276 "p" => "parallel".to_string(),
1277 other => other.to_string(),
1278 },
1279 },
1280 crate::analysis::facts::AttributeFact {
1281 name: "synchronous_commit".to_string(),
1282 value: row.get("subsynccommit"),
1283 },
1284 ];
1285 let mut push_param = |name: &str, value: Option<String>| {
1286 if let Some(value) = value {
1287 params.push(crate::analysis::facts::AttributeFact {
1288 name: name.to_string(),
1289 value,
1290 });
1291 }
1292 };
1293 push_param(
1294 "two_phase",
1295 row.get::<_, Option<String>>("two_phase_state")
1296 .map(|state| match state.as_str() {
1297 "d" => "false".to_string(),
1298 "e" => "true".to_string(),
1299 "p" => "pending".to_string(),
1300 other => other.to_string(),
1301 }),
1302 );
1303 push_param(
1304 "disable_on_error",
1305 row.get::<_, Option<bool>>("disable_on_error")
1306 .map(|value| value.to_string()),
1307 );
1308 push_param(
1309 "password_required",
1310 row.get::<_, Option<bool>>("password_required")
1311 .map(|value| value.to_string()),
1312 );
1313 push_param(
1314 "run_as_owner",
1315 row.get::<_, Option<bool>>("run_as_owner")
1316 .map(|value| value.to_string()),
1317 );
1318 push_param(
1319 "failover",
1320 row.get::<_, Option<bool>>("failover")
1321 .map(|value| value.to_string()),
1322 );
1323 push_param("origin", row.get("origin"));
1324 push_param(
1325 "skip_lsn",
1326 row.get::<_, Option<String>>("skip_lsn")
1327 .filter(|lsn| lsn != "0/0"),
1328 );
1329 cache.subscriptions.insert(
1330 name.clone(),
1331 crate::model::replication::SubscriptionState {
1332 name,
1333 owner: Some(row.get("owner_name")),
1334 connection: crate::analysis::facts::ConnectionTarget::Redacted,
1335 publications: row.get("subpublications"),
1336 params: Some(params),
1337 enabled: row.get("subenabled"),
1338 slot_name: row.get("subslotname"),
1339 generation: 0,
1340 },
1341 );
1342 }
1343
1344 let type_query = format!(
1346 "
1347 SELECT
1348 n.nspname AS schema_name,
1349 t.typname AS type_name,
1350 t.typtype::text AS type_kind,
1351 CASE WHEN t.typtype = 'd'
1352 THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
1353 ELSE NULL
1354 END AS domain_base_type,
1355 COALESCE(
1356 array_agg(e.enumlabel ORDER BY e.enumsortorder)
1357 FILTER (WHERE e.enumlabel IS NOT NULL),
1358 ARRAY[]::text[]
1359 ) AS enum_labels
1360 FROM pg_type t
1361 JOIN pg_namespace n ON n.oid = t.typnamespace
1362 LEFT JOIN pg_enum e ON e.enumtypid = t.oid
1363 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
1364 AND t.typtype IN ('e', 'd')
1365 {schema_filter}
1366 GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod;
1367 "
1368 );
1369
1370 for row in client.query(&type_query, &[&schema_values])? {
1371 let schema_name: String = row.get("schema_name");
1372 let type_name: String = row.get("type_name");
1373 let type_kind: String = row.get("type_kind");
1374 let domain_base_type: Option<String> = row.get("domain_base_type");
1375 let enum_labels: Vec<String> = row.get("enum_labels");
1376 let kind = match type_kind.as_str() {
1377 "e" => crate::model::types::TypeKind::Enum {
1378 variants: enum_labels,
1379 },
1380 "d" => crate::model::types::TypeKind::Domain {
1381 base_type: domain_base_type.unwrap_or_default(),
1382 base_type_id: None,
1383 },
1384 _ => continue,
1385 };
1386 let id = ObjectId::new(&schema_name, &type_name);
1387 cache.types.insert(
1388 id.clone(),
1389 crate::model::types::TypeState {
1390 id,
1391 generation: 0,
1392 kind,
1393 },
1394 );
1395 }
1396
1397 let depend_query = r#"
1399 SELECT
1400 d.classid, d.objid, d.objsubid,
1401 d.refclassid, d.refobjid, d.refobjsubid,
1402 d.deptype::text,
1403 COALESCE(n1.nspname, n1p.nspname, n1t.nspname) AS obj_schema,
1404 COALESCE(c1.relname, p1.proname, t1.typname) AS obj_name,
1405 COALESCE(n2.nspname, n2p.nspname, n2t.nspname) AS ref_schema,
1406 COALESCE(c2.relname, p2.proname, t2.typname) AS ref_name
1407 FROM pg_depend d
1408 LEFT JOIN pg_class c1 ON c1.oid = d.objid AND d.classid = 'pg_class'::regclass
1409 LEFT JOIN pg_namespace n1 ON n1.oid = c1.relnamespace
1410 LEFT JOIN pg_proc p1 ON p1.oid = d.objid AND d.classid = 'pg_proc'::regclass
1411 LEFT JOIN pg_namespace n1p ON n1p.oid = p1.pronamespace
1412 LEFT JOIN pg_type t1 ON t1.oid = d.objid AND d.classid = 'pg_type'::regclass
1413 LEFT JOIN pg_namespace n1t ON n1t.oid = t1.typnamespace
1414 LEFT JOIN pg_class c2 ON c2.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
1415 LEFT JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
1416 LEFT JOIN pg_proc p2 ON p2.oid = d.refobjid AND d.refclassid = 'pg_proc'::regclass
1417 LEFT JOIN pg_namespace n2p ON n2p.oid = p2.pronamespace
1418 LEFT JOIN pg_type t2 ON t2.oid = d.refobjid AND d.refclassid = 'pg_type'::regclass
1419 LEFT JOIN pg_namespace n2t ON n2t.oid = t2.typnamespace
1420 WHERE d.deptype IN ('n', 'a', 'i')
1421 AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname) IS NOT NULL
1422 AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname)
1423 NOT IN ('pg_catalog', 'information_schema')
1424 AND (
1425 $1::text[] IS NULL
1426 OR COALESCE(n1.nspname, n1p.nspname, n1t.nspname) = ANY($1)
1427 )
1428 "#;
1429
1430 for row in client.query(depend_query, &[&schema_values])? {
1431 let classid: u32 = row.get(0);
1432 let objid: u32 = row.get(1);
1433 let objsubid: i32 = row.get(2);
1434 let refclassid: u32 = row.get(3);
1435 let refobjid: u32 = row.get(4);
1436 let refobjsubid: i32 = row.get(5);
1437 let deptype: String = row.get(6);
1438 let obj_schema: Option<String> = row.get(7);
1439 let obj_name: Option<String> = row.get(8);
1440 let ref_schema: Option<String> = row.get(9);
1441 let ref_name: Option<String> = row.get(10);
1442
1443 cache.dependencies.push(crate::db::cache::DependencyCache {
1444 classid,
1445 objid,
1446 objsubid,
1447 refclassid,
1448 refobjid,
1449 refobjsubid,
1450 deptype,
1451 obj_schema,
1452 obj_name,
1453 ref_schema,
1454 ref_name,
1455 });
1456 }
1457
1458 let view_depend_query = r#"
1461 SELECT DISTINCT
1462 'pg_class'::regclass::oid AS classid,
1463 vc.oid AS objid,
1464 0 AS objsubid,
1465 'pg_class'::regclass::oid AS refclassid,
1466 tc.oid AS refobjid,
1467 0 AS refobjsubid,
1468 vn.nspname AS obj_schema,
1469 vc.relname AS obj_name,
1470 tn.nspname AS ref_schema,
1471 tc.relname AS ref_name
1472 FROM pg_rewrite rw
1473 JOIN pg_class vc ON vc.oid = rw.ev_class
1474 JOIN pg_namespace vn ON vn.oid = vc.relnamespace
1475 JOIN pg_depend d ON d.objid = rw.oid
1476 JOIN pg_class tc ON tc.oid = d.refobjid
1477 JOIN pg_namespace tn ON tn.oid = tc.relnamespace
1478 WHERE vc.relkind IN ('v', 'm')
1479 AND d.deptype = 'n'
1480 -- PostgreSQL 14/15 expose an internal rewrite-rule self-edge. It is
1481 -- not a dependency of the view definition and must not enter the
1482 -- modeled dependency graph.
1483 AND tc.oid <> vc.oid
1484 AND (
1485 $1::text[] IS NULL
1486 OR (vn.nspname = ANY($1) AND tn.nspname = ANY($1))
1487 )
1488 "#;
1489
1490 for row in client.query(view_depend_query, &[&schema_values])? {
1491 cache.dependencies.push(crate::db::cache::DependencyCache {
1492 classid: row.get(0),
1493 objid: row.get(1),
1494 objsubid: row.get(2),
1495 refclassid: row.get(3),
1496 refobjid: row.get(4),
1497 refobjsubid: row.get(5),
1498 deptype: "view".to_string(),
1499 obj_schema: Some(row.get(6)),
1500 obj_name: Some(row.get(7)),
1501 ref_schema: Some(row.get(8)),
1502 ref_name: Some(row.get(9)),
1503 });
1504 }
1505
1506 for row in client.query(
1510 "SELECT rolname, rolcanlogin, rolsuper FROM pg_roles ORDER BY rolname;",
1511 &[],
1512 )? {
1513 let name: String = row.get(0);
1514 let id = ObjectId::new("", &name);
1515 cache.roles.insert(
1516 id.clone(),
1517 crate::model::role::RoleState {
1518 id,
1519 can_login: row.get(1),
1520 is_superuser: row.get(2),
1521 member_of: Vec::new(),
1522 can_set_role_to: Vec::new(),
1523 granted_privileges: Vec::new(),
1524 },
1525 );
1526 }
1527
1528 let membership_query = if cache.pg_version_num.unwrap_or_default() >= 160_000 {
1529 "SELECT member.rolname, parent.rolname, membership.set_option
1530 FROM pg_auth_members membership
1531 JOIN pg_roles member ON member.oid = membership.member
1532 JOIN pg_roles parent ON parent.oid = membership.roleid;"
1533 } else {
1534 "SELECT member.rolname, parent.rolname, true AS set_option
1535 FROM pg_auth_members membership
1536 JOIN pg_roles member ON member.oid = membership.member
1537 JOIN pg_roles parent ON parent.oid = membership.roleid;"
1538 };
1539 for row in client.query(membership_query, &[])? {
1540 let member = ObjectId::new("", row.get::<_, String>(0));
1541 let parent = ObjectId::new("", row.get::<_, String>(1));
1542 let set_option: bool = row.get(2);
1543 if let Some(role) = cache.roles.get_mut(&member) {
1544 role.member_of.push(parent.clone());
1545 if set_option {
1546 role.can_set_role_to.push(parent);
1547 }
1548 }
1549 }
1550
1551 Ok(cache)
1552}
1553
1554#[cfg(test)]
1555mod atomic_write_tests {
1556 use super::*;
1557 use crate::db::cache::DbCacheVersioned;
1558 use std::fs;
1559 use std::io::Read;
1560
1561 #[test]
1562 fn production_cache_writer_atomically_replaces_and_decodes() {
1563 let temp_dir = tempfile::tempdir().unwrap();
1564 let cache_path = temp_dir.path().join("baseline.cache");
1565 fs::write(&cache_path, b"old-cache").unwrap();
1566
1567 let mut cache = DbCache::new();
1568 cache.pg_version_num = Some(180002);
1569 write_cache(&cache_path, cache, false).unwrap();
1570
1571 let encoded = fs::read(&cache_path).unwrap();
1572 assert_ne!(encoded, b"old-cache");
1573 let reader = std::io::Cursor::new(encoded);
1574 let mut decoder = zstd::stream::Decoder::new(reader).unwrap();
1575 let mut payload = Vec::new();
1576 decoder.read_to_end(&mut payload).unwrap();
1577 let payload = payload
1578 .strip_prefix(CACHE_V6_MAGIC)
1579 .expect("writer must prefix V6 cache payloads");
1580 let config = bincode::config::standard().with_variable_int_encoding();
1581 let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config)
1582 .unwrap()
1583 .0;
1584 assert_eq!(versioned.into_cache().unwrap().pg_version_num, Some(180002));
1585 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
1586 }
1587
1588 #[test]
1589 fn production_cache_writer_preserves_old_bytes_after_pre_install_failure() {
1590 let temp_dir = tempfile::tempdir().unwrap();
1591 let cache_path = temp_dir.path().join("baseline.cache");
1592 fs::write(&cache_path, b"known-good-cache").unwrap();
1593
1594 let error = write_cache_with_protection(&cache_path, DbCache::new(), |_| {
1595 Err(anyhow::anyhow!("injected payload-protection failure"))
1596 })
1597 .unwrap_err();
1598
1599 assert!(
1600 error
1601 .to_string()
1602 .contains("injected payload-protection failure")
1603 );
1604 assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
1605 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
1606 }
1607
1608 #[test]
1609 fn cache_writer_rejects_oversized_decoded_payload_before_replacement() {
1610 let temp_dir = tempfile::tempdir().unwrap();
1611 let cache_path = temp_dir.path().join("baseline.cache");
1612 fs::write(&cache_path, b"known-good-cache").unwrap();
1613
1614 let error = write_cache_with_protection_and_limits(
1615 &cache_path,
1616 DbCache::new(),
1617 Ok,
1618 MAX_CACHE_FILE_BYTES,
1619 CACHE_V6_MAGIC.len(),
1620 )
1621 .unwrap_err();
1622
1623 assert!(format!("{error:#}").contains("decoded-size limit"));
1624 assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
1625 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
1626 }
1627
1628 #[test]
1629 fn cache_writer_rejects_oversized_encoded_payload_before_replacement() {
1630 let temp_dir = tempfile::tempdir().unwrap();
1631 let cache_path = temp_dir.path().join("baseline.cache");
1632 fs::write(&cache_path, b"known-good-cache").unwrap();
1633 let max_file_bytes = 16_u64;
1634
1635 let error = write_cache_with_protection_and_limits(
1636 &cache_path,
1637 DbCache::new(),
1638 |_| Ok(vec![0; max_file_bytes as usize + 1]),
1639 max_file_bytes,
1640 MAX_CACHE_DECODE_BYTES,
1641 )
1642 .unwrap_err();
1643
1644 assert!(format!("{error:#}").contains("encoded-size limit"));
1645 assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
1646 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
1647 }
1648}