Skip to main content

safe_migrate/
sync.rs

1// FILE: src/sync.rs
2
3use crate::ast::identifiers::ObjectId;
4use crate::db::cache::{DbCache, ForeignKeyCache, IndexCache};
5use crate::model::relation::{Persistence, RelationKind, RelationState};
6use anyhow::{Context, Result};
7use postgres::{Client, NoTls};
8use std::fs;
9use std::path::Path;
10
11pub fn sync_cache(out_path: &Path, schemas: Option<&[String]>) -> Result<()> {
12    // Strict env-only credential enforcement
13    let db_url = std::env::var("DATABASE_URL")
14        .context("DATABASE_URL environment variable is required to sync database stats. Do not pass credentials via CLI flags or config files.")?;
15
16    // Destructive cache removal prevents corrupted reads on failures
17    if out_path.exists() {
18        fs::remove_file(out_path).context("Failed to remove old cache file before sync")?;
19    }
20
21    // Warn if connecting to a non-local host without TLS
22    let host = db_url
23        .split('@')
24        .nth(1)
25        .and_then(|h| h.split('/').next())
26        .unwrap_or("localhost");
27    if !host.starts_with("localhost")
28        && !host.starts_with("127.")
29        && !host.starts_with("/")
30        && host != "::1"
31    {
32        eprintln!(
33            "[WARN] Connecting to PostgreSQL at {} without TLS encryption.\n\
34             The database password will be sent in cleartext over the network.\n\
35             Use an SSH tunnel or a local connection for sensitive databases,\n\
36             or add native-tls support (see https://github.com/dsecurity49/safe-migrate).",
37            host
38        );
39    }
40
41    let mut client = Client::connect(&db_url, NoTls).context("Failed to connect to PostgreSQL")?;
42
43    let cache = populate_cache(&mut client, schemas)?;
44
45    // Atomic write via temp file
46    let tmp_path = out_path.with_extension("tmp");
47    let file = std::fs::File::create(&tmp_path).context("Failed to create temporary cache file")?;
48    let writer = std::io::BufWriter::new(file);
49    let mut encoder =
50        zstd::stream::Encoder::new(writer, 3).context("Failed to init zstd compression")?;
51
52    let versioned = crate::db::cache::DbCacheVersioned::V5(cache);
53    let bincode_config = bincode::config::standard().with_variable_int_encoding();
54
55    bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config)
56        .context("Failed binary bincode 2.0 schema compilation and write")?;
57
58    encoder
59        .finish()
60        .context("Failed to flush final zstd stream to disk")?;
61
62    fs::rename(&tmp_path, out_path).context("Failed to atomically rename cache file")?;
63
64    Ok(())
65}
66
67pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result<DbCache> {
68    let mut cache = DbCache::new();
69
70    let schema_filter = if let Some(s) = schemas {
71        format!("AND n.nspname = ANY(ARRAY['{}'])", s.join("','"))
72    } else {
73        "".to_string()
74    };
75
76    let schema_filter_with_fk = if let Some(s) = schemas {
77        let arr = format!("ARRAY['{}']", s.join("','"));
78        format!(
79            "AND (
80            n.nspname = ANY({arr})
81            OR c.oid IN (
82                SELECT conrelid FROM pg_constraint cst
83                JOIN pg_class c2 ON c2.oid = cst.confrelid
84                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
85                WHERE n2.nspname = ANY({arr})
86            )
87            OR c.oid IN (
88                SELECT confrelid FROM pg_constraint cst
89                JOIN pg_class c2 ON c2.oid = cst.conrelid
90                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
91                WHERE n2.nspname = ANY({arr})
92            )
93        )"
94        )
95    } else {
96        "".to_string()
97    };
98
99    let schema_filter_n1_or_n2 = if let Some(s) = schemas {
100        let arr = format!("ARRAY['{}']", s.join("','"));
101        format!("AND (n1.nspname = ANY({arr}) OR n2.nspname = ANY({arr}))")
102    } else {
103        "".to_string()
104    };
105
106    let schema_filter_nt = if let Some(s) = schemas {
107        let arr = format!("ARRAY['{}']", s.join("','"));
108        format!(
109            "AND (
110            n_t.nspname = ANY({arr})
111            OR t.oid IN (
112                SELECT conrelid FROM pg_constraint cst
113                JOIN pg_class c2 ON c2.oid = cst.confrelid
114                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
115                WHERE n2.nspname = ANY({arr})
116            )
117            OR t.oid IN (
118                SELECT confrelid FROM pg_constraint cst
119                JOIN pg_class c2 ON c2.oid = cst.conrelid
120                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
121                WHERE n2.nspname = ANY({arr})
122            )
123        )"
124        )
125    } else {
126        "".to_string()
127    };
128
129    // Query 1: Server Version
130    let version_row = client.query_one("SHOW server_version_num;", &[])?;
131    let version_str: String = version_row.get(0);
132    cache.pg_version_num = version_str.parse::<u32>().ok();
133
134    // Resolve role/database defaults and special entries such as "$user" exactly
135    // as PostgreSQL does, while excluding the implicit pg_catalog lookup.
136    let search_path_row = client.query_one("SELECT current_schemas(false);", &[])?;
137    cache.search_path = search_path_row.get(0);
138
139    // Query 2: Relations + Staleness
140    let table_query = format!(
141        "
142        SELECT
143            n.nspname AS schema_name,
144            c.relname AS relation_name,
145            c.relkind AS relation_kind,
146            c.relpersistence AS persistence,
147            CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
148            c.relpages::bigint AS relpages,
149            to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
150            to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
151            p.partstrat::text AS partition_strategy
152        FROM pg_class c
153        JOIN pg_namespace n ON n.oid = c.relnamespace
154        LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
155        LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
156        WHERE c.relkind IN ('r', 'p', 'v', 'm')
157          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
158          {schema_filter_with_fk};
159    "
160    );
161
162    for row in client.query(&table_query, &[])? {
163        let schema_name: String = row.get("schema_name");
164        let relation_name: String = row.get("relation_name");
165        let relkind: i8 = row.get("relation_kind");
166        let persistence_char: i8 = row.get("persistence");
167        let raw_rows: i64 = row.get("estimated_rows");
168        let relpages: i64 = row.get("relpages");
169
170        let last_analyze: Option<String> = row.get("last_analyze");
171        let last_autoanalyze: Option<String> = row.get("last_autoanalyze");
172
173        let object_id = ObjectId::new(&schema_name, &relation_name);
174
175        let kind = match relkind as u8 {
176            b'v' => RelationKind::View,
177            b'm' => RelationKind::MaterializedView,
178            _ => RelationKind::Table,
179        };
180
181        let persistence = match persistence_char as u8 {
182            b't' => Persistence::Temporary,
183            b'u' => Persistence::Unlogged,
184            _ => Persistence::Permanent,
185        };
186
187        let estimated_rows = if raw_rows < 0 {
188            None
189        } else {
190            Some(raw_rows as u64)
191        };
192
193        let mut state = RelationState::new(
194            object_id.clone(),
195            ObjectId::new("public", "postgres"),
196            0,
197            estimated_rows,
198            kind,
199            persistence,
200            0,
201        );
202        state.relpages = Some(relpages as u64);
203        state.last_analyze = last_analyze;
204        state.last_autoanalyze = last_autoanalyze;
205
206        let partition_strategy: Option<String> = row.get("partition_strategy");
207        if let Some(ref strat) = partition_strategy {
208            state.partition_type = Some(match strat.as_str() {
209                "r" => "RANGE".to_string(),
210                "l" => "LIST".to_string(),
211                "h" => "HASH".to_string(),
212                _ => strat.to_uppercase(),
213            });
214        }
215
216        if let Some(s) = schemas
217            && !s.contains(&schema_name)
218        {
219            state.mark_fk_dependency();
220        }
221
222        cache.insert_baseline(object_id, state);
223    }
224
225    // Query 3: Columns + Width
226    let col_query = format!("
227        SELECT
228            n.nspname AS schema_name,
229            c.relname AS relation_name,
230            a.attname AS column_name,
231            pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
232            a.attnotnull AS not_null,
233            s.avg_width AS avg_width,
234            pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
235            a.atttypmod AS type_modifier
236        FROM pg_attribute a
237        JOIN pg_class c ON a.attrelid = c.oid
238        JOIN pg_namespace n ON n.oid = c.relnamespace
239        LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
240        LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
241        WHERE a.attnum > 0 AND NOT a.attisdropped
242          AND c.relkind IN ('r', 'p', 'v', 'm')
243          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
244          {schema_filter_with_fk}
245        ORDER BY n.nspname, c.relname;
246    ");
247
248    let mut current_object_id: Option<ObjectId> = None;
249    let mut current_rel: Option<*mut crate::model::relation::RelationState> = None;
250
251    for row in client.query(&col_query, &[])? {
252        let schema_name: String = row.get("schema_name");
253        let relation_name: String = row.get("relation_name");
254        let column_name: String = row.get("column_name");
255        let type_name: String = row.get("type_name");
256        let not_null: bool = row.get("not_null");
257        let avg_width: Option<i32> = row.get("avg_width");
258        let default_expr_text: Option<String> = row.get("default_expr_text");
259        let type_modifier: Option<i32> = row.get("type_modifier");
260
261        // Fast path: reuse the mutable reference if the relation hasn't changed
262        let is_same_rel = if let Some(ref cur) = current_object_id {
263            cur.schema == schema_name && cur.name == relation_name
264        } else {
265            false
266        };
267
268        if !is_same_rel {
269            let new_oid = ObjectId::new(&schema_name, &relation_name);
270            if let Some(rel) = cache.relations.get_mut(&new_oid) {
271                current_rel = Some(rel as *mut _);
272            } else {
273                current_rel = None;
274            }
275            current_object_id = Some(new_oid);
276        }
277
278        if let Some(rel_ptr) = current_rel {
279            // SAFE: We are strictly single-threaded here, iterating rows sequentially.
280            // We just need a way to bypass the borrow checker for caching the map lookup.
281            let rel = unsafe { &mut *rel_ptr };
282            rel.columns.push(crate::model::column::Column {
283                name: column_name,
284                data_type: Some(type_name),
285                is_nullable: !not_null,
286                default: None,
287                avg_width,
288                default_expr_text,
289                type_modifier,
290            });
291        }
292    }
293
294    // Query 4: Triggers & Policies
295    let tp_query = format!("
296        SELECT 
297            n.nspname AS schema_name,
298            c.relname AS relation_name,
299            COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
300            COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
301        FROM pg_class c
302        JOIN pg_namespace n ON n.oid = c.relnamespace
303        LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
304        LEFT JOIN pg_policy p ON p.polrelid = c.oid
305        WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
306        {schema_filter_with_fk}
307        GROUP BY n.nspname, c.relname;
308    ");
309
310    for row in client.query(&tp_query, &[])? {
311        let schema_name: String = row.get("schema_name");
312        let relation_name: String = row.get("relation_name");
313        let triggers: Vec<String> = row.get("triggers");
314        let policies: Vec<String> = row.get("policies");
315
316        let object_id = ObjectId::new(&schema_name, &relation_name);
317
318        if let Some(rel) = cache.relations.get_mut(&object_id) {
319            rel.triggers.extend(triggers);
320            rel.policies.extend(policies);
321        }
322    }
323
324    // Query 4.25: Explicit non-owner relation privileges.
325    let acl_query = format!(
326        "
327        SELECT
328            n.nspname AS schema_name,
329            c.relname AS relation_name,
330            CASE
331                WHEN acl.grantee = 0 THEN 'public'
332                ELSE pg_catalog.pg_get_userbyid(acl.grantee)
333            END AS grantee,
334            acl.privilege_type
335        FROM pg_class c
336        JOIN pg_namespace n ON n.oid = c.relnamespace
337        CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
338        WHERE c.relkind IN ('r', 'p', 'v', 'm')
339          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
340          AND acl.grantee <> c.relowner
341          {schema_filter_with_fk};
342        "
343    );
344
345    for row in client.query(&acl_query, &[])? {
346        let schema_name: String = row.get("schema_name");
347        let relation_name: String = row.get("relation_name");
348        let grantee: String = row.get("grantee");
349        let privilege_type: String = row.get("privilege_type");
350        let privilege = match privilege_type.as_str() {
351            "SELECT" => crate::model::relation::Privilege::Select,
352            "INSERT" => crate::model::relation::Privilege::Insert,
353            "UPDATE" => crate::model::relation::Privilege::Update,
354            "DELETE" => crate::model::relation::Privilege::Delete,
355            "TRUNCATE" => crate::model::relation::Privilege::Truncate,
356            "REFERENCES" => crate::model::relation::Privilege::References,
357            "TRIGGER" => crate::model::relation::Privilege::Trigger,
358            _ => continue,
359        };
360        if let Some(relation) = cache
361            .relations
362            .get_mut(&ObjectId::new(&schema_name, &relation_name))
363        {
364            relation.privileges.grant(
365                ObjectId::new("", grantee),
366                [privilege].into_iter().collect(),
367            );
368        }
369    }
370
371    // Query 4.5: Trigger Functions
372    let trig_query = format!(
373        "
374        SELECT 
375            n.nspname AS table_schema,
376            c.relname AS table_name,
377            t.tgname AS trigger_name,
378            t.tgenabled::text AS enabled_mode,
379            fn.nspname AS function_schema,
380            f.proname || '()' AS function_name
381        FROM pg_trigger t
382        JOIN pg_class c ON c.oid = t.tgrelid
383        JOIN pg_namespace n ON n.oid = c.relnamespace
384        JOIN pg_proc f ON f.oid = t.tgfoid
385        JOIN pg_namespace fn ON fn.oid = f.pronamespace
386        WHERE t.tgisinternal = false
387          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
388          {schema_filter_with_fk};
389    "
390    );
391
392    for row in client.query(&trig_query, &[])? {
393        let table_schema: String = row.get("table_schema");
394        let table_name: String = row.get("table_name");
395        let trigger_name: String = row.get("trigger_name");
396        let enabled_mode: String = row.get("enabled_mode");
397        let function_schema: String = row.get("function_schema");
398        let function_name: String = row.get("function_name");
399
400        cache.triggers.push(crate::db::cache::TriggerCache {
401            trigger_id: ObjectId::new(&table_schema, &trigger_name),
402            table_id: ObjectId::new(&table_schema, &table_name),
403            function_id: ObjectId::new(&function_schema, &function_name),
404            enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode)
405                .ok_or_else(|| {
406                    anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}")
407                })?,
408        });
409    }
410
411    // Query 4.75: Table constraints
412    let constraint_query = format!(
413        "
414        SELECT
415            n.nspname AS table_schema,
416            c.relname AS table_name,
417            con.conname AS constraint_name,
418            con.contype::text AS constraint_type,
419            con.convalidated AS validated
420        FROM pg_constraint con
421        JOIN pg_class c ON c.oid = con.conrelid
422        JOIN pg_namespace n ON n.oid = c.relnamespace
423        WHERE con.contype IN ('c', 'f', 'p', 'u', 'x')
424          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
425          {schema_filter};
426        "
427    );
428
429    for row in client.query(&constraint_query, &[])? {
430        let table_schema: String = row.get("table_schema");
431        let table_name: String = row.get("table_name");
432        let constraint_name: String = row.get("constraint_name");
433        let constraint_type: String = row.get("constraint_type");
434        let validated: bool = row.get("validated");
435        let kind = match constraint_type.as_str() {
436            "c" => crate::model::constraint::ConstraintKind::Check,
437            "f" => crate::model::constraint::ConstraintKind::ForeignKey,
438            "p" => crate::model::constraint::ConstraintKind::PrimaryKey,
439            "u" => crate::model::constraint::ConstraintKind::Unique,
440            "x" => crate::model::constraint::ConstraintKind::Exclusion,
441            _ => continue,
442        };
443        cache
444            .constraints
445            .push(crate::model::constraint::ConstraintState {
446                table_id: ObjectId::new(&table_schema, &table_name),
447                name: constraint_name,
448                kind,
449                validated,
450            });
451    }
452
453    // Query 5: Foreign Keys
454    let fk_query = format!(
455        "
456        SELECT 
457            c.conname AS constraint_name,
458            n1.nspname AS from_schema, t1.relname AS from_table,
459            n2.nspname AS to_schema, t2.relname AS to_table
460        FROM pg_constraint c
461        JOIN pg_class t1 ON t1.oid = c.conrelid
462        JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
463        JOIN pg_class t2 ON t2.oid = c.confrelid
464        JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
465        WHERE c.contype = 'f'
466        {schema_filter_n1_or_n2};
467    "
468    );
469
470    for row in client.query(&fk_query, &[])? {
471        let constraint_name: String = row.get("constraint_name");
472        let from_schema: String = row.get("from_schema");
473        let from_table: String = row.get("from_table");
474        let to_schema: String = row.get("to_schema");
475        let to_table: String = row.get("to_table");
476
477        if let Some(s) = schemas
478            && (!s.contains(&from_schema) || !s.contains(&to_schema))
479        {
480            // Determine which one is out of scope to print a helpful warning
481            let out_of_scope_schema = if !s.contains(&from_schema) {
482                &from_schema
483            } else {
484                &to_schema
485            };
486            let out_of_scope_table = if !s.contains(&from_schema) {
487                &from_table
488            } else {
489                &to_table
490            };
491            eprintln!(
492                "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
493                constraint_name, out_of_scope_schema, out_of_scope_table
494            );
495        }
496
497        cache.foreign_keys.push(ForeignKeyCache {
498            constraint_name,
499            from_table: ObjectId::new(&from_schema, &from_table),
500            to_table: ObjectId::new(&to_schema, &to_table),
501        });
502    }
503
504    // Query 6: Indexes
505    let idx_query = format!(
506        "
507        SELECT 
508            n_i.nspname AS index_schema, i.relname AS index_name,
509            n_t.nspname AS table_schema, t.relname AS table_name
510        FROM pg_index x
511        JOIN pg_class i ON i.oid = x.indexrelid
512        JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
513        JOIN pg_class t ON t.oid = x.indrelid
514        JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
515        WHERE x.indisvalid = true
516        {schema_filter_nt};
517    "
518    );
519
520    for row in client.query(&idx_query, &[])? {
521        let index_schema: String = row.get("index_schema");
522        let index_name: String = row.get("index_name");
523        let table_schema: String = row.get("table_schema");
524        let table_name: String = row.get("table_name");
525
526        cache.indexes.push(IndexCache {
527            index_id: ObjectId::new(&index_schema, &index_name),
528            table_id: ObjectId::new(&table_schema, &table_name),
529        });
530    }
531
532    // Query 7: Functions
533    let func_query = format!(
534        "
535        SELECT
536            n.nspname AS schema_name,
537            p.proname AS func_name,
538            COALESCE(
539                (SELECT string_agg(pg_catalog.format_type(t, NULL), ',' ORDER BY n)
540                 FROM unnest(p.proargtypes::int[]) WITH ORDINALITY AS u(t, n)),
541                ''
542            ) AS arg_types,
543            pg_catalog.pg_get_function_result(p.oid) AS return_type,
544            p.provolatile::text AS volatility,
545            l.lanname AS language,
546            p.prosecdef AS security_definer
547        FROM pg_proc p
548        JOIN pg_namespace n ON n.oid = p.pronamespace
549        JOIN pg_language l ON l.oid = p.prolang
550        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
551          AND p.prokind = 'f'
552          {schema_filter};
553    "
554    );
555
556    for row in client.query(&func_query, &[])? {
557        let schema_name: String = row.get("schema_name");
558        let func_name: String = row.get("func_name");
559        let arg_types_str: String = row.get("arg_types");
560        let return_type: Option<String> = row.get("return_type");
561        let volatility_char: String = row.get("volatility");
562        let language: String = row.get("language");
563        let security_definer: bool = row.get("security_definer");
564
565        let volatility = match volatility_char.as_str() {
566            "v" => crate::model::function::Volatility::Volatile,
567            "s" => crate::model::function::Volatility::Stable,
568            "i" => crate::model::function::Volatility::Immutable,
569            _ => crate::model::function::Volatility::Volatile,
570        };
571
572        let security = if security_definer {
573            crate::model::function::SecurityMode::Definer
574        } else {
575            crate::model::function::SecurityMode::Invoker
576        };
577
578        // Normalize argument types in sync just like in resolver
579        let arg_types_str = arg_types_str
580            .split(',')
581            .map(|s| s.trim().to_lowercase())
582            .collect::<Vec<_>>()
583            .join(",");
584
585        let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str));
586
587        let arg_types = if arg_types_str.is_empty() {
588            Vec::new()
589        } else {
590            arg_types_str.split(',').map(|s| s.to_string()).collect()
591        };
592
593        cache.functions.insert(
594            id.clone(),
595            crate::model::function::FunctionState {
596                id,
597                arg_types,
598                return_type: return_type.unwrap_or_default(),
599                volatility,
600                language,
601                security,
602            },
603        );
604    }
605
606    // Query 8: User-defined types, including ordered enum labels and domains.
607    let type_query = format!(
608        "
609        SELECT
610            n.nspname AS schema_name,
611            t.typname AS type_name,
612            t.typtype::text AS type_kind,
613            CASE WHEN t.typtype = 'd'
614                THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
615                ELSE NULL
616            END AS domain_base_type,
617            COALESCE(
618                array_agg(e.enumlabel ORDER BY e.enumsortorder)
619                    FILTER (WHERE e.enumlabel IS NOT NULL),
620                ARRAY[]::text[]
621            ) AS enum_labels
622        FROM pg_type t
623        JOIN pg_namespace n ON n.oid = t.typnamespace
624        LEFT JOIN pg_enum e ON e.enumtypid = t.oid
625        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
626          AND t.typtype IN ('e', 'd')
627          {schema_filter}
628        GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod;
629        "
630    );
631
632    for row in client.query(&type_query, &[])? {
633        let schema_name: String = row.get("schema_name");
634        let type_name: String = row.get("type_name");
635        let type_kind: String = row.get("type_kind");
636        let domain_base_type: Option<String> = row.get("domain_base_type");
637        let enum_labels: Vec<String> = row.get("enum_labels");
638        let kind = match type_kind.as_str() {
639            "e" => crate::model::types::TypeKind::Enum {
640                variants: enum_labels,
641            },
642            "d" => crate::model::types::TypeKind::Domain {
643                base_type: domain_base_type.unwrap_or_default(),
644            },
645            _ => continue,
646        };
647        let id = ObjectId::new(&schema_name, &type_name);
648        cache.types.insert(
649            id.clone(),
650            crate::model::types::TypeState {
651                id,
652                generation: 0,
653                kind,
654            },
655        );
656    }
657
658    // Query 9: Dependencies (pg_depend)
659    let dependency_schemas = schemas.map(|items| items.to_vec());
660    let depend_query = r#"
661        SELECT
662            d.classid, d.objid, d.objsubid,
663            d.refclassid, d.refobjid, d.refobjsubid,
664            d.deptype::text,
665            COALESCE(n1.nspname, n1p.nspname, n1t.nspname) AS obj_schema,
666            COALESCE(c1.relname, p1.proname, t1.typname) AS obj_name,
667            COALESCE(n2.nspname, n2p.nspname, n2t.nspname) AS ref_schema,
668            COALESCE(c2.relname, p2.proname, t2.typname) AS ref_name
669        FROM pg_depend d
670        LEFT JOIN pg_class c1 ON c1.oid = d.objid AND d.classid = 'pg_class'::regclass
671        LEFT JOIN pg_namespace n1 ON n1.oid = c1.relnamespace
672        LEFT JOIN pg_proc p1 ON p1.oid = d.objid AND d.classid = 'pg_proc'::regclass
673        LEFT JOIN pg_namespace n1p ON n1p.oid = p1.pronamespace
674        LEFT JOIN pg_type t1 ON t1.oid = d.objid AND d.classid = 'pg_type'::regclass
675        LEFT JOIN pg_namespace n1t ON n1t.oid = t1.typnamespace
676        LEFT JOIN pg_class c2 ON c2.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
677        LEFT JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
678        LEFT JOIN pg_proc p2 ON p2.oid = d.refobjid AND d.refclassid = 'pg_proc'::regclass
679        LEFT JOIN pg_namespace n2p ON n2p.oid = p2.pronamespace
680        LEFT JOIN pg_type t2 ON t2.oid = d.refobjid AND d.refclassid = 'pg_type'::regclass
681        LEFT JOIN pg_namespace n2t ON n2t.oid = t2.typnamespace
682        WHERE d.deptype IN ('n', 'a', 'i')
683          AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname) IS NOT NULL
684          AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname)
685              NOT IN ('pg_catalog', 'information_schema')
686          AND (
687              $1::text[] IS NULL
688              OR COALESCE(n1.nspname, n1p.nspname, n1t.nspname) = ANY($1)
689          )
690    "#;
691
692    for row in client.query(depend_query, &[&dependency_schemas])? {
693        let classid: u32 = row.get(0);
694        let objid: u32 = row.get(1);
695        let objsubid: i32 = row.get(2);
696        let refclassid: u32 = row.get(3);
697        let refobjid: u32 = row.get(4);
698        let refobjsubid: i32 = row.get(5);
699        let deptype: String = row.get(6);
700        let obj_schema: Option<String> = row.get(7);
701        let obj_name: Option<String> = row.get(8);
702        let ref_schema: Option<String> = row.get(9);
703        let ref_name: Option<String> = row.get(10);
704
705        cache.dependencies.push(crate::db::cache::DependencyCache {
706            classid,
707            objid,
708            objsubid,
709            refclassid,
710            refobjid,
711            refobjsubid,
712            deptype,
713            obj_schema,
714            obj_name,
715            ref_schema,
716            ref_name,
717        });
718    }
719
720    // View dependencies are owned by pg_rewrite entries, so the generic pg_depend
721    // query above cannot recover the dependent view's schema-qualified identity.
722    let view_depend_query = r#"
723        SELECT DISTINCT
724            'pg_class'::regclass::oid AS classid,
725            vc.oid AS objid,
726            0 AS objsubid,
727            'pg_class'::regclass::oid AS refclassid,
728            tc.oid AS refobjid,
729            0 AS refobjsubid,
730            vn.nspname AS obj_schema,
731            vc.relname AS obj_name,
732            tn.nspname AS ref_schema,
733            tc.relname AS ref_name
734        FROM pg_rewrite rw
735        JOIN pg_class vc ON vc.oid = rw.ev_class
736        JOIN pg_namespace vn ON vn.oid = vc.relnamespace
737        JOIN pg_depend d ON d.objid = rw.oid
738        JOIN pg_class tc ON tc.oid = d.refobjid
739        JOIN pg_namespace tn ON tn.oid = tc.relnamespace
740        WHERE vc.relkind IN ('v', 'm')
741          AND d.deptype = 'n'
742          AND (
743              $1::text[] IS NULL
744              OR (vn.nspname = ANY($1) AND tn.nspname = ANY($1))
745          )
746    "#;
747
748    for row in client.query(view_depend_query, &[&dependency_schemas])? {
749        cache.dependencies.push(crate::db::cache::DependencyCache {
750            classid: row.get(0),
751            objid: row.get(1),
752            objsubid: row.get(2),
753            refclassid: row.get(3),
754            refobjid: row.get(4),
755            refobjsubid: row.get(5),
756            deptype: "view".to_string(),
757            obj_schema: Some(row.get(6)),
758            obj_name: Some(row.get(7)),
759            ref_schema: Some(row.get(8)),
760            ref_name: Some(row.get(9)),
761        });
762    }
763
764    Ok(cache)
765}