Skip to main content

drizzle_migrations/postgres/
introspect.rs

1//! `PostgreSQL` database introspection
2//!
3//! This module provides functionality to introspect an existing `PostgreSQL` database
4//! and extract its schema as DDL entities, matching drizzle-kit introspect.ts
5
6use super::ddl::{
7    CheckConstraint, Column, Enum, ForeignKey, Index, IndexColumn, Policy, PostgresEntity,
8    PrimaryKey, Role, Schema, Sequence, Table, UniqueConstraint, View,
9};
10use super::grammar::{is_system_namespace, is_system_role};
11use super::snapshot::PostgresSnapshot;
12
13/// Error type for introspection operations
14#[derive(Debug, Clone)]
15pub struct IntrospectError {
16    pub message: String,
17    pub table: Option<String>,
18    pub schema: Option<String>,
19}
20
21impl std::fmt::Display for IntrospectError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match (&self.schema, &self.table) {
24            (Some(s), Some(t)) => {
25                write!(f, "Introspection error for '{}.{}': {}", s, t, self.message)
26            }
27            (Some(s), None) => write!(f, "Introspection error in schema '{}': {}", s, self.message),
28            (None, Some(t)) => write!(f, "Introspection error for '{}': {}", t, self.message),
29            (None, None) => write!(f, "Introspection error: {}", self.message),
30        }
31    }
32}
33
34impl std::error::Error for IntrospectError {}
35
36/// Result type for introspection
37pub type IntrospectResult<T> = Result<T, IntrospectError>;
38
39// =============================================================================
40// Raw Query Result Types
41// =============================================================================
42
43/// Raw table info from `information_schema`
44#[derive(Debug, Clone)]
45pub struct RawTableInfo {
46    pub schema: String,
47    pub name: String,
48    pub is_rls_enabled: bool,
49    pub is_unlogged: bool,
50    pub is_temporary: bool,
51    pub tablespace: Option<String>,
52    pub comment: Option<String>,
53}
54
55/// Raw column info from `information_schema`
56#[derive(Debug, Clone)]
57pub struct RawColumnInfo {
58    pub schema: String,
59    pub table: String,
60    pub name: String,
61    pub column_type: String,
62    pub type_schema: Option<String>,
63    pub not_null: bool,
64    pub default_value: Option<String>,
65    pub is_identity: bool,
66    pub identity_type: Option<String>,
67    pub is_generated: bool,
68    pub generated_expression: Option<String>,
69    pub generated_stored: bool,
70    pub dimensions: Option<i32>,
71    pub comment: Option<String>,
72    pub ordinal_position: i32,
73}
74
75/// Raw enum info
76#[derive(Debug, Clone)]
77pub struct RawEnumInfo {
78    pub schema: String,
79    pub name: String,
80    pub values: Vec<String>,
81}
82
83/// Raw sequence info
84///
85/// Value columns are `Option` because `pg_sequences` returns NULL when the
86/// current user lacks privilege on the sequence.
87#[derive(Debug, Clone)]
88pub struct RawSequenceInfo {
89    pub schema: String,
90    pub name: String,
91    pub data_type: Option<String>,
92    pub start_value: Option<String>,
93    pub min_value: Option<String>,
94    pub max_value: Option<String>,
95    pub increment: Option<String>,
96    pub cycle: Option<bool>,
97    pub cache_value: Option<String>,
98    /// `schema.table` of the owning column when `pg_depend` records this
99    /// sequence as auto-owned (serial or identity, deptype `a`/`i`);
100    /// `None` for standalone, hand-managed sequences.
101    pub owned_by: Option<String>,
102}
103
104/// Raw index info
105#[derive(Debug, Clone)]
106pub struct RawIndexInfo {
107    pub schema: String,
108    pub table: String,
109    pub name: String,
110    pub is_unique: bool,
111    pub is_primary: bool,
112    pub method: String,
113    pub columns: Vec<RawIndexColumnInfo>,
114    pub where_clause: Option<String>,
115    pub concurrent: bool,
116}
117
118/// Raw index column info
119#[derive(Debug, Clone)]
120pub struct RawIndexColumnInfo {
121    pub name: String,
122    pub is_expression: bool,
123    pub asc: bool,
124    pub nulls_first: bool,
125    pub opclass: Option<String>,
126}
127
128/// Raw foreign key info
129#[derive(Debug, Clone)]
130pub struct RawForeignKeyInfo {
131    pub schema: String,
132    pub table: String,
133    pub name: String,
134    pub columns: Vec<String>,
135    pub schema_to: String,
136    pub table_to: String,
137    pub columns_to: Vec<String>,
138    pub on_update: String,
139    pub on_delete: String,
140    pub deferrable: bool,
141    pub initially_deferred: bool,
142}
143
144/// Raw primary key info
145#[derive(Debug, Clone)]
146pub struct RawPrimaryKeyInfo {
147    pub schema: String,
148    pub table: String,
149    pub name: String,
150    pub columns: Vec<String>,
151}
152
153/// Raw unique constraint info
154#[derive(Debug, Clone)]
155pub struct RawUniqueInfo {
156    pub schema: String,
157    pub table: String,
158    pub name: String,
159    pub columns: Vec<String>,
160    pub nulls_not_distinct: bool,
161    pub deferrable: bool,
162    pub initially_deferred: bool,
163}
164
165/// Raw check constraint info
166#[derive(Debug, Clone)]
167pub struct RawCheckInfo {
168    pub schema: String,
169    pub table: String,
170    pub name: String,
171    pub expression: String,
172}
173
174/// Raw view info
175#[derive(Debug, Clone)]
176pub struct RawViewInfo {
177    pub schema: String,
178    pub name: String,
179    pub definition: String,
180    pub is_materialized: bool,
181}
182
183/// Raw policy info (RLS)
184#[derive(Debug, Clone)]
185pub struct RawPolicyInfo {
186    pub schema: String,
187    pub table: String,
188    pub name: String,
189    pub as_clause: String,
190    pub for_clause: String,
191    pub to: Vec<String>,
192    pub using: Option<String>,
193    pub with_check: Option<String>,
194}
195
196/// Raw role info
197#[derive(Debug, Clone)]
198pub struct RawRoleInfo {
199    pub name: String,
200    pub create_db: bool,
201    pub create_role: bool,
202    pub inherit: bool,
203}
204
205/// Transport-decoded PostgreSQL catalog rows used by every driver.
206#[derive(Debug, Clone, Default)]
207pub struct RawIntrospection {
208    pub schemas: Vec<Schema>,
209    pub tables: Vec<RawTableInfo>,
210    pub columns: Vec<RawColumnInfo>,
211    pub enums: Vec<RawEnumInfo>,
212    pub sequences: Vec<RawSequenceInfo>,
213    pub views: Vec<RawViewInfo>,
214    pub indexes: Vec<RawIndexInfo>,
215    pub foreign_keys: Vec<RawForeignKeyInfo>,
216    pub primary_keys: Vec<RawPrimaryKeyInfo>,
217    pub unique_constraints: Vec<RawUniqueInfo>,
218    pub check_constraints: Vec<RawCheckInfo>,
219    pub roles: Vec<RawRoleInfo>,
220    pub policies: Vec<RawPolicyInfo>,
221}
222
223/// Assemble transport-decoded PostgreSQL metadata into the canonical DDL.
224#[must_use]
225pub fn assemble_ddl(raw: RawIntrospection) -> super::PostgresDDL {
226    let mut ddl = super::PostgresDDL::new();
227    for schema in raw.schemas {
228        ddl.schemas.push(schema);
229    }
230    for value in process_enums(&raw.enums) {
231        ddl.enums.push(value);
232    }
233    for sequence in process_sequences(&raw.sequences) {
234        ddl.sequences.push(sequence);
235    }
236    for role in process_roles(&raw.roles) {
237        ddl.roles.push(role);
238    }
239    for policy in process_policies(&raw.policies) {
240        ddl.policies.push(policy);
241    }
242    for table in process_tables(&raw.tables) {
243        ddl.tables.push(table);
244    }
245    for column in process_columns(&raw.columns) {
246        ddl.columns.push(column);
247    }
248    for index in process_indexes(&raw.indexes) {
249        ddl.indexes.push(index);
250    }
251    for foreign_key in process_foreign_keys(&raw.foreign_keys) {
252        ddl.fks.push(foreign_key);
253    }
254    for primary_key in process_primary_keys(&raw.primary_keys) {
255        ddl.pks.push(primary_key);
256    }
257    for unique in process_unique_constraints(&raw.unique_constraints) {
258        ddl.uniques.push(unique);
259    }
260    for check in process_check_constraints(&raw.check_constraints) {
261        ddl.checks.push(check);
262    }
263    for view in process_views(&raw.views) {
264        ddl.views.push(view);
265    }
266    ddl
267}
268
269// =============================================================================
270// Introspection Result
271// =============================================================================
272
273/// Introspection result containing all extracted entities
274#[derive(Debug, Clone, Default)]
275pub struct IntrospectionResult {
276    pub schemas: Vec<Schema>,
277    pub enums: Vec<Enum>,
278    pub sequences: Vec<Sequence>,
279    pub roles: Vec<Role>,
280    pub tables: Vec<Table>,
281    pub columns: Vec<Column>,
282    pub indexes: Vec<Index>,
283    pub foreign_keys: Vec<ForeignKey>,
284    pub primary_keys: Vec<PrimaryKey>,
285    pub unique_constraints: Vec<UniqueConstraint>,
286    pub check_constraints: Vec<CheckConstraint>,
287    pub views: Vec<View>,
288    pub policies: Vec<Policy>,
289    pub errors: Vec<IntrospectError>,
290}
291
292impl IntrospectionResult {
293    /// Convert to a snapshot
294    #[must_use]
295    pub fn to_snapshot(&self) -> PostgresSnapshot {
296        let mut snapshot = PostgresSnapshot::new();
297
298        for schema in &self.schemas {
299            snapshot.add_entity(PostgresEntity::Schema(schema.clone()));
300        }
301        for e in &self.enums {
302            snapshot.add_entity(PostgresEntity::Enum(e.clone()));
303        }
304        for seq in &self.sequences {
305            // Serial/identity-owned sequences were already dropped by
306            // `process_sequences` (pg_depend ownership); everything left is a
307            // real standalone sequence.
308            snapshot.add_entity(PostgresEntity::Sequence(seq.clone()));
309        }
310        for role in &self.roles {
311            snapshot.add_entity(PostgresEntity::Role(role.clone()));
312        }
313        for table in &self.tables {
314            snapshot.add_entity(PostgresEntity::Table(table.clone()));
315        }
316        for column in &self.columns {
317            snapshot.add_entity(PostgresEntity::Column(column.clone()));
318        }
319        for index in &self.indexes {
320            snapshot.add_entity(PostgresEntity::Index(index.clone()));
321        }
322        for fk in &self.foreign_keys {
323            snapshot.add_entity(PostgresEntity::ForeignKey(fk.clone()));
324        }
325        for pk in &self.primary_keys {
326            snapshot.add_entity(PostgresEntity::PrimaryKey(pk.clone()));
327        }
328        for unique in &self.unique_constraints {
329            snapshot.add_entity(PostgresEntity::UniqueConstraint(unique.clone()));
330        }
331        for check in &self.check_constraints {
332            snapshot.add_entity(PostgresEntity::CheckConstraint(check.clone()));
333        }
334        for view in &self.views {
335            snapshot.add_entity(PostgresEntity::View(view.clone()));
336        }
337        for policy in &self.policies {
338            snapshot.add_entity(PostgresEntity::Policy(policy.clone()));
339        }
340
341        snapshot
342    }
343
344    /// Check if introspection had any errors
345    #[must_use]
346    pub const fn has_errors(&self) -> bool {
347        !self.errors.is_empty()
348    }
349
350    /// Get all entities as a vector
351    #[must_use]
352    pub fn to_entities(&self) -> Vec<PostgresEntity> {
353        let mut entities = Vec::new();
354
355        for s in &self.schemas {
356            entities.push(PostgresEntity::Schema(s.clone()));
357        }
358        for e in &self.enums {
359            entities.push(PostgresEntity::Enum(e.clone()));
360        }
361        for s in &self.sequences {
362            entities.push(PostgresEntity::Sequence(s.clone()));
363        }
364        for r in &self.roles {
365            entities.push(PostgresEntity::Role(r.clone()));
366        }
367        for t in &self.tables {
368            entities.push(PostgresEntity::Table(t.clone()));
369        }
370        for c in &self.columns {
371            entities.push(PostgresEntity::Column(c.clone()));
372        }
373        for i in &self.indexes {
374            entities.push(PostgresEntity::Index(i.clone()));
375        }
376        for f in &self.foreign_keys {
377            entities.push(PostgresEntity::ForeignKey(f.clone()));
378        }
379        for p in &self.primary_keys {
380            entities.push(PostgresEntity::PrimaryKey(p.clone()));
381        }
382        for u in &self.unique_constraints {
383            entities.push(PostgresEntity::UniqueConstraint(u.clone()));
384        }
385        for c in &self.check_constraints {
386            entities.push(PostgresEntity::CheckConstraint(c.clone()));
387        }
388        for v in &self.views {
389            entities.push(PostgresEntity::View(v.clone()));
390        }
391        for p in &self.policies {
392            entities.push(PostgresEntity::Policy(p.clone()));
393        }
394
395        entities
396    }
397}
398
399// =============================================================================
400// Processing Functions
401// =============================================================================
402
403/// Process raw table info into Table entities
404#[must_use]
405pub fn process_tables(raw_tables: &[RawTableInfo]) -> Vec<Table> {
406    raw_tables
407        .iter()
408        .filter(|t| !is_system_namespace(&t.schema))
409        .map(|t| Table {
410            schema: t.schema.clone().into(),
411            name: t.name.clone().into(),
412            is_unlogged: if t.is_unlogged { Some(true) } else { None },
413            is_temporary: if t.is_temporary { Some(true) } else { None },
414            inherits: None,
415            tablespace: t.tablespace.clone().map(Into::into),
416            is_rls_enabled: Some(t.is_rls_enabled),
417            comment: t.comment.clone().map(Into::into),
418        })
419        .collect()
420}
421
422/// Identity sequence options decoded from the packed `identity_type` column.
423#[derive(Debug, Clone, Default)]
424struct IdentityOptions {
425    start: Option<String>,
426    increment: Option<String>,
427    min: Option<String>,
428    max: Option<String>,
429    cycle: Option<bool>,
430}
431
432/// Decode the `identity_type` column: either the JSON object emitted by
433/// [`queries::COLUMNS_QUERY`] or a legacy plain `ALWAYS` / `BY DEFAULT`
434/// string. Returns the identity type string plus any sequence options.
435fn parse_identity_type(raw: &str) -> (String, IdentityOptions) {
436    let trimmed = raw.trim();
437    if trimmed.starts_with('{')
438        && let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
439    {
440        let get = |key: &str| {
441            value
442                .get(key)
443                .and_then(serde_json::Value::as_str)
444                .map(ToString::to_string)
445        };
446        let type_str = get("type").unwrap_or_else(|| "ALWAYS".to_string());
447        let options = IdentityOptions {
448            start: get("start"),
449            increment: get("increment"),
450            min: get("min"),
451            max: get("max"),
452            cycle: value.get("cycle").and_then(serde_json::Value::as_bool),
453        };
454        return (type_str, options);
455    }
456    (trimmed.to_string(), IdentityOptions::default())
457}
458
459/// Process raw column info into Column entities
460#[must_use]
461pub fn process_columns(raw_columns: &[RawColumnInfo]) -> Vec<Column> {
462    use super::ddl::{GeneratedType, IdentityType};
463
464    raw_columns
465        .iter()
466        .filter(|c| !is_system_namespace(&c.schema))
467        .map(|c| {
468            let generated = if c.is_generated {
469                c.generated_expression
470                    .as_ref()
471                    .map(|expr| super::ddl::Generated {
472                        expression: expr.clone().into(),
473                        gen_type: if c.generated_stored {
474                            GeneratedType::Stored
475                        } else {
476                            GeneratedType::Virtual
477                        },
478                    })
479            } else {
480                None
481            };
482
483            let identity = if c.is_identity {
484                c.identity_type.as_ref().map(|t| {
485                    // `identity_type` is either the packed JSON object built
486                    // by COLUMNS_QUERY ({type, start, increment, min, max,
487                    // cycle}) or a legacy plain `ALWAYS` / `BY DEFAULT`
488                    // string from hand-built rows.
489                    let (type_str, options) = parse_identity_type(t);
490                    let identity_type = if type_str.eq_ignore_ascii_case("always") {
491                        IdentityType::Always
492                    } else {
493                        IdentityType::ByDefault
494                    };
495                    super::ddl::Identity {
496                        name: format!("{}_{}_seq", c.table, c.name).into(),
497                        schema: Some(c.schema.clone().into()),
498                        type_: identity_type,
499                        increment: options.increment.map(Into::into),
500                        min_value: options.min.map(Into::into),
501                        max_value: options.max.map(Into::into),
502                        start_with: options.start.map(Into::into),
503                        cache: None,
504                        cycle: options.cycle,
505                    }
506                })
507            } else {
508                None
509            };
510
511            let dimensions = c.dimensions.filter(|dims| *dims > 0).or_else(|| {
512                if c.column_type.starts_with('_') {
513                    Some(1)
514                } else {
515                    None
516                }
517            });
518            let column_type = if dimensions.is_some() {
519                c.column_type
520                    .strip_prefix('_')
521                    .unwrap_or(&c.column_type)
522                    .to_string()
523            } else {
524                c.column_type.clone()
525            };
526
527            Column {
528                schema: c.schema.clone().into(),
529                table: c.table.clone().into(),
530                name: c.name.clone().into(),
531                sql_type: column_type.into(),
532                type_schema: c.type_schema.clone().map(std::convert::Into::into),
533                not_null: c.not_null,
534                default: c.default_value.clone().map(std::convert::Into::into),
535                generated,
536                identity,
537                dimensions,
538                comment: c.comment.clone().map(std::convert::Into::into),
539                // pg_attribute exposes attcollation but we don't read it yet
540                // (the introspect SQL doesn't pull it). Collation drift
541                // detection requires extending the SELECT — defer to a
542                // follow-up.
543                collate: None,
544                ordinal_position: Some(c.ordinal_position),
545            }
546        })
547        .collect()
548}
549
550/// Process raw enum info into Enum entities
551#[must_use]
552pub fn process_enums(raw_enums: &[RawEnumInfo]) -> Vec<Enum> {
553    raw_enums
554        .iter()
555        .filter(|e| !is_system_namespace(&e.schema))
556        .map(|e| Enum {
557            schema: e.schema.clone().into(),
558            name: e.name.clone().into(),
559            values: e.values.iter().map(|v| v.clone().into()).collect(),
560        })
561        .collect()
562}
563
564/// Process raw sequence info into Sequence entities.
565///
566/// Sequences auto-owned by a serial/identity column (per `pg_depend`) are
567/// dropped here — PostgreSQL manages them, and surfacing them would make the
568/// diff engine DROP them or CREATE duplicates. Hand-managed sequences are kept
569/// even when their name matches the `{table}_{column}_seq` pattern.
570#[must_use]
571pub fn process_sequences(raw_sequences: &[RawSequenceInfo]) -> Vec<Sequence> {
572    raw_sequences
573        .iter()
574        .filter(|s| !is_system_namespace(&s.schema) && s.owned_by.is_none())
575        .map(|s| Sequence {
576            schema: s.schema.clone().into(),
577            name: s.name.clone().into(),
578            increment_by: s.increment.clone().map(Into::into),
579            min_value: s.min_value.clone().map(Into::into),
580            max_value: s.max_value.clone().map(Into::into),
581            start_with: s.start_value.clone().map(Into::into),
582            cache_size: s.cache_value.as_deref().and_then(|v| v.parse().ok()),
583            cycle: s.cycle,
584        })
585        .collect()
586}
587
588/// Process raw index info into Index entities
589#[must_use]
590pub fn process_indexes(raw_indexes: &[RawIndexInfo]) -> Vec<Index> {
591    use super::ddl::Opclass;
592
593    raw_indexes
594        .iter()
595        .filter(|i| !is_system_namespace(&i.schema) && !i.is_primary)
596        .map(|i| {
597            let columns: Vec<IndexColumn> = i
598                .columns
599                .iter()
600                .map(|c| IndexColumn {
601                    value: c.name.clone().into(),
602                    is_expression: c.is_expression,
603                    asc: c.asc,
604                    nulls_first: c.nulls_first,
605                    opclass: c.opclass.clone().map(Opclass::new),
606                })
607                .collect();
608
609            Index {
610                schema: i.schema.clone().into(),
611                table: i.table.clone().into(),
612                name: i.name.clone().into(),
613                name_explicit: true,
614                columns,
615                is_unique: i.is_unique,
616                where_clause: i.where_clause.clone().map(std::convert::Into::into),
617                method: Some(i.method.clone().into()),
618                concurrently: i.concurrent,
619                r#with: None,
620            }
621        })
622        .collect()
623}
624
625/// Process raw foreign key info into `ForeignKey` entities
626#[must_use]
627pub fn process_foreign_keys(raw_fks: &[RawForeignKeyInfo]) -> Vec<ForeignKey> {
628    raw_fks
629        .iter()
630        .filter(|f| !is_system_namespace(&f.schema))
631        .map(|f| ForeignKey {
632            schema: f.schema.clone().into(),
633            table: f.table.clone().into(),
634            name: f.name.clone().into(),
635            name_explicit: true,
636            columns: f.columns.iter().map(|c| c.clone().into()).collect(),
637            schema_to: f.schema_to.clone().into(),
638            table_to: f.table_to.clone().into(),
639            columns_to: f.columns_to.iter().map(|c| c.clone().into()).collect(),
640            on_update: Some(f.on_update.clone().into()),
641            on_delete: Some(f.on_delete.clone().into()),
642            deferrable: f.deferrable,
643            initially_deferred: f.initially_deferred,
644        })
645        .collect()
646}
647
648/// Process raw primary key info into `PrimaryKey` entities
649#[must_use]
650pub fn process_primary_keys(raw_pks: &[RawPrimaryKeyInfo]) -> Vec<PrimaryKey> {
651    raw_pks
652        .iter()
653        .filter(|p| !is_system_namespace(&p.schema))
654        .map(|p| PrimaryKey {
655            schema: p.schema.clone().into(),
656            table: p.table.clone().into(),
657            name: p.name.clone().into(),
658            name_explicit: true,
659            columns: p.columns.iter().map(|c| c.clone().into()).collect(),
660        })
661        .collect()
662}
663
664/// Process raw unique constraint info into `UniqueConstraint` entities
665#[must_use]
666pub fn process_unique_constraints(raw_uniques: &[RawUniqueInfo]) -> Vec<UniqueConstraint> {
667    raw_uniques
668        .iter()
669        .filter(|u| !is_system_namespace(&u.schema))
670        .map(|u| UniqueConstraint {
671            schema: u.schema.clone().into(),
672            table: u.table.clone().into(),
673            name: u.name.clone().into(),
674            name_explicit: true,
675            columns: u.columns.iter().map(|c| c.clone().into()).collect(),
676            nulls_not_distinct: u.nulls_not_distinct,
677            deferrable: u.deferrable,
678            initially_deferred: u.initially_deferred,
679        })
680        .collect()
681}
682
683/// Process raw check constraint info into `CheckConstraint` entities
684#[must_use]
685pub fn process_check_constraints(raw_checks: &[RawCheckInfo]) -> Vec<CheckConstraint> {
686    raw_checks
687        .iter()
688        .filter(|c| !is_system_namespace(&c.schema))
689        .map(|c| CheckConstraint {
690            schema: c.schema.clone().into(),
691            table: c.table.clone().into(),
692            name: c.name.clone().into(),
693            value: c.expression.clone().into(),
694        })
695        .collect()
696}
697
698/// Process raw view info into View entities
699#[must_use]
700pub fn process_views(raw_views: &[RawViewInfo]) -> Vec<View> {
701    raw_views
702        .iter()
703        .filter(|v| !is_system_namespace(&v.schema))
704        .map(|v| View {
705            schema: v.schema.clone().into(),
706            name: v.name.clone().into(),
707            definition: Some(v.definition.clone().into()),
708            materialized: v.is_materialized,
709            r#with: None,
710            is_existing: false,
711            with_no_data: None,
712            using: None,
713            tablespace: None,
714        })
715        .collect()
716}
717
718/// Process raw policy info into Policy entities
719#[must_use]
720pub fn process_policies(raw_policies: &[RawPolicyInfo]) -> Vec<Policy> {
721    use std::borrow::Cow;
722
723    raw_policies
724        .iter()
725        .filter(|p| !is_system_namespace(&p.schema))
726        .map(|p| {
727            let roles = p.to.iter().cloned().map(Cow::Owned).collect();
728
729            Policy {
730                schema: p.schema.clone().into(),
731                table: p.table.clone().into(),
732                name: p.name.clone().into(),
733                as_clause: Some(p.as_clause.clone().into()),
734                for_clause: Some(p.for_clause.clone().into()),
735                to: Some(roles),
736                using: p.using.clone().map(std::convert::Into::into),
737                with_check: p.with_check.clone().map(std::convert::Into::into),
738            }
739        })
740        .collect()
741}
742
743/// Process raw role info into Role entities
744#[must_use]
745pub fn process_roles(raw_roles: &[RawRoleInfo]) -> Vec<Role> {
746    raw_roles
747        .iter()
748        .filter(|r| !is_system_role(&r.name))
749        .map(|r| Role {
750            name: r.name.clone().into(),
751            superuser: None,
752            create_db: Some(r.create_db),
753            create_role: Some(r.create_role),
754            inherit: Some(r.inherit),
755            can_login: None,
756            replication: None,
757            bypass_rls: None,
758            conn_limit: None,
759            password: None,
760            valid_until: None,
761        })
762        .collect()
763}
764
765// =============================================================================
766// SQL Queries
767// =============================================================================
768
769/// SQL queries for `PostgreSQL` introspection
770pub mod queries {
771    /// Query to get all schemas
772    pub const SCHEMAS_QUERY: &str = r"
773        SELECT n.nspname AS name
774        FROM pg_namespace n
775        WHERE n.nspname NOT LIKE 'pg_%'
776          AND n.nspname != 'information_schema'
777          AND has_schema_privilege(current_user, n.oid, 'USAGE')
778        ORDER BY n.nspname
779    ";
780
781    /// Query to get all tables
782    pub const TABLES_QUERY: &str = r"
783        SELECT 
784            n.nspname AS schema,
785            c.relname AS name,
786            c.relrowsecurity AS is_rls_enabled,
787            c.relpersistence = 'u' AS is_unlogged,
788            c.relpersistence = 't' AS is_temporary,
789            tsp.spcname AS tablespace,
790            obj_description(c.oid, 'pg_class') AS comment
791        FROM pg_class c
792        JOIN pg_namespace n ON n.oid = c.relnamespace
793        LEFT JOIN pg_tablespace tsp ON tsp.oid = c.reltablespace
794        WHERE c.relkind IN ('r', 'p')
795          AND n.nspname NOT LIKE 'pg_%'
796          AND n.nspname != 'information_schema'
797          AND has_schema_privilege(current_user, n.oid, 'USAGE')
798          AND has_table_privilege(current_user, c.oid, 'SELECT')
799        ORDER BY n.nspname, c.relname
800    ";
801
802    /// Query to get all columns.
803    ///
804    /// Result-column positions are load-bearing: every driver decodes rows
805    /// by index. Two derived columns pack extra detail without changing the
806    /// shape:
807    ///
808    /// - `column_type` appends the type modifier reconstructed from
809    ///   `character_maximum_length` / `numeric_precision+scale`, so
810    ///   `varchar(255)` and `numeric(10,2)` survive introspection instead of
811    ///   degrading to bare `varchar` / `numeric`.
812    /// - `identity_type` is a JSON object
813    ///   `{type, start, increment, min, max, cycle}` built from
814    ///   `information_schema.columns` identity metadata (the legacy plain
815    ///   `ALWAYS` / `BY DEFAULT` strings are still accepted by
816    ///   `process_columns` for hand-built rows).
817    pub const COLUMNS_QUERY: &str = r"
818        SELECT
819            c.table_schema AS schema,
820            c.table_name AS table,
821            c.column_name AS name,
822            c.udt_name || CASE
823                WHEN c.data_type != 'ARRAY' AND c.character_maximum_length IS NOT NULL
824                    THEN '(' || c.character_maximum_length || ')'
825                WHEN c.udt_name IN ('numeric', 'decimal')
826                     AND c.numeric_precision IS NOT NULL
827                     AND c.numeric_scale IS NOT NULL
828                    THEN '(' || c.numeric_precision || ',' || c.numeric_scale || ')'
829                ELSE ''
830            END AS column_type,
831            c.udt_schema AS type_schema,
832            c.is_nullable = 'NO' AS not_null,
833            c.column_default AS default_value,
834            c.is_identity = 'YES' AS is_identity,
835            CASE
836                WHEN c.is_identity = 'YES' THEN json_build_object(
837                    'type', c.identity_generation,
838                    'start', c.identity_start,
839                    'increment', c.identity_increment,
840                    'min', c.identity_minimum,
841                    'max', c.identity_maximum,
842                    'cycle', c.identity_cycle = 'YES'
843                )::text
844                ELSE NULL
845            END AS identity_type,
846            c.is_generated = 'ALWAYS' AS is_generated,
847            c.generation_expression AS generated_expression,
848            COALESCE(a.attgenerated = 's', false) AS generated_stored,
849            NULLIF(a.attndims::int4, 0) AS dimensions,
850            col_description(cls.oid, a.attnum) AS comment,
851            c.ordinal_position
852        FROM information_schema.columns c
853        LEFT JOIN pg_namespace n
854          ON n.nspname = c.table_schema
855        LEFT JOIN pg_class cls
856          ON cls.relnamespace = n.oid
857         AND cls.relname = c.table_name
858        LEFT JOIN pg_attribute a
859          ON a.attrelid = cls.oid
860         AND a.attname = c.column_name
861         AND a.attnum > 0
862         AND NOT a.attisdropped
863        WHERE c.table_schema NOT LIKE 'pg_%'
864          AND c.table_schema != 'information_schema'
865          AND n.oid IS NOT NULL
866          AND has_schema_privilege(current_user, n.oid, 'USAGE')
867        UNION ALL
868        -- Materialized-view columns: information_schema.columns excludes
869        -- matviews entirely, so read them straight from pg_attribute.
870        SELECT
871            mn.nspname AS schema,
872            mc.relname AS table,
873            ma.attname AS name,
874            mt.typname || COALESCE(
875                substring(format_type(ma.atttypid, ma.atttypmod) from '\(.*\)'),
876                ''
877            ) AS column_type,
878            mtn.nspname AS type_schema,
879            ma.attnotnull AS not_null,
880            NULL::text AS default_value,
881            FALSE AS is_identity,
882            NULL::text AS identity_type,
883            FALSE AS is_generated,
884            NULL::text AS generated_expression,
885            FALSE AS generated_stored,
886            NULLIF(ma.attndims::int4, 0) AS dimensions,
887            col_description(mc.oid, ma.attnum) AS comment,
888            ma.attnum::int4 AS ordinal_position
889        FROM pg_class mc
890        JOIN pg_namespace mn ON mn.oid = mc.relnamespace
891        JOIN pg_attribute ma ON ma.attrelid = mc.oid
892        JOIN pg_type mt ON mt.oid = ma.atttypid
893        JOIN pg_namespace mtn ON mtn.oid = mt.typnamespace
894        WHERE mc.relkind = 'm'
895          AND ma.attnum > 0
896          AND NOT ma.attisdropped
897          AND mn.nspname NOT LIKE 'pg_%'
898          AND mn.nspname != 'information_schema'
899          AND has_schema_privilege(current_user, mn.oid, 'USAGE')
900          AND has_table_privilege(current_user, mc.oid, 'SELECT')
901        ORDER BY 1, 2, 15
902    ";
903
904    /// Query to get all enums
905    pub const ENUMS_QUERY: &str = r"
906        SELECT 
907            n.nspname AS schema,
908            t.typname AS name,
909            array_agg(e.enumlabel ORDER BY e.enumsortorder) AS values
910        FROM pg_type t
911        JOIN pg_enum e ON t.oid = e.enumtypid
912        JOIN pg_namespace n ON n.oid = t.typnamespace
913        WHERE n.nspname NOT LIKE 'pg_%'
914          AND n.nspname != 'information_schema'
915          AND has_schema_privilege(current_user, n.oid, 'USAGE')
916        GROUP BY n.nspname, t.typname
917        ORDER BY n.nspname, t.typname
918    ";
919
920    /// Query to get all sequences
921    ///
922    /// Uses the underlying `pg_sequence` + `pg_class` catalog tables instead
923    /// of the `pg_sequences` convenience view.  The view internally calls
924    /// `pg_sequence_parameters()` which can fail when sequences are being
925    /// dropped concurrently (e.g. during parallel test runs).  Direct
926    /// catalog access is fully MVCC-protected and avoids this issue.
927    ///
928    /// Value columns are nullable because the current user may lack
929    /// privilege on the sequence.
930    pub const SEQUENCES_QUERY: &str = r"
931        SELECT
932            n.nspname AS schema,
933            c.relname AS name,
934            format_type(s.seqtypid, NULL)::text AS data_type,
935            s.seqstart::text AS start_value,
936            s.seqmin::text AS min_value,
937            s.seqmax::text AS max_value,
938            s.seqincrement::text AS increment,
939            s.seqcycle AS cycle,
940            s.seqcache::text AS cache_value,
941            -- Owning column's schema.table when the sequence is auto-owned by
942            -- a serial (deptype 'a') or identity (deptype 'i') column; NULL
943            -- for standalone, hand-managed sequences.
944            (
945                SELECT format('%s.%s', dn.nspname, dc.relname)
946                FROM pg_depend d
947                JOIN pg_class dc ON dc.oid = d.refobjid
948                JOIN pg_namespace dn ON dn.oid = dc.relnamespace
949                WHERE d.objid = s.seqrelid
950                  AND d.classid = 'pg_class'::regclass
951                  AND d.refclassid = 'pg_class'::regclass
952                  AND d.refobjsubid > 0
953                  AND d.deptype IN ('a', 'i')
954                LIMIT 1
955            )::text AS owned_by
956        FROM pg_sequence s
957        JOIN pg_class c ON c.oid = s.seqrelid
958        JOIN pg_namespace n ON n.oid = c.relnamespace
959        WHERE n.nspname NOT LIKE 'pg_%'
960          AND n.nspname != 'information_schema'
961          AND has_schema_privilege(current_user, n.oid, 'USAGE')
962          AND (
963              -- Reference s.seqrelid (not c.oid) so this qual only depends on
964              -- pg_sequence: PostgreSQL 18's planner can push it down to the
965              -- pg_class scan before the join filters to sequences, and
966              -- has_sequence_privilege errors on non-sequence relations.
967              has_sequence_privilege(current_user, s.seqrelid, 'USAGE')
968              OR has_sequence_privilege(current_user, s.seqrelid, 'SELECT')
969          )
970        ORDER BY n.nspname, c.relname
971    ";
972
973    /// Query to get all views.
974    ///
975    /// Accepts `$1::text[]` — when non-NULL, scopes to those schemas;
976    /// when NULL, returns all non-system views.
977    pub const VIEWS_QUERY: &str = r"
978        SELECT
979            n.nspname AS schema,
980            c.relname AS name,
981            pg_get_viewdef(c.oid) AS definition,
982            FALSE AS is_materialized
983        FROM pg_class c
984        JOIN pg_namespace n ON n.oid = c.relnamespace
985        WHERE (
986            ($1::text[] IS NOT NULL AND n.nspname = ANY($1::text[]))
987            OR ($1::text[] IS NULL AND n.nspname NOT LIKE 'pg_%'
988                AND n.nspname != 'information_schema')
989        )
990          AND c.relkind = 'v'
991          AND has_schema_privilege(current_user, n.oid, 'USAGE')
992          AND has_table_privilege(current_user, c.oid, 'SELECT')
993          AND pg_get_viewdef(c.oid) IS NOT NULL
994        UNION ALL
995        SELECT
996            n.nspname AS schema,
997            c.relname AS name,
998            pg_get_viewdef(c.oid) AS definition,
999            TRUE AS is_materialized
1000        FROM pg_class c
1001        JOIN pg_namespace n ON n.oid = c.relnamespace
1002        WHERE (
1003            ($1::text[] IS NOT NULL AND n.nspname = ANY($1::text[]))
1004            OR ($1::text[] IS NULL AND n.nspname NOT LIKE 'pg_%'
1005                AND n.nspname != 'information_schema')
1006        )
1007          AND c.relkind = 'm'
1008          AND has_schema_privilege(current_user, n.oid, 'USAGE')
1009          AND has_table_privilege(current_user, c.oid, 'SELECT')
1010          AND pg_get_viewdef(c.oid) IS NOT NULL
1011        ORDER BY schema, name
1012    ";
1013
1014    /// Query to get all indexes
1015    pub const INDEXES_QUERY: &str = r"
1016SELECT
1017    ns.nspname AS schema,
1018    tbl.relname AS table,
1019    idx.relname AS name,
1020    ix.indisunique AS is_unique,
1021    ix.indisprimary AS is_primary,
1022    am.amname AS method,
1023    array_agg(pg_get_indexdef(ix.indexrelid, s.n, true) ORDER BY s.n) AS columns,
1024    pg_get_expr(ix.indpred, ix.indrelid) AS where_clause
1025FROM pg_index ix
1026JOIN pg_class idx ON idx.oid = ix.indexrelid
1027JOIN pg_class tbl ON tbl.oid = ix.indrelid
1028JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
1029JOIN pg_am am ON am.oid = idx.relam
1030JOIN generate_series(1, ix.indnkeyatts) AS s(n) ON TRUE
1031WHERE ns.nspname NOT LIKE 'pg_%'
1032  AND ns.nspname <> 'information_schema'
1033  AND has_schema_privilege(current_user, ns.oid, 'USAGE')
1034  AND has_table_privilege(current_user, tbl.oid, 'SELECT')
1035GROUP BY ns.nspname, tbl.relname, idx.relname, ix.indisunique, ix.indisprimary, am.amname, ix.indpred, ix.indrelid
1036ORDER BY ns.nspname, tbl.relname, idx.relname
1037";
1038
1039    /// Schema-filtered variant of [`INDEXES_QUERY`].
1040    ///
1041    /// `pg_get_indexdef()` calls `relation_open()` which is not
1042    /// MVCC-protected and can fail if concurrent DDL drops an index.
1043    /// Scoping to specific schemas (`$1::text[]`) avoids encountering
1044    /// OIDs from schemas being modified by other sessions.
1045    pub const INDEXES_QUERY_FILTERED: &str = r"
1046SELECT
1047    ns.nspname AS schema,
1048    tbl.relname AS table,
1049    idx.relname AS name,
1050    ix.indisunique AS is_unique,
1051    ix.indisprimary AS is_primary,
1052    am.amname AS method,
1053    array_agg(pg_get_indexdef(ix.indexrelid, s.n, true) ORDER BY s.n) AS columns,
1054    pg_get_expr(ix.indpred, ix.indrelid) AS where_clause
1055FROM pg_index ix
1056JOIN pg_class idx ON idx.oid = ix.indexrelid
1057JOIN pg_class tbl ON tbl.oid = ix.indrelid
1058JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
1059JOIN pg_am am ON am.oid = idx.relam
1060JOIN generate_series(1, ix.indnkeyatts) AS s(n) ON TRUE
1061WHERE ns.nspname = ANY($1::text[])
1062  AND has_schema_privilege(current_user, ns.oid, 'USAGE')
1063  AND has_table_privilege(current_user, tbl.oid, 'SELECT')
1064GROUP BY ns.nspname, tbl.relname, idx.relname, ix.indisunique, ix.indisprimary, am.amname, ix.indpred, ix.indrelid
1065ORDER BY ns.nspname, tbl.relname, idx.relname
1066";
1067
1068    /// Query to get all foreign keys
1069    pub const FOREIGN_KEYS_QUERY: &str = r"
1070SELECT
1071    ns.nspname AS schema,
1072    tbl.relname AS table,
1073    con.conname AS name,
1074    array_agg(src.attname ORDER BY s.ord) AS columns,
1075    ns_to.nspname AS schema_to,
1076    tbl_to.relname AS table_to,
1077    array_agg(dst.attname ORDER BY s.ord) AS columns_to,
1078    con.confupdtype::text AS on_update,
1079    con.confdeltype::text AS on_delete,
1080    con.condeferrable AS deferrable,
1081    con.condeferred AS initially_deferred
1082FROM pg_constraint con
1083JOIN pg_class tbl ON tbl.oid = con.conrelid
1084JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
1085JOIN pg_class tbl_to ON tbl_to.oid = con.confrelid
1086JOIN pg_namespace ns_to ON ns_to.oid = tbl_to.relnamespace
1087JOIN unnest(con.conkey) WITH ORDINALITY AS s(attnum, ord) ON TRUE
1088JOIN pg_attribute src ON src.attrelid = tbl.oid AND src.attnum = s.attnum
1089JOIN unnest(con.confkey) WITH ORDINALITY AS r(attnum, ord) ON r.ord = s.ord
1090JOIN pg_attribute dst ON dst.attrelid = tbl_to.oid AND dst.attnum = r.attnum
1091WHERE con.contype = 'f'
1092  AND ns.nspname NOT LIKE 'pg_%'
1093  AND ns.nspname <> 'information_schema'
1094  AND has_schema_privilege(current_user, ns.oid, 'USAGE')
1095  AND has_table_privilege(current_user, tbl.oid, 'SELECT')
1096GROUP BY ns.nspname, tbl.relname, con.conname, ns_to.nspname, tbl_to.relname, con.confupdtype, con.confdeltype, con.condeferrable, con.condeferred
1097ORDER BY ns.nspname, tbl.relname, con.conname
1098";
1099
1100    /// Query to get all primary keys
1101    pub const PRIMARY_KEYS_QUERY: &str = r"
1102SELECT
1103    ns.nspname AS schema,
1104    tbl.relname AS table,
1105    con.conname AS name,
1106    array_agg(att.attname ORDER BY s.ord) AS columns
1107FROM pg_constraint con
1108JOIN pg_class tbl ON tbl.oid = con.conrelid
1109JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
1110JOIN unnest(con.conkey) WITH ORDINALITY AS s(attnum, ord) ON TRUE
1111JOIN pg_attribute att ON att.attrelid = tbl.oid AND att.attnum = s.attnum
1112WHERE con.contype = 'p'
1113  AND ns.nspname NOT LIKE 'pg_%'
1114  AND ns.nspname <> 'information_schema'
1115  AND has_schema_privilege(current_user, ns.oid, 'USAGE')
1116  AND has_table_privilege(current_user, tbl.oid, 'SELECT')
1117GROUP BY ns.nspname, tbl.relname, con.conname
1118ORDER BY ns.nspname, tbl.relname, con.conname
1119";
1120
1121    /// Query to get all unique constraints.
1122    ///
1123    /// `nulls_not_distinct` reads `pg_index.indnullsnotdistinct` through the
1124    /// constraint's `conindid`. The column only exists on PostgreSQL 15+,
1125    /// so it is accessed through `to_jsonb(...) ->> 'indnullsnotdistinct'` —
1126    /// on older servers the key is simply absent and the value defaults to
1127    /// FALSE, keeping the query parseable on every supported version.
1128    pub const UNIQUES_QUERY: &str = r"
1129SELECT
1130    ns.nspname AS schema,
1131    tbl.relname AS table,
1132    con.conname AS name,
1133    array_agg(att.attname ORDER BY s.ord) AS columns,
1134    COALESCE((
1135        SELECT (to_jsonb(ix) ->> 'indnullsnotdistinct')::bool
1136        FROM pg_index ix
1137        WHERE ix.indexrelid = con.conindid
1138    ), FALSE) AS nulls_not_distinct,
1139    con.condeferrable AS deferrable,
1140    con.condeferred AS initially_deferred
1141FROM pg_constraint con
1142JOIN pg_class tbl ON tbl.oid = con.conrelid
1143JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
1144JOIN unnest(con.conkey) WITH ORDINALITY AS s(attnum, ord) ON TRUE
1145JOIN pg_attribute att ON att.attrelid = tbl.oid AND att.attnum = s.attnum
1146WHERE con.contype = 'u'
1147  AND ns.nspname NOT LIKE 'pg_%'
1148  AND ns.nspname <> 'information_schema'
1149  AND has_schema_privilege(current_user, ns.oid, 'USAGE')
1150  AND has_table_privilege(current_user, tbl.oid, 'SELECT')
1151GROUP BY ns.nspname, tbl.relname, con.conname, con.conindid, con.condeferrable, con.condeferred
1152ORDER BY ns.nspname, tbl.relname, con.conname
1153";
1154
1155    /// Query to get all check constraints
1156    pub const CHECKS_QUERY: &str = r"
1157SELECT
1158    ns.nspname AS schema,
1159    tbl.relname AS table,
1160    con.conname AS name,
1161    pg_get_expr(con.conbin, con.conrelid) AS expression
1162FROM pg_constraint con
1163JOIN pg_class tbl ON tbl.oid = con.conrelid
1164JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
1165WHERE con.contype = 'c'
1166  AND ns.nspname NOT LIKE 'pg_%'
1167  AND ns.nspname <> 'information_schema'
1168  AND has_schema_privilege(current_user, ns.oid, 'USAGE')
1169  AND has_table_privilege(current_user, tbl.oid, 'SELECT')
1170ORDER BY ns.nspname, tbl.relname, con.conname
1171";
1172
1173    /// Schema-filtered variant of [`CHECKS_QUERY`].
1174    ///
1175    /// `pg_get_expr()` calls `relation_open()` which is not MVCC-protected.
1176    /// Scoping to specific schemas avoids encountering OIDs from
1177    /// schemas being modified by other sessions.
1178    pub const CHECKS_QUERY_FILTERED: &str = r"
1179SELECT
1180    ns.nspname AS schema,
1181    tbl.relname AS table,
1182    con.conname AS name,
1183    pg_get_expr(con.conbin, con.conrelid) AS expression
1184FROM pg_constraint con
1185JOIN pg_class tbl ON tbl.oid = con.conrelid
1186JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
1187WHERE con.contype = 'c'
1188  AND ns.nspname = ANY($1::text[])
1189  AND has_schema_privilege(current_user, ns.oid, 'USAGE')
1190  AND has_table_privilege(current_user, tbl.oid, 'SELECT')
1191ORDER BY ns.nspname, tbl.relname, con.conname
1192";
1193
1194    /// Query to get all roles
1195    pub const ROLES_QUERY: &str = r"
1196SELECT
1197    rolname AS name,
1198    rolcreatedb AS create_db,
1199    rolcreaterole AS create_role,
1200    rolinherit AS inherit
1201FROM pg_roles
1202ORDER BY rolname
1203";
1204
1205    /// Query to get all policies
1206    pub const POLICIES_QUERY: &str = r#"
1207SELECT
1208    n.nspname AS schema,
1209    c.relname AS table,
1210    p.polname AS name,
1211    CASE
1212        WHEN p.polpermissive THEN 'PERMISSIVE'::text
1213        ELSE 'RESTRICTIVE'::text
1214    END AS as_clause,
1215    CASE p.polcmd
1216        WHEN 'r'::"char" THEN 'SELECT'::text
1217        WHEN 'a'::"char" THEN 'INSERT'::text
1218        WHEN 'w'::"char" THEN 'UPDATE'::text
1219        WHEN 'd'::"char" THEN 'DELETE'::text
1220        WHEN '*'::"char" THEN 'ALL'::text
1221        ELSE NULL::text
1222    END AS for_clause,
1223    CASE
1224        WHEN p.polroles = '{0}'::oid[] THEN (string_to_array('public'::text, ''::text))::name[]
1225        ELSE ARRAY(
1226            SELECT pg_authid.rolname
1227            FROM pg_authid
1228            WHERE pg_authid.oid = ANY(p.polroles)
1229            ORDER BY pg_authid.rolname
1230        )
1231    END AS to,
1232    pg_get_expr(p.polqual, p.polrelid) AS using,
1233    pg_get_expr(p.polwithcheck, p.polrelid) AS with_check
1234FROM pg_policy p
1235JOIN pg_class c ON c.oid = p.polrelid
1236JOIN pg_namespace n ON n.oid = c.relnamespace
1237WHERE n.nspname NOT LIKE 'pg_%'
1238  AND n.nspname <> 'information_schema'
1239  AND has_schema_privilege(current_user, n.oid, 'USAGE')
1240  AND has_table_privilege(current_user, c.oid, 'SELECT')
1241ORDER BY n.nspname, c.relname, p.polname
1242"#;
1243}
1244
1245// =============================================================================
1246// Utility Functions
1247// =============================================================================
1248
1249/// Convert `PostgreSQL` foreign key action codes to human-readable strings.
1250///
1251/// `PostgreSQL` stores FK actions as single-character codes in `pg_constraint`.
1252#[must_use]
1253pub fn action_code_to_string(code: &str) -> String {
1254    match code {
1255        "r" => "RESTRICT",
1256        "c" => "CASCADE",
1257        "n" => "SET NULL",
1258        "d" => "SET DEFAULT",
1259        // "a" (NO ACTION) and any unknown code fall through to NO ACTION.
1260        _ => "NO ACTION",
1261    }
1262    .to_string()
1263}
1264
1265/// Strip one trailing directive token (case-insensitive) from `value`,
1266/// returning the remainder when the directive was present at the end.
1267fn strip_trailing_directive<'a>(value: &'a str, directive: &str) -> Option<&'a str> {
1268    let value = value.trim_end();
1269    if value.len() <= directive.len() {
1270        return None;
1271    }
1272    let split = value.len() - directive.len();
1273    if !value.is_char_boundary(split) {
1274        return None;
1275    }
1276    let (head, tail) = value.split_at(split);
1277    if tail.eq_ignore_ascii_case(directive) && head.ends_with(char::is_whitespace) {
1278        Some(head.trim_end())
1279    } else {
1280        None
1281    }
1282}
1283
1284/// Check whether a string is a plain (unquoted) SQL identifier.
1285fn is_plain_identifier(value: &str) -> bool {
1286    !value.is_empty()
1287        && value
1288            .chars()
1289            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1290        && value
1291            .chars()
1292            .next()
1293            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1294}
1295
1296/// Check whether every parenthesis in `value` is balanced and none of them
1297/// sit inside quotes we'd mis-parse. (Quotes are not tracked — good enough
1298/// for `pg_get_indexdef` output, which quotes identifiers, not parens.)
1299fn parens_balanced(value: &str) -> bool {
1300    let mut depth = 0_i32;
1301    for ch in value.chars() {
1302        match ch {
1303            '(' => depth += 1,
1304            ')' => {
1305                depth -= 1;
1306                if depth < 0 {
1307                    return false;
1308                }
1309            }
1310            _ => {}
1311        }
1312    }
1313    depth == 0
1314}
1315
1316/// Unquote a double-quoted identifier, unescaping doubled quotes
1317/// (`"userName"` → `userName`, `"say""hi"""` → `say"hi"`).
1318fn unquote_identifier(value: &str) -> Option<String> {
1319    let inner = value.strip_prefix('"')?.strip_suffix('"')?;
1320    Some(inner.replace("\"\"", "\""))
1321}
1322
1323/// Parse raw index column strings from `pg_get_indexdef` into `RawIndexColumnInfo`.
1324///
1325/// Each string is a single column element like `"name"`, `"\"userName\" DESC"`,
1326/// `"lower(email) varchar_pattern_ops"`, `"(price * quantity)"`, or
1327/// `"col DESC NULLS LAST"`.
1328#[must_use]
1329pub fn parse_index_columns(cols: Vec<String>) -> Vec<RawIndexColumnInfo> {
1330    cols.into_iter()
1331        .map(|c| {
1332            let mut core = c.trim().to_string();
1333
1334            // Strip trailing ordering directives only — never tokens inside
1335            // the expression itself. `NULLS FIRST`/`NULLS LAST` come after
1336            // `ASC`/`DESC` in pg_get_indexdef output.
1337            let mut nulls_first: Option<bool> = None;
1338            if let Some(rest) = strip_trailing_directive(&core, "NULLS FIRST") {
1339                nulls_first = Some(true);
1340                core = rest.to_string();
1341            } else if let Some(rest) = strip_trailing_directive(&core, "NULLS LAST") {
1342                nulls_first = Some(false);
1343                core = rest.to_string();
1344            }
1345            let asc = if let Some(rest) = strip_trailing_directive(&core, "DESC") {
1346                core = rest.to_string();
1347                false
1348            } else {
1349                if let Some(rest) = strip_trailing_directive(&core, "ASC") {
1350                    core = rest.to_string();
1351                }
1352                true
1353            };
1354            // In PostgreSQL, DESC implies NULLS FIRST unless NULLS LAST was
1355            // given explicitly; ASC implies NULLS LAST.
1356            let nulls_first = nulls_first.unwrap_or(!asc);
1357
1358            // Split off a trailing operator class token, but only when the
1359            // remainder is still well-formed: the opclass must be a plain
1360            // identifier and the rest must have balanced parentheses.
1361            // Expressions like `(price * quantity)` stay verbatim.
1362            let mut opclass: Option<String> = None;
1363            if let Some(idx) = core.rfind(char::is_whitespace) {
1364                let candidate = core[idx..].trim();
1365                let rest = core[..idx].trim_end();
1366                if is_plain_identifier(candidate)
1367                    && (candidate.ends_with("_ops")
1368                        || super::grammar::VECTOR_OPS.contains(&candidate))
1369                    && !rest.is_empty()
1370                    && parens_balanced(rest)
1371                {
1372                    opclass = Some(candidate.to_string());
1373                    core = rest.to_string();
1374                }
1375            }
1376
1377            // Plain quoted identifiers are unquoted (re-rendering quotes them
1378            // again); anything with expression syntax is stored verbatim.
1379            let (name, is_expression) = if let Some(unquoted) = unquote_identifier(&core) {
1380                (unquoted, false)
1381            } else if is_plain_identifier(&core) {
1382                (core, false)
1383            } else {
1384                (core, true)
1385            };
1386
1387            RawIndexColumnInfo {
1388                name,
1389                is_expression,
1390                asc,
1391                nulls_first,
1392                opclass,
1393            }
1394        })
1395        .collect()
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400    use super::*;
1401
1402    #[test]
1403    fn test_process_tables() {
1404        let raw = vec![
1405            RawTableInfo {
1406                schema: "public".to_string(),
1407                name: "users".to_string(),
1408                is_unlogged: false,
1409                is_temporary: false,
1410                tablespace: None,
1411                comment: None,
1412                is_rls_enabled: false,
1413            },
1414            RawTableInfo {
1415                schema: "pg_catalog".to_string(),
1416                name: "pg_class".to_string(),
1417                is_unlogged: false,
1418                is_temporary: false,
1419                tablespace: None,
1420                comment: None,
1421                is_rls_enabled: false,
1422            },
1423        ];
1424
1425        let tables = process_tables(&raw);
1426        assert_eq!(tables.len(), 1);
1427        assert_eq!(tables[0].name, "users");
1428    }
1429
1430    #[test]
1431    fn test_introspection_result_to_snapshot() {
1432        let mut result = IntrospectionResult::default();
1433        result.schemas.push(Schema::new("public"));
1434        result.tables.push(Table {
1435            schema: "public".into(),
1436            name: "users".into(),
1437            is_unlogged: None,
1438            is_temporary: None,
1439            inherits: None,
1440            tablespace: None,
1441            is_rls_enabled: None,
1442            comment: None,
1443        });
1444
1445        let snapshot = result.to_snapshot();
1446        assert_eq!(snapshot.ddl.len(), 2);
1447    }
1448
1449    #[test]
1450    fn parse_index_columns_handles_realistic_indexdef_output() {
1451        let cols = parse_index_columns(vec![
1452            "(price * quantity)".to_string(),
1453            "\"userName\" DESC".to_string(),
1454            "lower(email) varchar_pattern_ops".to_string(),
1455            "col DESC NULLS LAST".to_string(),
1456            "\"say\"\"hi\"\"\"".to_string(),
1457            "plain_col".to_string(),
1458            "name text_pattern_ops".to_string(),
1459        ]);
1460
1461        // Expression stays verbatim — no opclass split of `*` / `quantity)`.
1462        assert_eq!(cols[0].name, "(price * quantity)");
1463        assert!(cols[0].is_expression);
1464        assert_eq!(cols[0].opclass, None);
1465        assert!(cols[0].asc);
1466        assert!(!cols[0].nulls_first);
1467
1468        // Quoted identifier is unquoted; DESC implies NULLS FIRST.
1469        assert_eq!(cols[1].name, "userName");
1470        assert!(!cols[1].is_expression);
1471        assert!(!cols[1].asc);
1472        assert!(cols[1].nulls_first);
1473
1474        // Expression + trailing opclass token.
1475        assert_eq!(cols[2].name, "lower(email)");
1476        assert!(cols[2].is_expression);
1477        assert_eq!(cols[2].opclass.as_deref(), Some("varchar_pattern_ops"));
1478
1479        // Explicit NULLS LAST wins over the DESC default.
1480        assert_eq!(cols[3].name, "col");
1481        assert!(!cols[3].asc);
1482        assert!(!cols[3].nulls_first);
1483
1484        // Doubled quotes are unescaped.
1485        assert_eq!(cols[4].name, "say\"hi\"");
1486        assert!(!cols[4].is_expression);
1487
1488        // Plain identifier, defaults.
1489        assert_eq!(cols[5].name, "plain_col");
1490        assert!(!cols[5].is_expression);
1491        assert!(cols[5].asc);
1492        assert!(!cols[5].nulls_first);
1493
1494        // Identifier + opclass.
1495        assert_eq!(cols[6].name, "name");
1496        assert!(!cols[6].is_expression);
1497        assert_eq!(cols[6].opclass.as_deref(), Some("text_pattern_ops"));
1498    }
1499
1500    #[test]
1501    fn process_columns_populates_identity_options_from_packed_json() {
1502        let raw = RawColumnInfo {
1503            schema: "public".to_string(),
1504            table: "users".to_string(),
1505            name: "id".to_string(),
1506            column_type: "int4".to_string(),
1507            type_schema: Some("pg_catalog".to_string()),
1508            not_null: true,
1509            default_value: None,
1510            is_identity: true,
1511            identity_type: Some(
1512                r#"{"type":"ALWAYS","start":"100","increment":"5","min":"1","max":"1000","cycle":true}"#
1513                    .to_string(),
1514            ),
1515            is_generated: false,
1516            generated_expression: None,
1517            generated_stored: false,
1518            dimensions: None,
1519            comment: None,
1520            ordinal_position: 1,
1521        };
1522
1523        let columns = process_columns(&[raw]);
1524        let identity = columns[0].identity.as_ref().expect("identity");
1525        assert_eq!(identity.start_with.as_deref(), Some("100"));
1526        assert_eq!(identity.increment.as_deref(), Some("5"));
1527        assert_eq!(identity.min_value.as_deref(), Some("1"));
1528        assert_eq!(identity.max_value.as_deref(), Some("1000"));
1529        assert_eq!(identity.cycle, Some(true));
1530    }
1531
1532    #[test]
1533    fn process_columns_accepts_legacy_plain_identity_type() {
1534        let raw = RawColumnInfo {
1535            schema: "public".to_string(),
1536            table: "users".to_string(),
1537            name: "id".to_string(),
1538            column_type: "int4".to_string(),
1539            type_schema: Some("pg_catalog".to_string()),
1540            not_null: true,
1541            default_value: None,
1542            is_identity: true,
1543            identity_type: Some("BY DEFAULT".to_string()),
1544            is_generated: false,
1545            generated_expression: None,
1546            generated_stored: false,
1547            dimensions: None,
1548            comment: None,
1549            ordinal_position: 1,
1550        };
1551
1552        let columns = process_columns(&[raw]);
1553        let identity = columns[0].identity.as_ref().expect("identity");
1554        assert_eq!(identity.type_, super::super::ddl::IdentityType::ByDefault);
1555        assert_eq!(identity.increment, None);
1556    }
1557
1558    #[test]
1559    fn postgres_catalog_queries_are_privilege_scoped() {
1560        use queries::{
1561            CHECKS_QUERY, COLUMNS_QUERY, ENUMS_QUERY, FOREIGN_KEYS_QUERY, INDEXES_QUERY,
1562            POLICIES_QUERY, PRIMARY_KEYS_QUERY, SCHEMAS_QUERY, SEQUENCES_QUERY, TABLES_QUERY,
1563            UNIQUES_QUERY, VIEWS_QUERY,
1564        };
1565
1566        for query in [
1567            SCHEMAS_QUERY,
1568            TABLES_QUERY,
1569            COLUMNS_QUERY,
1570            ENUMS_QUERY,
1571            SEQUENCES_QUERY,
1572            VIEWS_QUERY,
1573            INDEXES_QUERY,
1574            FOREIGN_KEYS_QUERY,
1575            PRIMARY_KEYS_QUERY,
1576            UNIQUES_QUERY,
1577            CHECKS_QUERY,
1578            POLICIES_QUERY,
1579        ] {
1580            assert!(
1581                query.contains("has_schema_privilege"),
1582                "query is not schema-privilege scoped: {query}"
1583            );
1584        }
1585
1586        for query in [
1587            TABLES_QUERY,
1588            VIEWS_QUERY,
1589            INDEXES_QUERY,
1590            FOREIGN_KEYS_QUERY,
1591            PRIMARY_KEYS_QUERY,
1592            UNIQUES_QUERY,
1593            CHECKS_QUERY,
1594            POLICIES_QUERY,
1595        ] {
1596            assert!(
1597                query.contains("has_table_privilege"),
1598                "query is not table-privilege scoped: {query}"
1599            );
1600        }
1601    }
1602}