1use crate::ast::identifiers::ObjectId;
2use crate::db::cache::{CACHE_V6_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache};
3use crate::db::cache_file::{
4 MAX_CACHE_DECODE_BYTES, MAX_CACHE_FILE_BYTES, protect_cache_bytes,
5 validate_cache_encryption_configuration,
6};
7use crate::model::relation::{Persistence, RelationKind, RelationState};
8use anyhow::{Context, Result};
9use postgres::config::Host;
10use postgres::{Client, Config as PostgresConfig, GenericClient, IsolationLevel, NoTls};
11use std::io::{self, Write};
12use std::path::Path;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14use tempfile::NamedTempFile;
15
16#[cfg(windows)]
17use std::fs;
18
19const MIN_POSTGRES_VERSION_NUM: u32 = 140_000;
20const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
21
22pub fn sync_cache(
23 out_path: &Path,
24 schemas: Option<&[String]>,
25 cache_encryption: bool,
26) -> Result<()> {
27 validate_cache_encryption_configuration(cache_encryption)
28 .context("Invalid cache encryption configuration")?;
29 let db_url = std::env::var("DATABASE_URL")
31 .context("DATABASE_URL environment variable is required to sync PostgreSQL schema metadata and statistics. Do not pass credentials via CLI flags or config files.")?;
32 if db_url.trim().is_empty() {
33 anyhow::bail!("DATABASE_URL must not be empty or whitespace");
34 }
35
36 let mut client = connect_database(&db_url)?;
37
38 let cache = populate_cache(&mut client, schemas)?;
39
40 write_cache(out_path, cache, cache_encryption)
41}
42
43fn connect_database(db_url: &str) -> Result<Client> {
44 let mut config: PostgresConfig = db_url
45 .parse()
46 .context("DATABASE_URL is not a valid PostgreSQL connection string")?;
47
48 if !database_config_is_local(&config) {
49 anyhow::bail!(
50 "Remote DATABASE_URL connections are not supported by this build. Use an SSH tunnel and connect through localhost or a Unix socket."
51 );
52 }
53
54 apply_connection_safety_defaults(&mut config);
55
56 config
57 .connect(NoTls)
58 .context("Failed to connect to PostgreSQL")
59}
60
61fn apply_connection_safety_defaults(config: &mut PostgresConfig) {
62 if config.get_connect_timeout().is_none() {
63 config.connect_timeout(DEFAULT_CONNECT_TIMEOUT);
64 }
65}
66
67pub(crate) fn database_config_is_local(config: &PostgresConfig) -> bool {
68 config
69 .get_hostaddrs()
70 .iter()
71 .all(|address| address.is_loopback())
72 && config.get_hosts().iter().all(|host| match host {
73 #[cfg(unix)]
74 Host::Unix(_) => true,
75 Host::Tcp(name) => is_local_host(name),
76 })
77}
78
79pub(crate) fn ensure_supported_postgres_version(version: u32) -> Result<()> {
80 if version < MIN_POSTGRES_VERSION_NUM {
81 anyhow::bail!(
82 "PostgreSQL {} is unsupported; safe-migrate sync requires PostgreSQL 14 or newer",
83 version / 10_000
84 );
85 }
86 Ok(())
87}
88
89pub(crate) fn is_local_host(host: &str) -> bool {
90 if host.starts_with('/') || host.eq_ignore_ascii_case("localhost") {
91 return true;
92 }
93 host.trim_start_matches('[')
94 .trim_end_matches(']')
95 .parse::<std::net::IpAddr>()
96 .is_ok_and(|address| address.is_loopback())
97}
98
99pub(crate) fn cache_search_path(
100 database_search_path: Vec<String>,
101 schemas: Option<&[String]>,
102) -> Vec<String> {
103 let Some(schemas) = schemas else {
104 return database_search_path;
105 };
106
107 let mut scoped_search_path = Vec::new();
108 for schema in database_search_path
109 .into_iter()
110 .filter(|schema| schemas.contains(schema))
111 .chain(schemas.iter().cloned())
112 {
113 if !scoped_search_path.contains(&schema) {
114 scoped_search_path.push(schema);
115 }
116 }
117 scoped_search_path
118}
119
120pub(crate) fn parse_search_path_setting(setting: &str) -> Vec<String> {
123 let mut entries = Vec::new();
124 let mut current = String::new();
125 let mut chars = setting.chars().peekable();
126 let mut quoted = false;
127
128 while let Some(ch) = chars.next() {
129 match ch {
130 '"' if quoted && chars.peek() == Some(&'"') => {
131 current.push('"');
132 chars.next();
133 }
134 '"' => quoted = !quoted,
135 ',' if !quoted => {
136 let entry = current.trim();
137 if !entry.is_empty() {
138 entries.push(entry.to_string());
139 }
140 current.clear();
141 }
142 _ => current.push(ch),
143 }
144 }
145
146 let entry = current.trim();
147 if !entry.is_empty() {
148 entries.push(entry.to_string());
149 }
150 entries
151}
152
153pub(crate) fn relation_owner_id(owner_name: impl Into<String>) -> ObjectId {
154 ObjectId::new("", owner_name)
155}
156
157pub(crate) fn is_system_schema(schema: &str) -> bool {
158 schema == "information_schema" || schema.starts_with("pg_")
159}
160
161fn sequence_kind_from_pg(
162 dependency_type: Option<&str>,
163 has_nextval_default: bool,
164) -> Result<crate::model::sequence::SequenceKind> {
165 match dependency_type {
166 Some("i") => Ok(crate::model::sequence::SequenceKind::Identity),
167 Some("a") if has_nextval_default => Ok(crate::model::sequence::SequenceKind::SerialLike),
168 Some("a") => Ok(crate::model::sequence::SequenceKind::Owned),
169 None => Ok(crate::model::sequence::SequenceKind::Standalone),
170 Some(other) => anyhow::bail!("unsupported pg_depend type '{other}'"),
171 }
172}
173
174fn relation_kind_from_pg(code: u8) -> Result<RelationKind> {
175 match code {
176 b'r' | b'p' => Ok(RelationKind::Table),
177 b'v' => Ok(RelationKind::View),
178 b'm' => Ok(RelationKind::MaterializedView),
179 other => anyhow::bail!("unsupported pg_class.relkind byte {other}"),
180 }
181}
182
183fn persistence_from_pg(code: u8) -> Result<Persistence> {
184 match code {
185 b'p' => Ok(Persistence::Permanent),
186 b't' => Ok(Persistence::Temporary),
187 b'u' => Ok(Persistence::Unlogged),
188 other => anyhow::bail!("unsupported pg_class.relpersistence byte {other}"),
189 }
190}
191
192fn partition_strategy_from_pg(code: Option<&str>) -> Result<Option<String>> {
193 match code {
194 None => Ok(None),
195 Some("r") => Ok(Some("RANGE".to_string())),
196 Some("l") => Ok(Some("LIST".to_string())),
197 Some("h") => Ok(Some("HASH".to_string())),
198 Some(other) => anyhow::bail!("unsupported partition strategy '{other}'"),
199 }
200}
201
202fn routine_volatility_from_pg(code: &str) -> Result<crate::model::function::Volatility> {
203 match code {
204 "v" => Ok(crate::model::function::Volatility::Volatile),
205 "s" => Ok(crate::model::function::Volatility::Stable),
206 "i" => Ok(crate::model::function::Volatility::Immutable),
207 other => anyhow::bail!("unknown pg_proc.provolatile value '{other}'"),
208 }
209}
210
211fn routine_kind_from_pg(code: &str) -> Result<crate::model::function::RoutineKind> {
212 match code {
213 "f" => Ok(crate::model::function::RoutineKind::Function),
214 "p" => Ok(crate::model::function::RoutineKind::Procedure),
215 "a" => Ok(crate::model::function::RoutineKind::Aggregate),
216 "w" => Ok(crate::model::function::RoutineKind::Window),
217 other => anyhow::bail!("unknown pg_proc.prokind value '{other}'"),
218 }
219}
220
221fn subscription_streaming_from_pg(code: &str) -> Result<&'static str> {
222 match code {
223 "t" | "true" => Ok("true"),
224 "f" | "false" => Ok("false"),
225 "p" => Ok("parallel"),
226 other => anyhow::bail!("unknown subscription streaming mode '{other}'"),
227 }
228}
229
230fn subscription_two_phase_from_pg(code: &str) -> Result<&'static str> {
231 match code {
232 "d" => Ok("false"),
233 "e" => Ok("true"),
234 "p" => Ok("pending"),
235 other => anyhow::bail!("unknown subscription two-phase state '{other}'"),
236 }
237}
238
239fn write_cache(out_path: &Path, cache: DbCache, cache_encryption: bool) -> Result<()> {
240 write_cache_with_protection(out_path, cache, |compressed| {
241 protect_cache_bytes(compressed, cache_encryption)
242 })
243}
244
245fn write_cache_with_protection(
246 out_path: &Path,
247 cache: DbCache,
248 protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
249) -> Result<()> {
250 write_cache_with_protection_and_limits(
251 out_path,
252 cache,
253 protect,
254 MAX_CACHE_FILE_BYTES,
255 MAX_CACHE_DECODE_BYTES,
256 )
257}
258
259fn write_cache_with_protection_and_limits(
260 out_path: &Path,
261 cache: DbCache,
262 protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
263 max_file_bytes: u64,
264 max_decode_bytes: usize,
265) -> Result<()> {
266 cache
267 .validate_semantics()
268 .map_err(anyhow::Error::msg)
269 .context("Refusing to write a semantically invalid Cache V6 baseline")?;
270 let parent = cache_parent(out_path);
271 let mut temp_file = NamedTempFile::new_in(parent).with_context(|| {
272 format!(
273 "Failed to create temporary cache file beside {}",
274 out_path.display()
275 )
276 })?;
277 let mut compressed = Vec::new();
278 let encoder = zstd::stream::Encoder::new(&mut compressed, 3)
279 .context("Failed to init zstd compression")?;
280 let mut encoder = SizeLimitedWriter::new(encoder, max_decode_bytes);
281
282 if let Err(error) = encoder.write_all(CACHE_V6_MAGIC) {
283 if encoder.limit_exceeded() {
284 anyhow::bail!(
285 "Cache payload exceeds the {} MiB decoded-size limit",
286 max_decode_bytes / (1024 * 1024)
287 );
288 }
289 return Err(error).context("Failed to write cache V6 payload header");
290 }
291
292 let versioned = DbCacheVersioned::V6(Box::new(cache));
293 let bincode_config = bincode::config::standard().with_variable_int_encoding();
294
295 let encode_result =
296 bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config);
297 if encoder.limit_exceeded() {
298 anyhow::bail!(
299 "Cache payload exceeds the {} MiB decoded-size limit",
300 max_decode_bytes / (1024 * 1024)
301 );
302 }
303 encode_result.context("Failed bincode schema compilation and write")?;
304
305 let encoder = encoder.into_inner();
306 encoder
307 .finish()
308 .context("Failed to flush final zstd stream to disk")?;
309
310 let cache_bytes = protect(compressed)?;
311 let cache_file_bytes = u64::try_from(cache_bytes.len()).unwrap_or(u64::MAX);
312 if cache_file_bytes > max_file_bytes {
313 anyhow::bail!(
314 "Cache payload exceeds the {} MiB encoded-size limit",
315 max_file_bytes / (1024 * 1024)
316 );
317 }
318 temp_file
319 .write_all(&cache_bytes)
320 .context("Failed to write cache payload")?;
321 temp_file.flush().context("Failed to flush cache payload")?;
322 temp_file
323 .as_file()
324 .sync_all()
325 .context("Failed to synchronize cache payload before installation")?;
326
327 replace_cache(temp_file, out_path)?;
328
329 Ok(())
330}
331
332fn cache_parent(out_path: &Path) -> &Path {
333 out_path
334 .parent()
335 .filter(|parent| !parent.as_os_str().is_empty())
336 .unwrap_or_else(|| Path::new("."))
337}
338
339struct SizeLimitedWriter<W> {
341 inner: W,
342 bytes_written: usize,
343 max_bytes: usize,
344 limit_exceeded: bool,
345}
346
347impl<W> SizeLimitedWriter<W> {
348 fn new(inner: W, max_bytes: usize) -> Self {
349 Self {
350 inner,
351 bytes_written: 0,
352 max_bytes,
353 limit_exceeded: false,
354 }
355 }
356
357 fn limit_exceeded(&self) -> bool {
358 self.limit_exceeded
359 }
360
361 fn into_inner(self) -> W {
362 self.inner
363 }
364}
365
366impl<W: Write> Write for SizeLimitedWriter<W> {
367 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
368 if bytes.len() > self.max_bytes.saturating_sub(self.bytes_written) {
369 self.limit_exceeded = true;
370 return Err(io::Error::new(
371 io::ErrorKind::InvalidData,
372 "cache decoded-size limit exceeded",
373 ));
374 }
375
376 let written = self.inner.write(bytes)?;
377 self.bytes_written = self.bytes_written.saturating_add(written);
378 Ok(written)
379 }
380
381 fn flush(&mut self) -> io::Result<()> {
382 self.inner.flush()
383 }
384}
385
386#[cfg(not(windows))]
387fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
388 temp_file
389 .persist(out_path)
390 .map_err(|error| error.error)
391 .with_context(|| {
392 format!(
393 "Failed to atomically replace cache file: {}",
394 out_path.display()
395 )
396 })?;
397 let parent = cache_parent(out_path);
398 std::fs::File::open(parent)
399 .and_then(|directory| directory.sync_all())
400 .with_context(|| {
401 format!(
402 "Installed cache but failed to synchronize its parent directory: {}",
403 parent.display()
404 )
405 })?;
406 Ok(())
407}
408
409#[cfg(windows)]
410fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
411 let backup = out_path.with_extension("safe-migrate.backup");
412 if backup.exists() {
413 if out_path.exists() {
414 fs::remove_file(&backup).with_context(|| {
415 format!(
416 "Failed to remove stale cache backup before replacement: {}",
417 backup.display()
418 )
419 })?;
420 } else {
421 fs::rename(&backup, out_path).with_context(|| {
422 format!(
423 "Failed to restore interrupted cache replacement from backup: {}",
424 backup.display()
425 )
426 })?;
427 }
428 }
429
430 if !out_path.exists() {
431 temp_file
432 .persist(out_path)
433 .map_err(|error| error.error)
434 .with_context(|| format!("Failed to install cache file: {}", out_path.display()))?;
435 return Ok(());
436 }
437
438 fs::rename(out_path, &backup).with_context(|| {
439 format!(
440 "Failed to stage existing cache for replacement: {}",
441 out_path.display()
442 )
443 })?;
444
445 match temp_file.persist(out_path) {
446 Ok(_) => {
447 fs::remove_file(&backup).with_context(|| {
448 format!(
449 "Installed new cache but failed to remove backup: {}",
450 backup.display()
451 )
452 })?;
453 Ok(())
454 }
455 Err(error) => {
456 let restore_result = fs::rename(&backup, out_path);
457 let message = if let Err(restore_error) = restore_result {
458 format!(
459 "Failed to install new cache: {}. The old cache could not be restored: {}",
460 error.error, restore_error
461 )
462 } else {
463 format!(
464 "Failed to install new cache; restored the previous cache: {}",
465 error.error
466 )
467 };
468 Err(anyhow::anyhow!(message))
469 }
470 }
471}
472
473pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result<DbCache> {
474 let mut transaction = client
475 .build_transaction()
476 .isolation_level(IsolationLevel::RepeatableRead)
477 .read_only(true)
478 .start()
479 .context("Failed to start read-only cache synchronization transaction")?;
480 let cache = populate_cache_from_client(&mut transaction, schemas)?;
481 transaction
482 .commit()
483 .context("Failed to commit cache synchronization transaction")?;
484 Ok(cache)
485}
486
487#[doc(hidden)]
488pub fn populate_cache_in_current_transaction(
489 client: &mut Client,
490 schemas: Option<&[String]>,
491) -> Result<DbCache> {
492 populate_cache_from_client(client, schemas)
493}
494
495fn load_view_dependencies(
496 client: &mut impl GenericClient,
497 schema_values: &Option<Vec<String>>,
498) -> Result<Vec<crate::db::cache::DependencyCache>> {
499 let query = r#"
500 SELECT DISTINCT
501 'pg_class'::regclass::oid AS classid,
502 vc.oid AS objid,
503 0 AS objsubid,
504 'pg_class'::regclass::oid AS refclassid,
505 tc.oid AS refobjid,
506 0 AS refobjsubid,
507 vn.nspname AS obj_schema,
508 vc.relname AS obj_name,
509 tn.nspname AS ref_schema,
510 tc.relname AS ref_name
511 FROM pg_rewrite rw
512 JOIN pg_class vc ON vc.oid = rw.ev_class
513 JOIN pg_namespace vn ON vn.oid = vc.relnamespace
514 JOIN pg_depend d ON d.objid = rw.oid
515 JOIN pg_class tc ON tc.oid = d.refobjid
516 JOIN pg_namespace tn ON tn.oid = tc.relnamespace
517 WHERE vc.relkind IN ('v', 'm')
518 AND d.classid = 'pg_rewrite'::regclass
519 AND d.refclassid = 'pg_class'::regclass
520 AND d.deptype = 'n'
521 AND tc.oid <> vc.oid
522 AND tc.relkind IN ('r', 'p', 'v', 'm')
523 AND vn.nspname NOT LIKE 'pg\_%' ESCAPE '\'
524 AND vn.nspname <> 'information_schema'
525 AND tn.nspname NOT LIKE 'pg\_%' ESCAPE '\'
526 AND tn.nspname <> 'information_schema'
527 AND (
528 $1::text[] IS NULL
529 OR vn.nspname = ANY($1)
530 OR tn.nspname = ANY($1)
531 )
532 "#;
533
534 let rows = client
535 .query(query, &[schema_values])
536 .context("Failed to load view dependencies from pg_rewrite/pg_depend")?;
537 rows.into_iter()
538 .map(|row| {
539 Ok(crate::db::cache::DependencyCache {
540 classid: row.try_get(0).context("view dependency classid")?,
541 objid: row.try_get(1).context("view dependency object oid")?,
542 objsubid: row.try_get(2).context("view dependency object sub-id")?,
543 refclassid: row
544 .try_get(3)
545 .context("view dependency referenced classid")?,
546 refobjid: row
547 .try_get(4)
548 .context("view dependency referenced object oid")?,
549 refobjsubid: row
550 .try_get(5)
551 .context("view dependency referenced object sub-id")?,
552 deptype: "view".to_string(),
553 obj_schema: Some(row.try_get(6).context("view dependency schema")?),
554 obj_name: Some(row.try_get(7).context("view dependency name")?),
555 ref_schema: Some(
556 row.try_get(8)
557 .context("view dependency referenced schema")?,
558 ),
559 ref_name: Some(row.try_get(9).context("view dependency referenced name")?),
560 })
561 })
562 .collect()
563}
564
565fn load_roles(
566 client: &mut impl GenericClient,
567 pg_version_num: u32,
568) -> Result<std::collections::HashMap<ObjectId, crate::model::role::RoleState>> {
569 let mut roles = std::collections::HashMap::new();
570 let rows = client
571 .query(
572 "SELECT rolname, rolcanlogin, rolsuper FROM pg_roles ORDER BY rolname;",
573 &[],
574 )
575 .context("Failed to load role identities from pg_roles")?;
576 for row in rows {
577 let name: String = row.try_get(0).context("role name")?;
578 let id = ObjectId::new("", &name);
579 roles.insert(
580 id.clone(),
581 crate::model::role::RoleState {
582 id,
583 can_login: row.try_get(1).context("role login capability")?,
584 is_superuser: row.try_get(2).context("role superuser capability")?,
585 member_of: Vec::new(),
586 can_set_role_to: Vec::new(),
587 granted_privileges: Vec::new(),
588 },
589 );
590 }
591
592 let membership_query = if pg_version_num >= 160_000 {
593 "SELECT member.rolname, parent.rolname, membership.set_option
594 FROM pg_auth_members membership
595 JOIN pg_roles member ON member.oid = membership.member
596 JOIN pg_roles parent ON parent.oid = membership.roleid;"
597 } else {
598 "SELECT member.rolname, parent.rolname, true AS set_option
599 FROM pg_auth_members membership
600 JOIN pg_roles member ON member.oid = membership.member
601 JOIN pg_roles parent ON parent.oid = membership.roleid;"
602 };
603 let memberships = client
604 .query(membership_query, &[])
605 .context("Failed to load role memberships from pg_auth_members")?;
606 for row in memberships {
607 let member = ObjectId::new("", row.try_get::<_, String>(0).context("member role")?);
608 let parent = ObjectId::new("", row.try_get::<_, String>(1).context("parent role")?);
609 let set_option: bool = row.try_get(2).context("role membership SET option")?;
610 if let Some(role) = roles.get_mut(&member) {
611 role.member_of.push(parent.clone());
612 if set_option {
613 role.can_set_role_to.push(parent);
614 }
615 }
616 }
617 Ok(roles)
618}
619
620struct ProvenanceCatalog {
621 pg_version_num: u32,
622 metadata: crate::db::cache::CacheMetadata,
623 search_path: Vec<String>,
624}
625
626fn load_provenance(
627 client: &mut impl GenericClient,
628 schemas: Option<&[String]>,
629) -> Result<ProvenanceCatalog> {
630 let version_row = client
631 .query_one("SHOW server_version_num;", &[])
632 .context("Failed to load PostgreSQL server version")?;
633 let version_str: String = version_row
634 .try_get(0)
635 .context("PostgreSQL server version field")?;
636 let pg_version_num = version_str
637 .parse::<u32>()
638 .context("PostgreSQL returned an invalid server_version_num")?;
639 ensure_supported_postgres_version(pg_version_num)?;
640
641 let row = client
642 .query_one(
643 "SELECT current_database(), current_user, session_user, current_setting('search_path'),
644 (SELECT setting::bigint FROM pg_settings WHERE name = 'lock_timeout'),
645 (SELECT setting::bigint FROM pg_settings WHERE name = 'statement_timeout');",
646 &[],
647 )
648 .context("Failed to load synchronization provenance and timeout settings")?;
649 let search_path_setting: String = row
650 .try_get(3)
651 .context("synchronization provenance search_path")?;
652 let lock_timeout_ms = row
653 .try_get::<_, Option<i64>>(4)
654 .context("synchronization provenance lock_timeout field")?
655 .context("PostgreSQL did not report lock_timeout")?;
656 let statement_timeout_ms = row
657 .try_get::<_, Option<i64>>(5)
658 .context("synchronization provenance statement_timeout field")?
659 .context("PostgreSQL did not report statement_timeout")?;
660
661 let search_path_row = client
662 .query_one("SELECT current_schemas(false);", &[])
663 .context("Failed to load the effective PostgreSQL search path")?;
664 let effective_search_path = search_path_row
665 .try_get(0)
666 .context("effective PostgreSQL search path field")?;
667
668 Ok(ProvenanceCatalog {
669 pg_version_num,
670 metadata: crate::db::cache::CacheMetadata {
671 created_at_unix_secs: Some(
672 SystemTime::now()
673 .duration_since(UNIX_EPOCH)
674 .unwrap_or_default()
675 .as_secs(),
676 ),
677 source_database: Some(
678 row.try_get(0)
679 .context("synchronization provenance database field")?,
680 ),
681 source_role: Some(
682 row.try_get(1)
683 .context("synchronization provenance current-role field")?,
684 ),
685 source_session_role: Some(
686 row.try_get(2)
687 .context("synchronization provenance session-role field")?,
688 ),
689 source_search_path: Some(parse_search_path_setting(&search_path_setting)),
690 source_lock_timeout_ms: lock_timeout_ms
691 .try_into()
692 .context("PostgreSQL returned a negative lock_timeout")?,
693 source_statement_timeout_ms: statement_timeout_ms
694 .try_into()
695 .context("PostgreSQL returned a negative statement_timeout")?,
696 schemas: schemas.map(<[String]>::to_vec),
697 },
698 search_path: cache_search_path(effective_search_path, schemas),
699 })
700}
701
702fn load_schemas(
703 client: &mut impl GenericClient,
704 schema_values: &Option<Vec<String>>,
705 schema_filter: &str,
706) -> Result<std::collections::HashMap<String, crate::model::schema::SchemaState>> {
707 let query = format!(
708 "SELECT n.nspname, pg_catalog.pg_get_userbyid(n.nspowner)
709 FROM pg_namespace n
710 WHERE n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
711 AND n.nspname <> 'information_schema'
712 {schema_filter}
713 ORDER BY n.nspname;"
714 );
715 let rows = client
716 .query(&query, &[schema_values])
717 .context("Failed to load schemas from pg_namespace")?;
718 rows.into_iter()
719 .map(|row| {
720 let name: String = row.try_get(0).context("schema name")?;
721 let owner: String = row.try_get(1).context("schema owner")?;
722 Ok((
723 name.clone(),
724 crate::model::schema::SchemaState {
725 name,
726 owner: relation_owner_id(owner),
727 generation: 0,
728 },
729 ))
730 })
731 .collect()
732}
733
734fn load_sequences(
735 client: &mut impl GenericClient,
736 schema_values: &Option<Vec<String>>,
737) -> Result<std::collections::HashMap<ObjectId, crate::model::sequence::SequenceState>> {
738 let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1) OR tn.nspname = ANY($1))";
743 let query = format!(
744 "SELECT
745 n.nspname AS sequence_schema,
746 s.relname AS sequence_name,
747 pg_catalog.pg_get_userbyid(s.relowner) AS owner_name,
748 tn.nspname AS table_schema,
749 t.relname AS table_name,
750 a.attname AS column_name,
751 d.deptype::text AS dependency_type,
752 CASE WHEN ad.adbin IS NULL THEN false
753 ELSE pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval(%'
754 END AS has_nextval_default
755 FROM pg_class s
756 JOIN pg_namespace n ON n.oid = s.relnamespace
757 LEFT JOIN pg_depend d
758 ON d.classid = 'pg_class'::regclass
759 AND d.objid = s.oid
760 AND d.objsubid = 0
761 AND d.refclassid = 'pg_class'::regclass
762 AND d.deptype IN ('a', 'i')
763 LEFT JOIN pg_class t ON t.oid = d.refobjid
764 LEFT JOIN pg_namespace tn ON tn.oid = t.relnamespace
765 LEFT JOIN pg_attribute a
766 ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
767 LEFT JOIN pg_attrdef ad
768 ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
769 WHERE s.relkind = 'S'
770 AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
771 AND n.nspname <> 'information_schema'
772 {schema_filter}
773 ORDER BY n.nspname, s.relname;"
774 );
775 let rows = client
776 .query(&query, &[schema_values])
777 .context("Failed to load sequences and ownership from pg_class/pg_depend")?;
778 rows.into_iter()
779 .map(|row| {
780 let id = ObjectId::new(
781 row.try_get::<_, String>(0).context("sequence schema")?,
782 row.try_get::<_, String>(1).context("sequence name")?,
783 );
784 let owner = relation_owner_id(row.try_get::<_, String>(2).context("sequence owner")?);
785 let table_schema: Option<String> =
786 row.try_get(3).context("sequence owner table schema")?;
787 let table_name: Option<String> = row.try_get(4).context("sequence owner table name")?;
788 let column_name: Option<String> =
789 row.try_get(5).context("sequence owner column name")?;
790 let dependency_type: Option<String> =
791 row.try_get(6).context("sequence dependency type")?;
792 let has_nextval_default: bool =
793 row.try_get(7).context("sequence-backed default marker")?;
794 let owned_by = table_schema
795 .zip(table_name)
796 .zip(column_name)
797 .map(|((schema, table), column)| (ObjectId::new(schema, table), column));
798 let kind = sequence_kind_from_pg(dependency_type.as_deref(), has_nextval_default)
799 .with_context(|| format!("sequence '{}' dependency kind", id))?;
800 Ok((
801 id.clone(),
802 crate::model::sequence::SequenceState {
803 id,
804 owner,
805 owned_by,
806 kind,
807 generation: 0,
808 },
809 ))
810 })
811 .collect()
812}
813
814fn load_relations_and_columns(
815 client: &mut impl GenericClient,
816 schemas: Option<&[String]>,
817 schema_values: &Option<Vec<String>>,
818 schema_filter_with_fk: &str,
819) -> Result<std::collections::HashMap<ObjectId, RelationState>> {
820 let relation_query = format!(
821 "
822 SELECT
823 n.nspname AS schema_name,
824 c.relname AS relation_name,
825 c.relkind AS relation_kind,
826 c.relpersistence AS persistence,
827 pg_catalog.pg_get_userbyid(c.relowner) AS owner_name,
828 CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
829 c.relpages::bigint AS relpages,
830 to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
831 to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
832 p.partstrat::text AS partition_strategy
833 FROM pg_class c
834 JOIN pg_namespace n ON n.oid = c.relnamespace
835 LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
836 LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
837 WHERE c.relkind IN ('r', 'p', 'v', 'm')
838 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
839 {schema_filter_with_fk};
840 "
841 );
842 let rows = client
843 .query(&relation_query, &[schema_values])
844 .context("Failed to load relations and statistics from pg_class")?;
845 let mut relations = std::collections::HashMap::new();
846 for row in rows {
847 let schema_name: String = row.try_get("schema_name").context("relation schema")?;
848 let relation_name: String = row.try_get("relation_name").context("relation name")?;
849 let relkind: i8 = row.try_get("relation_kind").context("relation kind")?;
850 let persistence_char: i8 = row.try_get("persistence").context("relation persistence")?;
851 let owner_name: String = row.try_get("owner_name").context("relation owner")?;
852 let raw_rows: i64 = row
853 .try_get("estimated_rows")
854 .context("relation estimated row count")?;
855 let relpages: i64 = row.try_get("relpages").context("relation page count")?;
856 let last_analyze: Option<String> = row
857 .try_get("last_analyze")
858 .context("relation last-analyze timestamp")?;
859 let last_autoanalyze: Option<String> = row
860 .try_get("last_autoanalyze")
861 .context("relation last-autoanalyze timestamp")?;
862
863 let object_id = ObjectId::new(&schema_name, &relation_name);
864 let kind = relation_kind_from_pg(relkind as u8)
865 .with_context(|| format!("relation '{}' kind", object_id))?;
866 let persistence = persistence_from_pg(persistence_char as u8)
867 .with_context(|| format!("relation '{}' persistence", object_id))?;
868 let estimated_rows = if raw_rows < 0 {
869 None
870 } else {
871 Some(raw_rows as u64)
872 };
873 let mut state = RelationState::new(
874 object_id.clone(),
875 relation_owner_id(owner_name),
876 0,
877 estimated_rows,
878 kind,
879 persistence,
880 0,
881 );
882 state.relpages = Some(
883 relpages
884 .try_into()
885 .with_context(|| format!("relation '{}' has a negative page count", object_id))?,
886 );
887 state.last_analyze = last_analyze;
888 state.last_autoanalyze = last_autoanalyze;
889 let partition_strategy: Option<String> = row
890 .try_get("partition_strategy")
891 .context("relation partition strategy")?;
892 state.partition_type = partition_strategy_from_pg(partition_strategy.as_deref())
893 .with_context(|| format!("relation '{}' partition strategy", object_id))?;
894 if let Some(scoped_schemas) = schemas
895 && !scoped_schemas.contains(&schema_name)
896 {
897 state.mark_fk_dependency();
898 }
899 relations.insert(object_id, state);
900 }
901
902 let column_query = format!(
903 "
904 SELECT
905 n.nspname AS schema_name,
906 c.relname AS relation_name,
907 a.attname AS column_name,
908 pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
909 a.attnotnull AS not_null,
910 s.avg_width AS avg_width,
911 pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
912 a.atttypmod AS type_modifier
913 FROM pg_attribute a
914 JOIN pg_class c ON a.attrelid = c.oid
915 JOIN pg_namespace n ON n.oid = c.relnamespace
916 LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
917 LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
918 WHERE a.attnum > 0 AND NOT a.attisdropped
919 AND c.relkind IN ('r', 'p', 'v', 'm')
920 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
921 {schema_filter_with_fk}
922 ORDER BY n.nspname, c.relname;
923 "
924 );
925 let rows = client
926 .query(&column_query, &[schema_values])
927 .context("Failed to load relation columns from pg_attribute")?;
928 for row in rows {
929 let relation_id = ObjectId::new(
930 row.try_get::<_, String>("schema_name")
931 .context("column relation schema")?,
932 row.try_get::<_, String>("relation_name")
933 .context("column relation name")?,
934 );
935 let relation = relations.get_mut(&relation_id).with_context(|| {
936 format!(
937 "column catalog row references relation '{}' omitted by the relation loader",
938 relation_id
939 )
940 })?;
941 relation.columns.push(crate::model::column::Column {
942 name: row.try_get("column_name").context("column name")?,
943 data_type: Some(row.try_get("type_name").context("column type")?),
944 type_id: None,
945 is_nullable: !row
946 .try_get::<_, bool>("not_null")
947 .context("column nullability")?,
948 default: None,
949 avg_width: row.try_get("avg_width").context("column average width")?,
950 default_expr_text: row
951 .try_get("default_expr_text")
952 .context("column default expression")?,
953 type_modifier: row
954 .try_get("type_modifier")
955 .context("column type modifier")?,
956 });
957 }
958 Ok(relations)
959}
960
961struct RelationDecoration {
962 relation_id: ObjectId,
963 triggers: Vec<String>,
964 policies: Vec<String>,
965}
966
967struct RelationGrant {
968 relation_id: ObjectId,
969 grantee: ObjectId,
970 privilege: crate::model::relation::Privilege,
971}
972
973fn load_relation_decorations(
974 client: &mut impl GenericClient,
975 schema_values: &Option<Vec<String>>,
976 schema_filter_with_fk: &str,
977) -> Result<(Vec<RelationDecoration>, Vec<RelationGrant>)> {
978 let topology_query = format!(
979 "
980 SELECT
981 n.nspname AS schema_name,
982 c.relname AS relation_name,
983 COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
984 COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
985 FROM pg_class c
986 JOIN pg_namespace n ON n.oid = c.relnamespace
987 LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
988 LEFT JOIN pg_policy p ON p.polrelid = c.oid
989 WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
990 {schema_filter_with_fk}
991 GROUP BY n.nspname, c.relname;
992 "
993 );
994 let decorations = client
995 .query(&topology_query, &[schema_values])
996 .context("Failed to load relation triggers and policies")?
997 .into_iter()
998 .map(|row| {
999 Ok(RelationDecoration {
1000 relation_id: ObjectId::new(
1001 row.try_get::<_, String>("schema_name")
1002 .context("decorated relation schema")?,
1003 row.try_get::<_, String>("relation_name")
1004 .context("decorated relation name")?,
1005 ),
1006 triggers: row.try_get("triggers").context("relation trigger names")?,
1007 policies: row.try_get("policies").context("relation policy names")?,
1008 })
1009 })
1010 .collect::<Result<Vec<_>>>()?;
1011
1012 let acl_query = format!(
1013 "
1014 SELECT
1015 n.nspname AS schema_name,
1016 c.relname AS relation_name,
1017 CASE
1018 WHEN acl.grantee = 0 THEN 'public'
1019 ELSE pg_catalog.pg_get_userbyid(acl.grantee)
1020 END AS grantee,
1021 acl.privilege_type
1022 FROM pg_class c
1023 JOIN pg_namespace n ON n.oid = c.relnamespace
1024 CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
1025 WHERE c.relkind IN ('r', 'p', 'v', 'm')
1026 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
1027 AND acl.grantee <> c.relowner
1028 {schema_filter_with_fk};
1029 "
1030 );
1031 let grants = client
1032 .query(&acl_query, &[schema_values])
1033 .context("Failed to load explicit relation privileges")?
1034 .into_iter()
1035 .map(|row| {
1036 let privilege_type: String = row
1037 .try_get("privilege_type")
1038 .context("relation privilege type")?;
1039 let privilege = match privilege_type.as_str() {
1040 "SELECT" => crate::model::relation::Privilege::Select,
1041 "INSERT" => crate::model::relation::Privilege::Insert,
1042 "UPDATE" => crate::model::relation::Privilege::Update,
1043 "DELETE" => crate::model::relation::Privilege::Delete,
1044 "TRUNCATE" => crate::model::relation::Privilege::Truncate,
1045 "REFERENCES" => crate::model::relation::Privilege::References,
1046 "TRIGGER" => crate::model::relation::Privilege::Trigger,
1047 "MAINTAIN" => crate::model::relation::Privilege::Maintain,
1048 other => anyhow::bail!("unsupported relation privilege type '{other}'"),
1049 };
1050 Ok(RelationGrant {
1051 relation_id: ObjectId::new(
1052 row.try_get::<_, String>("schema_name")
1053 .context("privileged relation schema")?,
1054 row.try_get::<_, String>("relation_name")
1055 .context("privileged relation name")?,
1056 ),
1057 grantee: ObjectId::new(
1058 "",
1059 row.try_get::<_, String>("grantee")
1060 .context("relation privilege grantee")?,
1061 ),
1062 privilege,
1063 })
1064 })
1065 .collect::<Result<Vec<_>>>()?;
1066 Ok((decorations, grants))
1067}
1068
1069fn load_triggers(
1070 client: &mut impl GenericClient,
1071 schema_values: &Option<Vec<String>>,
1072 schema_filter_with_fk: &str,
1073) -> Result<Vec<crate::db::cache::TriggerCache>> {
1074 let query = format!(
1075 "
1076 SELECT
1077 n.nspname AS table_schema,
1078 c.relname AS table_name,
1079 t.tgname AS trigger_name,
1080 t.tgenabled::text AS enabled_mode,
1081 fn.nspname AS function_schema,
1082 f.proname || '()' AS function_name
1083 FROM pg_trigger t
1084 JOIN pg_class c ON c.oid = t.tgrelid
1085 JOIN pg_namespace n ON n.oid = c.relnamespace
1086 JOIN pg_proc f ON f.oid = t.tgfoid
1087 JOIN pg_namespace fn ON fn.oid = f.pronamespace
1088 WHERE t.tgisinternal = false
1089 AND c.relkind IN ('r', 'p', 'v', 'm')
1090 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
1091 {schema_filter_with_fk};
1092 "
1093 );
1094 client
1095 .query(&query, &[schema_values])
1096 .context("Failed to load triggers and trigger functions")?
1097 .into_iter()
1098 .map(|row| {
1099 let table_schema: String = row.try_get("table_schema").context("trigger schema")?;
1100 let enabled_mode: String = row
1101 .try_get("enabled_mode")
1102 .context("trigger enabled mode")?;
1103 Ok(crate::db::cache::TriggerCache {
1104 trigger_id: ObjectId::new(
1105 &table_schema,
1106 row.try_get::<_, String>("trigger_name")
1107 .context("trigger name")?,
1108 ),
1109 table_id: ObjectId::new(
1110 &table_schema,
1111 row.try_get::<_, String>("table_name")
1112 .context("trigger table name")?,
1113 ),
1114 function_id: ObjectId::new(
1115 row.try_get::<_, String>("function_schema")
1116 .context("trigger function schema")?,
1117 row.try_get::<_, String>("function_name")
1118 .context("trigger function name")?,
1119 ),
1120 enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode)
1121 .ok_or_else(|| {
1122 anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}")
1123 })?,
1124 })
1125 })
1126 .collect()
1127}
1128
1129fn load_constraints(
1130 client: &mut impl GenericClient,
1131 schema_values: &Option<Vec<String>>,
1132 schema_filter_with_fk: &str,
1133) -> Result<Vec<crate::model::constraint::ConstraintState>> {
1134 let query = format!(
1135 "
1136 SELECT
1137 n.nspname AS table_schema,
1138 c.relname AS table_name,
1139 con.conname AS constraint_name,
1140 con.contype::text AS constraint_type,
1141 con.convalidated AS validated
1142 FROM pg_constraint con
1143 JOIN pg_class c ON c.oid = con.conrelid
1144 JOIN pg_namespace n ON n.oid = c.relnamespace
1145 WHERE con.contype IN ('c', 'f', 'p', 'u', 'x')
1146 AND c.relkind IN ('r', 'p', 'v', 'm')
1147 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
1148 {schema_filter_with_fk};
1149 "
1150 );
1151 client
1152 .query(&query, &[schema_values])
1153 .context("Failed to load table constraints from pg_constraint")?
1154 .into_iter()
1155 .map(|row| {
1156 let constraint_type: String =
1157 row.try_get("constraint_type").context("constraint type")?;
1158 let kind = match constraint_type.as_str() {
1159 "c" => crate::model::constraint::ConstraintKind::Check,
1160 "f" => crate::model::constraint::ConstraintKind::ForeignKey,
1161 "p" => crate::model::constraint::ConstraintKind::PrimaryKey,
1162 "u" => crate::model::constraint::ConstraintKind::Unique,
1163 "x" => crate::model::constraint::ConstraintKind::Exclusion,
1164 other => anyhow::bail!("unsupported pg_constraint.contype '{other}'"),
1165 };
1166 Ok(crate::model::constraint::ConstraintState {
1167 table_id: ObjectId::new(
1168 row.try_get::<_, String>("table_schema")
1169 .context("constraint table schema")?,
1170 row.try_get::<_, String>("table_name")
1171 .context("constraint table name")?,
1172 ),
1173 name: row.try_get("constraint_name").context("constraint name")?,
1174 kind,
1175 validated: row
1176 .try_get("validated")
1177 .context("constraint validation state")?,
1178 })
1179 })
1180 .collect()
1181}
1182
1183fn load_foreign_keys(
1184 client: &mut impl GenericClient,
1185 schemas: Option<&[String]>,
1186 schema_values: &Option<Vec<String>>,
1187 schema_filter_n1_or_n2: &str,
1188) -> Result<Vec<ForeignKeyCache>> {
1189 let query = format!(
1190 "
1191 SELECT
1192 c.conname AS constraint_name,
1193 n1.nspname AS from_schema, t1.relname AS from_table,
1194 n2.nspname AS to_schema, t2.relname AS to_table
1195 FROM pg_constraint c
1196 JOIN pg_class t1 ON t1.oid = c.conrelid
1197 JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
1198 JOIN pg_class t2 ON t2.oid = c.confrelid
1199 JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
1200 WHERE c.contype = 'f'
1201 {schema_filter_n1_or_n2};
1202 "
1203 );
1204 client
1205 .query(&query, &[schema_values])
1206 .context("Failed to load foreign keys from pg_constraint")?
1207 .into_iter()
1208 .map(|row| {
1209 let constraint_name: String = row
1210 .try_get("constraint_name")
1211 .context("foreign-key constraint name")?;
1212 let from_schema: String = row
1213 .try_get("from_schema")
1214 .context("foreign-key source schema")?;
1215 let from_table: String = row
1216 .try_get("from_table")
1217 .context("foreign-key source table")?;
1218 let to_schema: String = row
1219 .try_get("to_schema")
1220 .context("foreign-key target schema")?;
1221 let to_table: String = row
1222 .try_get("to_table")
1223 .context("foreign-key target table")?;
1224 if let Some(scoped_schemas) = schemas
1225 && (!scoped_schemas.contains(&from_schema)
1226 || !scoped_schemas.contains(&to_schema))
1227 {
1228 let (out_of_scope_schema, out_of_scope_table) =
1229 if !scoped_schemas.contains(&from_schema) {
1230 (&from_schema, &from_table)
1231 } else {
1232 (&to_schema, &to_table)
1233 };
1234 eprintln!(
1235 "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
1236 constraint_name, out_of_scope_schema, out_of_scope_table
1237 );
1238 }
1239 Ok(ForeignKeyCache {
1240 constraint_name,
1241 from_table: ObjectId::new(from_schema, from_table),
1242 to_table: ObjectId::new(to_schema, to_table),
1243 })
1244 })
1245 .collect()
1246}
1247
1248fn load_indexes(
1249 client: &mut impl GenericClient,
1250 schema_values: &Option<Vec<String>>,
1251 schema_filter_nt: &str,
1252) -> Result<Vec<IndexCache>> {
1253 let query = format!(
1254 "
1255 SELECT
1256 n_i.nspname AS index_schema, i.relname AS index_name,
1257 n_t.nspname AS table_schema, t.relname AS table_name
1258 FROM pg_index x
1259 JOIN pg_class i ON i.oid = x.indexrelid
1260 JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
1261 JOIN pg_class t ON t.oid = x.indrelid
1262 JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
1263 WHERE x.indisvalid = true
1264 AND n_i.nspname !~ '^pg_'
1265 AND n_i.nspname <> 'information_schema'
1266 AND n_t.nspname !~ '^pg_'
1267 AND n_t.nspname <> 'information_schema'
1268 {schema_filter_nt};
1269 "
1270 );
1271 let rows = client
1272 .query(&query, &[schema_values])
1273 .context("Failed to load valid indexes from pg_index")?;
1274 let mut indexes = Vec::with_capacity(rows.len());
1275 for row in rows {
1276 let index_schema: String = row.try_get("index_schema").context("index schema")?;
1277 let table_schema: String = row
1278 .try_get("table_schema")
1279 .context("indexed table schema")?;
1280 if is_system_schema(&index_schema) || is_system_schema(&table_schema) {
1281 continue;
1282 }
1283 indexes.push(IndexCache {
1284 index_id: ObjectId::new(
1285 index_schema,
1286 row.try_get::<_, String>("index_name")
1287 .context("index name")?,
1288 ),
1289 table_id: ObjectId::new(
1290 table_schema,
1291 row.try_get::<_, String>("table_name")
1292 .context("indexed table name")?,
1293 ),
1294 });
1295 }
1296 Ok(indexes)
1297}
1298
1299fn load_routines(
1300 client: &mut impl GenericClient,
1301 schema_values: &Option<Vec<String>>,
1302 schema_filter: &str,
1303) -> Result<std::collections::HashMap<ObjectId, crate::model::function::FunctionState>> {
1304 let query = format!(
1305 "
1306 SELECT
1307 n.nspname AS schema_name,
1308 p.proname AS func_name,
1309 ARRAY(
1310 SELECT pg_catalog.format_type(t, NULL)
1311 FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS u(t, n)
1312 ORDER BY n
1313 )::text[] AS arg_types,
1314 pg_catalog.pg_get_function_result(p.oid) AS return_type,
1315 p.provolatile::text AS volatility,
1316 p.prokind::text AS routine_kind,
1317 l.lanname AS language,
1318 p.prosecdef AS security_definer
1319 FROM pg_proc p
1320 JOIN pg_namespace n ON n.oid = p.pronamespace
1321 JOIN pg_language l ON l.oid = p.prolang
1322 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
1323 AND p.prokind IN ('f', 'p', 'a', 'w')
1324 {schema_filter};
1325 "
1326 );
1327 client
1328 .query(&query, &[schema_values])
1329 .context("Failed to load routines from pg_proc")?
1330 .into_iter()
1331 .map(|row| {
1332 let schema_name: String = row.try_get("schema_name").context("routine schema")?;
1333 let function_name: String = row.try_get("func_name").context("routine name")?;
1334 let raw_arg_types: Vec<String> =
1335 row.try_get("arg_types").context("routine argument types")?;
1336 let volatility_code: String =
1337 row.try_get("volatility").context("routine volatility")?;
1338 let volatility = routine_volatility_from_pg(&volatility_code)?;
1339 let routine_kind_code: String = row.try_get("routine_kind").context("routine kind")?;
1340 let routine_kind = routine_kind_from_pg(&routine_kind_code)?;
1341 let arg_types = raw_arg_types
1342 .iter()
1343 .map(|arg_type| {
1344 crate::analysis::resolver::Resolver::normalize_function_arg_type(arg_type)
1345 })
1346 .collect::<Vec<_>>();
1347 let id = ObjectId::new(
1348 schema_name,
1349 format!("{}({})", function_name, arg_types.join(",")),
1350 );
1351 let security_definer: bool = row
1352 .try_get("security_definer")
1353 .context("routine security mode")?;
1354 Ok((
1355 id.clone(),
1356 crate::model::function::FunctionState {
1357 id,
1358 routine_kind,
1359 arg_types,
1360 arg_type_ids: Vec::new(),
1361 return_type: row
1362 .try_get::<_, Option<String>>("return_type")
1363 .context("routine return type")?
1364 .unwrap_or_default(),
1365 return_type_id: None,
1366 volatility,
1367 language: row.try_get("language").context("routine language")?,
1368 security: if security_definer {
1369 crate::model::function::SecurityMode::Definer
1370 } else {
1371 crate::model::function::SecurityMode::Invoker
1372 },
1373 },
1374 ))
1375 })
1376 .collect()
1377}
1378
1379fn load_types(
1380 client: &mut impl GenericClient,
1381 schema_values: &Option<Vec<String>>,
1382 schema_filter: &str,
1383) -> Result<std::collections::HashMap<ObjectId, crate::model::types::TypeState>> {
1384 let query = format!(
1385 "
1386 SELECT
1387 n.nspname AS schema_name,
1388 t.typname AS type_name,
1389 t.typtype::text AS type_kind,
1390 CASE WHEN t.typtype = 'd'
1391 THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
1392 ELSE NULL
1393 END AS domain_base_type,
1394 COALESCE(
1395 array_agg(e.enumlabel ORDER BY e.enumsortorder)
1396 FILTER (WHERE e.enumlabel IS NOT NULL),
1397 ARRAY[]::text[]
1398 ) AS enum_labels
1399 FROM pg_type t
1400 JOIN pg_namespace n ON n.oid = t.typnamespace
1401 LEFT JOIN pg_enum e ON e.enumtypid = t.oid
1402 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
1403 AND t.typtype IN ('e', 'd')
1404 {schema_filter}
1405 GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod;
1406 "
1407 );
1408 client
1409 .query(&query, &[schema_values])
1410 .context("Failed to load user-defined enum and domain types")?
1411 .into_iter()
1412 .map(|row| {
1413 let type_kind: String = row.try_get("type_kind").context("type kind")?;
1414 let kind = match type_kind.as_str() {
1415 "e" => crate::model::types::TypeKind::Enum {
1416 variants: row.try_get("enum_labels").context("enum labels")?,
1417 },
1418 "d" => crate::model::types::TypeKind::Domain {
1419 base_type: row
1420 .try_get::<_, Option<String>>("domain_base_type")
1421 .context("domain base type")?
1422 .context("PostgreSQL omitted the base type for a domain")?,
1423 base_type_id: None,
1424 },
1425 other => anyhow::bail!("unsupported pg_type.typtype '{other}'"),
1426 };
1427 let id = ObjectId::new(
1428 row.try_get::<_, String>("schema_name")
1429 .context("type schema")?,
1430 row.try_get::<_, String>("type_name").context("type name")?,
1431 );
1432 Ok((
1433 id.clone(),
1434 crate::model::types::TypeState {
1435 id,
1436 generation: 0,
1437 kind,
1438 },
1439 ))
1440 })
1441 .collect()
1442}
1443
1444fn load_publications(
1445 client: &mut impl GenericClient,
1446 pg_version_num: u32,
1447) -> Result<std::collections::HashMap<String, crate::model::replication::PublicationState>> {
1448 let publication_query = if pg_version_num >= 180_000 {
1449 r#"
1450 SELECT p.oid, p.pubname::text AS publication_name,
1451 pg_catalog.pg_get_userbyid(p.pubowner) AS owner_name,
1452 p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete,
1453 p.pubtruncate, p.pubviaroot, p.pubgencols::text AS generated_columns
1454 FROM pg_publication p
1455 ORDER BY p.oid
1456 "#
1457 } else {
1458 r#"
1459 SELECT p.oid, p.pubname::text AS publication_name,
1460 pg_catalog.pg_get_userbyid(p.pubowner) AS owner_name,
1461 p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete,
1462 p.pubtruncate, p.pubviaroot, NULL::text AS generated_columns
1463 FROM pg_publication p
1464 ORDER BY p.oid
1465 "#
1466 };
1467 let rows = client
1468 .query(publication_query, &[])
1469 .context("Failed to load publications from pg_publication")?;
1470 let mut names_by_oid = std::collections::HashMap::<u32, String>::new();
1471 let mut publications = std::collections::HashMap::new();
1472 for row in rows {
1473 let oid: u32 = row.try_get("oid").context("publication OID")?;
1474 let name: String = row
1475 .try_get("publication_name")
1476 .context("publication name")?;
1477 let mut operations = Vec::new();
1478 for (field, operation) in [
1479 ("pubinsert", "insert"),
1480 ("pubupdate", "update"),
1481 ("pubdelete", "delete"),
1482 ("pubtruncate", "truncate"),
1483 ] {
1484 if row
1485 .try_get::<_, bool>(field)
1486 .with_context(|| format!("publication '{name}' {field}"))?
1487 {
1488 operations.push(operation);
1489 }
1490 }
1491 let mut params = vec![
1492 crate::analysis::facts::AttributeFact {
1493 name: "publish".to_string(),
1494 value: operations.join(", "),
1495 },
1496 crate::analysis::facts::AttributeFact {
1497 name: "publish_via_partition_root".to_string(),
1498 value: row
1499 .try_get::<_, bool>("pubviaroot")
1500 .context("publication partition-root mode")?
1501 .to_string(),
1502 },
1503 ];
1504 if let Some(generated_columns) = row
1505 .try_get::<_, Option<String>>("generated_columns")
1506 .context("publication generated-column mode")?
1507 {
1508 let value = match generated_columns.as_str() {
1509 "n" => "none",
1510 "s" => "stored",
1511 other => anyhow::bail!(
1512 "publication '{name}' has unknown generated-column mode '{other}'"
1513 ),
1514 };
1515 params.push(crate::analysis::facts::AttributeFact {
1516 name: "publish_generated_columns".to_string(),
1517 value: value.to_string(),
1518 });
1519 }
1520 let scope = if row
1521 .try_get::<_, bool>("puballtables")
1522 .context("publication all-tables mode")?
1523 {
1524 crate::analysis::facts::PublicationScope::AllTables { except: Vec::new() }
1525 } else {
1526 crate::analysis::facts::PublicationScope::Explicit(Vec::new())
1527 };
1528 names_by_oid.insert(oid, name.clone());
1529 publications.insert(
1530 name.clone(),
1531 crate::model::replication::PublicationState {
1532 name,
1533 owner: Some(row.try_get("owner_name").context("publication owner")?),
1534 scope,
1535 params,
1536 generation: 0,
1537 },
1538 );
1539 }
1540
1541 let relation_query = if pg_version_num >= 150_000 {
1542 r#"
1543 SELECT pr.prpubid, n.nspname::text AS schema_name,
1544 c.relname::text AS relation_name,
1545 pg_catalog.pg_get_expr(pr.prqual, pr.prrelid) AS row_filter,
1546 CASE WHEN pr.prattrs IS NULL THEN NULL ELSE ARRAY(
1547 SELECT a.attname::text
1548 FROM pg_attribute a
1549 WHERE a.attrelid = pr.prrelid
1550 AND a.attnum = ANY(pr.prattrs::smallint[])
1551 ORDER BY array_position(pr.prattrs::smallint[], a.attnum)
1552 ) END AS columns
1553 FROM pg_publication_rel pr
1554 JOIN pg_class c ON c.oid = pr.prrelid
1555 JOIN pg_namespace n ON n.oid = c.relnamespace
1556 ORDER BY pr.prpubid, pr.oid
1557 "#
1558 } else {
1559 r#"
1560 SELECT pr.prpubid, n.nspname::text AS schema_name,
1561 c.relname::text AS relation_name,
1562 NULL::text AS row_filter, NULL::text[] AS columns
1563 FROM pg_publication_rel pr
1564 JOIN pg_class c ON c.oid = pr.prrelid
1565 JOIN pg_namespace n ON n.oid = c.relnamespace
1566 ORDER BY pr.prpubid, pr.oid
1567 "#
1568 };
1569 for row in client
1570 .query(relation_query, &[])
1571 .context("Failed to load publication relation membership")?
1572 {
1573 let oid: u32 = row.try_get("prpubid").context("publication relation OID")?;
1574 let name = names_by_oid.get(&oid).with_context(|| {
1575 format!("publication relation membership references unknown publication OID {oid}")
1576 })?;
1577 let publication = publications
1578 .get_mut(name)
1579 .with_context(|| format!("publication '{name}' disappeared during assembly"))?;
1580 let crate::analysis::facts::PublicationScope::Explicit(objects) = &mut publication.scope
1581 else {
1582 continue;
1583 };
1584 objects.push(crate::analysis::facts::PublicationObjectFact::Table {
1585 name: crate::ast::identifiers::QualifiedName::new(
1586 Some(crate::ast::identifiers::Ident::new(
1587 row.try_get::<_, String>("schema_name")
1588 .context("publication relation schema")?,
1589 true,
1590 )),
1591 crate::ast::identifiers::Ident::new(
1592 row.try_get::<_, String>("relation_name")
1593 .context("publication relation name")?,
1594 true,
1595 ),
1596 ),
1597 only: true,
1598 include_partitions: false,
1599 columns: row
1600 .try_get("columns")
1601 .context("publication relation column list")?,
1602 row_filter: row
1603 .try_get::<_, Option<String>>("row_filter")
1604 .context("publication relation row filter")?
1605 .map(crate::analysis::facts::PublicationRowFilter::CatalogSql),
1606 });
1607 }
1608
1609 if pg_version_num >= 150_000 {
1610 for row in client
1611 .query(
1612 r#"
1613 SELECT pn.pnpubid, n.nspname::text AS schema_name
1614 FROM pg_publication_namespace pn
1615 JOIN pg_namespace n ON n.oid = pn.pnnspid
1616 ORDER BY pn.pnpubid, pn.oid
1617 "#,
1618 &[],
1619 )
1620 .context("Failed to load publication schema membership")?
1621 {
1622 let oid: u32 = row.try_get("pnpubid").context("publication schema OID")?;
1623 let name = names_by_oid.get(&oid).with_context(|| {
1624 format!("publication schema membership references unknown publication OID {oid}")
1625 })?;
1626 let publication = publications
1627 .get_mut(name)
1628 .with_context(|| format!("publication '{name}' disappeared during assembly"))?;
1629 let crate::analysis::facts::PublicationScope::Explicit(objects) =
1630 &mut publication.scope
1631 else {
1632 continue;
1633 };
1634 objects.push(
1635 crate::analysis::facts::PublicationObjectFact::SchemaTables {
1636 schema: row
1637 .try_get("schema_name")
1638 .context("publication member schema name")?,
1639 row_filter: None,
1640 },
1641 );
1642 }
1643 }
1644 Ok(publications)
1645}
1646
1647fn load_subscriptions(
1648 client: &mut impl GenericClient,
1649 pg_version_num: u32,
1650) -> Result<std::collections::HashMap<String, crate::model::replication::SubscriptionState>> {
1651 let query = match pg_version_num {
1653 170_000.. => {
1654 r#"
1655 SELECT s.subname::text AS subscription_name,
1656 pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1657 s.subenabled, s.subbinary, s.subslotname::text,
1658 s.subsynccommit, s.subpublications,
1659 s.substream::text AS streaming,
1660 s.subtwophasestate::text AS two_phase_state,
1661 s.subdisableonerr AS disable_on_error,
1662 s.subpasswordrequired AS password_required,
1663 s.subrunasowner AS run_as_owner,
1664 s.subfailover AS failover,
1665 s.suborigin AS origin,
1666 s.subskiplsn::text AS skip_lsn
1667 FROM pg_subscription s
1668 WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1669 ORDER BY s.oid
1670 "#
1671 }
1672 160_000.. => {
1673 r#"
1674 SELECT s.subname::text AS subscription_name,
1675 pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1676 s.subenabled, s.subbinary, s.subslotname::text,
1677 s.subsynccommit, s.subpublications,
1678 s.substream::text AS streaming,
1679 s.subtwophasestate::text AS two_phase_state,
1680 s.subdisableonerr AS disable_on_error,
1681 s.subpasswordrequired AS password_required,
1682 s.subrunasowner AS run_as_owner,
1683 NULL::bool AS failover,
1684 s.suborigin AS origin,
1685 s.subskiplsn::text AS skip_lsn
1686 FROM pg_subscription s
1687 WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1688 ORDER BY s.oid
1689 "#
1690 }
1691 150_000.. => {
1692 r#"
1693 SELECT s.subname::text AS subscription_name,
1694 pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1695 s.subenabled, s.subbinary, s.subslotname::text,
1696 s.subsynccommit, s.subpublications,
1697 s.substream::text AS streaming,
1698 s.subtwophasestate::text AS two_phase_state,
1699 s.subdisableonerr AS disable_on_error,
1700 NULL::bool AS password_required,
1701 NULL::bool AS run_as_owner,
1702 NULL::bool AS failover,
1703 NULL::text AS origin,
1704 s.subskiplsn::text AS skip_lsn
1705 FROM pg_subscription s
1706 WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1707 ORDER BY s.oid
1708 "#
1709 }
1710 _ => {
1711 r#"
1712 SELECT s.subname::text AS subscription_name,
1713 pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1714 s.subenabled, s.subbinary, s.subslotname::text,
1715 s.subsynccommit, s.subpublications,
1716 s.substream::text AS streaming,
1717 NULL::text AS two_phase_state,
1718 NULL::bool AS disable_on_error,
1719 NULL::bool AS password_required,
1720 NULL::bool AS run_as_owner,
1721 NULL::bool AS failover,
1722 NULL::text AS origin,
1723 NULL::text AS skip_lsn
1724 FROM pg_subscription s
1725 WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1726 ORDER BY s.oid
1727 "#
1728 }
1729 };
1730 client
1731 .query(query, &[])
1732 .context("Failed to load non-secret subscription metadata")?
1733 .into_iter()
1734 .map(|row| {
1735 let name: String = row
1736 .try_get("subscription_name")
1737 .context("subscription name")?;
1738 let streaming_code: String = row
1739 .try_get("streaming")
1740 .with_context(|| format!("subscription '{name}' streaming mode"))?;
1741 let streaming = subscription_streaming_from_pg(&streaming_code)
1742 .with_context(|| format!("subscription '{name}' streaming mode"))?;
1743 let mut params = vec![
1744 crate::analysis::facts::AttributeFact {
1745 name: "binary".to_string(),
1746 value: row
1747 .try_get::<_, bool>("subbinary")
1748 .with_context(|| format!("subscription '{name}' binary mode"))?
1749 .to_string(),
1750 },
1751 crate::analysis::facts::AttributeFact {
1752 name: "streaming".to_string(),
1753 value: streaming.to_string(),
1754 },
1755 crate::analysis::facts::AttributeFact {
1756 name: "synchronous_commit".to_string(),
1757 value: row
1758 .try_get("subsynccommit")
1759 .with_context(|| format!("subscription '{name}' synchronous_commit"))?,
1760 },
1761 ];
1762 let two_phase = row
1763 .try_get::<_, Option<String>>("two_phase_state")
1764 .with_context(|| format!("subscription '{name}' two-phase state"))?
1765 .map(|state| subscription_two_phase_from_pg(&state).map(str::to_string))
1766 .transpose()?;
1767 let mut push_param = |param_name: &str, value: Option<String>| {
1768 if let Some(value) = value {
1769 params.push(crate::analysis::facts::AttributeFact {
1770 name: param_name.to_string(),
1771 value,
1772 });
1773 }
1774 };
1775 push_param("two_phase", two_phase);
1776 for (field, param_name) in [
1777 ("disable_on_error", "disable_on_error"),
1778 ("password_required", "password_required"),
1779 ("run_as_owner", "run_as_owner"),
1780 ("failover", "failover"),
1781 ] {
1782 push_param(
1783 param_name,
1784 row.try_get::<_, Option<bool>>(field)
1785 .with_context(|| format!("subscription '{name}' {field}"))?
1786 .map(|value| value.to_string()),
1787 );
1788 }
1789 push_param(
1790 "origin",
1791 row.try_get("origin")
1792 .with_context(|| format!("subscription '{name}' origin"))?,
1793 );
1794 push_param(
1795 "skip_lsn",
1796 row.try_get::<_, Option<String>>("skip_lsn")
1797 .with_context(|| format!("subscription '{name}' skip LSN"))?
1798 .filter(|lsn| lsn != "0/0"),
1799 );
1800 Ok((
1801 name.clone(),
1802 crate::model::replication::SubscriptionState {
1803 name,
1804 owner: Some(row.try_get("owner_name").context("subscription owner")?),
1805 connection: crate::analysis::facts::ConnectionTarget::Redacted,
1806 publications: row
1807 .try_get("subpublications")
1808 .context("subscription publication names")?,
1809 params: Some(params),
1810 enabled: row
1811 .try_get("subenabled")
1812 .context("subscription enabled state")?,
1813 slot_name: row
1814 .try_get("subslotname")
1815 .context("subscription slot name")?,
1816 generation: 0,
1817 },
1818 ))
1819 })
1820 .collect()
1821}
1822
1823fn populate_cache_from_client(
1824 client: &mut impl GenericClient,
1825 schemas: Option<&[String]>,
1826) -> Result<DbCache> {
1827 let mut cache = DbCache::new();
1828 let schema_values = schemas.map(|items| items.to_vec());
1829 let provenance = load_provenance(client, schemas)?;
1830 cache.pg_version_num = Some(provenance.pg_version_num);
1831 cache.metadata = provenance.metadata;
1832 cache.search_path = provenance.search_path;
1833
1834 let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1))";
1835 let schema_filter_with_fk = r#"
1836 AND (
1837 $1::text[] IS NULL
1838 OR n.nspname = ANY($1)
1839 OR c.oid IN (
1840 SELECT conrelid FROM pg_constraint cst
1841 JOIN pg_class c2 ON c2.oid = cst.confrelid
1842 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
1843 WHERE n2.nspname = ANY($1)
1844 )
1845 OR c.oid IN (
1846 SELECT confrelid FROM pg_constraint cst
1847 JOIN pg_class c2 ON c2.oid = cst.conrelid
1848 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
1849 WHERE n2.nspname = ANY($1)
1850 )
1851 )
1852 "#;
1853 let schema_filter_n1_or_n2 =
1854 "AND ($1::text[] IS NULL OR n1.nspname = ANY($1) OR n2.nspname = ANY($1))";
1855 let schema_filter_nt = r#"
1856 AND (
1857 $1::text[] IS NULL
1858 OR n_t.nspname = ANY($1)
1859 OR t.oid IN (
1860 SELECT conrelid FROM pg_constraint cst
1861 JOIN pg_class c2 ON c2.oid = cst.confrelid
1862 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
1863 WHERE n2.nspname = ANY($1)
1864 )
1865 OR t.oid IN (
1866 SELECT confrelid FROM pg_constraint cst
1867 JOIN pg_class c2 ON c2.oid = cst.conrelid
1868 JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
1869 WHERE n2.nspname = ANY($1)
1870 )
1871 )
1872 "#;
1873
1874 cache.schemas = load_schemas(client, &schema_values, schema_filter)?;
1877 cache
1881 .search_path
1882 .retain(|schema| cache.schemas.contains_key(schema));
1883
1884 cache.sequences = load_sequences(client, &schema_values)?;
1885
1886 cache.relations =
1887 load_relations_and_columns(client, schemas, &schema_values, schema_filter_with_fk)?;
1888
1889 let (relation_decorations, relation_grants) =
1890 load_relation_decorations(client, &schema_values, schema_filter_with_fk)?;
1891 for decoration in relation_decorations {
1892 let relation = cache
1893 .relations
1894 .get_mut(&decoration.relation_id)
1895 .with_context(|| {
1896 format!(
1897 "relation decoration references omitted relation '{}'",
1898 decoration.relation_id
1899 )
1900 })?;
1901 relation.triggers.extend(decoration.triggers);
1902 relation.policies.extend(decoration.policies);
1903 }
1904 for grant in relation_grants {
1905 let relation = cache
1906 .relations
1907 .get_mut(&grant.relation_id)
1908 .with_context(|| {
1909 format!(
1910 "relation privilege references omitted relation '{}'",
1911 grant.relation_id
1912 )
1913 })?;
1914 relation
1915 .privileges
1916 .grant(grant.grantee, [grant.privilege].into_iter().collect());
1917 }
1918
1919 cache.triggers = load_triggers(client, &schema_values, schema_filter_with_fk)?;
1920
1921 cache.constraints = load_constraints(client, &schema_values, schema_filter_with_fk)?;
1922
1923 cache.foreign_keys =
1924 load_foreign_keys(client, schemas, &schema_values, schema_filter_n1_or_n2)?;
1925
1926 cache.indexes = load_indexes(client, &schema_values, schema_filter_nt)?;
1927
1928 cache.functions = load_routines(client, &schema_values, schema_filter)?;
1929
1930 cache.publications = load_publications(client, cache.pg_version_num.unwrap_or_default())?;
1931
1932 cache.subscriptions = load_subscriptions(client, cache.pg_version_num.unwrap_or_default())?;
1933
1934 cache.types = load_types(client, &schema_values, schema_filter)?;
1935
1936 cache.dependencies = load_view_dependencies(client, &schema_values)?;
1940
1941 cache.roles = load_roles(client, cache.pg_version_num.unwrap_or_default())?;
1945
1946 cache
1947 .validate_semantics()
1948 .map_err(anyhow::Error::msg)
1949 .context("PostgreSQL catalogs produced a semantically invalid Cache V6 baseline")?;
1950 Ok(cache)
1951}
1952
1953#[cfg(test)]
1954mod catalog_conversion_tests {
1955 use super::*;
1956
1957 #[test]
1958 fn known_catalog_codes_convert_without_fallbacks() {
1959 assert!(matches!(
1960 sequence_kind_from_pg(Some("i"), false).unwrap(),
1961 crate::model::sequence::SequenceKind::Identity
1962 ));
1963 assert!(matches!(
1964 sequence_kind_from_pg(Some("a"), true).unwrap(),
1965 crate::model::sequence::SequenceKind::SerialLike
1966 ));
1967 assert!(matches!(
1968 relation_kind_from_pg(b'm').unwrap(),
1969 RelationKind::MaterializedView
1970 ));
1971 assert!(matches!(
1972 persistence_from_pg(b'u').unwrap(),
1973 Persistence::Unlogged
1974 ));
1975 assert_eq!(
1976 partition_strategy_from_pg(Some("h")).unwrap(),
1977 Some("HASH".to_string())
1978 );
1979 assert!(matches!(
1980 routine_volatility_from_pg("i").unwrap(),
1981 crate::model::function::Volatility::Immutable
1982 ));
1983 assert!(matches!(
1984 routine_kind_from_pg("a").unwrap(),
1985 crate::model::function::RoutineKind::Aggregate
1986 ));
1987 assert_eq!(subscription_streaming_from_pg("p").unwrap(), "parallel");
1988 assert_eq!(subscription_two_phase_from_pg("e").unwrap(), "true");
1989 }
1990
1991 #[test]
1992 fn unknown_catalog_codes_are_actionable_errors() {
1993 for error in [
1994 sequence_kind_from_pg(Some("x"), false).unwrap_err(),
1995 relation_kind_from_pg(b'x').unwrap_err(),
1996 persistence_from_pg(b'x').unwrap_err(),
1997 partition_strategy_from_pg(Some("x")).unwrap_err(),
1998 routine_volatility_from_pg("x").unwrap_err(),
1999 routine_kind_from_pg("x").unwrap_err(),
2000 subscription_streaming_from_pg("x").unwrap_err(),
2001 subscription_two_phase_from_pg("x").unwrap_err(),
2002 ] {
2003 assert!(!error.to_string().is_empty());
2004 }
2005 }
2006
2007 #[test]
2008 fn connection_timeout_default_preserves_an_explicit_value() {
2009 let mut defaulted = PostgresConfig::new();
2010 apply_connection_safety_defaults(&mut defaulted);
2011 assert_eq!(
2012 defaulted.get_connect_timeout(),
2013 Some(&DEFAULT_CONNECT_TIMEOUT)
2014 );
2015
2016 let explicit = Duration::from_secs(3);
2017 let mut configured = PostgresConfig::new();
2018 configured.connect_timeout(explicit);
2019 apply_connection_safety_defaults(&mut configured);
2020 assert_eq!(configured.get_connect_timeout(), Some(&explicit));
2021 }
2022}
2023
2024#[cfg(test)]
2025mod atomic_write_tests {
2026 use super::*;
2027 use crate::db::cache::DbCacheVersioned;
2028 use std::fs;
2029 use std::io::Read;
2030
2031 fn decode_written_cache(path: &Path) -> DbCache {
2032 let encoded = fs::read(path).unwrap();
2033 let reader = std::io::Cursor::new(encoded);
2034 let mut decoder = zstd::stream::Decoder::new(reader).unwrap();
2035 let mut payload = Vec::new();
2036 decoder.read_to_end(&mut payload).unwrap();
2037 let payload = payload
2038 .strip_prefix(CACHE_V6_MAGIC)
2039 .expect("writer must prefix V6 cache payloads");
2040 let config = bincode::config::standard().with_variable_int_encoding();
2041 let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config)
2042 .unwrap()
2043 .0;
2044 versioned.into_cache().unwrap()
2045 }
2046
2047 #[test]
2048 fn bare_cache_filenames_use_the_current_directory_as_parent() {
2049 assert_eq!(cache_parent(Path::new("baseline.cache")), Path::new("."));
2050 assert_eq!(
2051 cache_parent(Path::new("cache/baseline.cache")),
2052 Path::new("cache")
2053 );
2054 }
2055
2056 #[test]
2057 fn production_cache_writer_atomically_replaces_and_decodes() {
2058 let temp_dir = tempfile::tempdir().unwrap();
2059 let cache_path = temp_dir.path().join("baseline.cache");
2060 fs::write(&cache_path, b"old-cache").unwrap();
2061
2062 let mut cache = DbCache::new();
2063 cache.pg_version_num = Some(180002);
2064 write_cache(&cache_path, cache, false).unwrap();
2065
2066 assert_eq!(
2067 decode_written_cache(&cache_path).pg_version_num,
2068 Some(180002)
2069 );
2070 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2071 }
2072
2073 #[test]
2074 fn concurrent_cache_writers_leave_one_complete_decodable_payload() {
2075 let temp_dir = tempfile::tempdir().unwrap();
2076 let cache_path = temp_dir.path().join("baseline.cache");
2077 let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
2078 let mut writers = Vec::new();
2079 for version in [170_007, 180_002] {
2080 let cache_path = cache_path.clone();
2081 let barrier = barrier.clone();
2082 writers.push(std::thread::spawn(move || {
2083 let mut cache = DbCache::new();
2084 cache.pg_version_num = Some(version);
2085 barrier.wait();
2086 write_cache(&cache_path, cache, false)
2087 }));
2088 }
2089 barrier.wait();
2090 let results = writers
2091 .into_iter()
2092 .map(|writer| writer.join().unwrap())
2093 .collect::<Vec<_>>();
2094
2095 assert!(results.iter().any(Result::is_ok));
2096 assert!(matches!(
2097 decode_written_cache(&cache_path).pg_version_num,
2098 Some(170_007 | 180_002)
2099 ));
2100 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2101 }
2102
2103 #[test]
2104 fn production_cache_writer_preserves_old_bytes_after_pre_install_failure() {
2105 let temp_dir = tempfile::tempdir().unwrap();
2106 let cache_path = temp_dir.path().join("baseline.cache");
2107 fs::write(&cache_path, b"known-good-cache").unwrap();
2108
2109 let error = write_cache_with_protection(&cache_path, DbCache::new(), |_| {
2110 Err(anyhow::anyhow!("injected payload-protection failure"))
2111 })
2112 .unwrap_err();
2113
2114 assert!(
2115 error
2116 .to_string()
2117 .contains("injected payload-protection failure")
2118 );
2119 assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
2120 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2121 }
2122
2123 #[test]
2124 fn cache_writer_rejects_oversized_decoded_payload_before_replacement() {
2125 let temp_dir = tempfile::tempdir().unwrap();
2126 let cache_path = temp_dir.path().join("baseline.cache");
2127 fs::write(&cache_path, b"known-good-cache").unwrap();
2128
2129 let error = write_cache_with_protection_and_limits(
2130 &cache_path,
2131 DbCache::new(),
2132 Ok,
2133 MAX_CACHE_FILE_BYTES,
2134 CACHE_V6_MAGIC.len(),
2135 )
2136 .unwrap_err();
2137
2138 assert!(format!("{error:#}").contains("decoded-size limit"));
2139 assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
2140 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2141 }
2142
2143 #[test]
2144 fn cache_writer_rejects_oversized_encoded_payload_before_replacement() {
2145 let temp_dir = tempfile::tempdir().unwrap();
2146 let cache_path = temp_dir.path().join("baseline.cache");
2147 fs::write(&cache_path, b"known-good-cache").unwrap();
2148 let max_file_bytes = 16_u64;
2149
2150 let error = write_cache_with_protection_and_limits(
2151 &cache_path,
2152 DbCache::new(),
2153 |_| Ok(vec![0; max_file_bytes as usize + 1]),
2154 max_file_bytes,
2155 MAX_CACHE_DECODE_BYTES,
2156 )
2157 .unwrap_err();
2158
2159 assert!(format!("{error:#}").contains("encoded-size limit"));
2160 assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
2161 assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2162 }
2163}