Skip to main content

safe_migrate/
sync.rs

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    // Strict env-only credential enforcement
24    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
106/// Parse PostgreSQL's canonical `SHOW search_path` representation while
107/// preserving the special `$user` placeholder and quoted identifier casing.
108pub(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
232// This bounds decoded bytes entering zstd, not the compressed output size.
233struct 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    // Server version and connection provenance.
416    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    // Resolve role/database defaults and special entries such as "$user" exactly
449    // as PostgreSQL does, while excluding the implicit pg_catalog lookup. An
450    // explicit schema scope remains the resolution boundary, but selected
451    // schemas retain their live PostgreSQL priority.
452    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    // Schemas are an authoritative catalog only for the requested sync scope.
456    // FK-only external schemas pulled in below deliberately do not enter it.
457    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    // A scoped request can name schemas that do not exist yet. PostgreSQL's
478    // effective search path skips those entries, so do not let them become
479    // inferred-present namespaces when the cache is hydrated.
480    cache
481        .search_path
482        .retain(|schema| cache.schemas.contains_key(schema));
483
484    // A sequence can have at most one pg_depend ownership relationship. The
485    // dependency flavor distinguishes identity's internal dependency from an
486    // ordinary OWNED BY relationship. An auto dependency is serial-like only
487    // when the owning column also has the sequence-backed nextval default.
488    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    // Relations and statistics.
551    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    // Columns and width statistics.
639    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    // Triggers and policies.
687    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    // Explicit non-owner relation privileges.
717    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    // Trigger functions.
764    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    // Table constraints.
804    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    // Foreign keys.
846    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            // Determine which one is out of scope to print a helpful warning
873            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    // Indexes.
897    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    // Routines share one PostgreSQL namespace, regardless of kind.
933    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    // Publications are database-level objects. Their catalog is synchronized
1015    // in full even when relation synchronization is schema-scoped.
1016    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    // Connection strings are intentionally excluded. Later PostgreSQL versions
1185    // add safe subscription settings, so each query exposes one stable shape.
1186    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    // User-defined types, including ordered enum labels and domains.
1345    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    // Catalog dependencies.
1398    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    // View dependencies are owned by pg_rewrite entries, so the generic pg_depend
1459    // query above cannot recover the dependent view's schema-qualified identity.
1460    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    // Role identity and membership are required to distinguish a valid
1507    // `SET ROLE` from a migration that PostgreSQL would reject. pg_roles does
1508    // not expose password hashes or other credentials.
1509    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}