Skip to main content

ssh_commander_core/postgres/
introspect.rs

1//! Schema introspection queries against `pg_catalog`.
2//!
3//! `information_schema` is portable but slow on large databases (the views
4//! join across many tables and filter by privilege). `pg_catalog` is direct,
5//! orders of magnitude faster, and exposes the metadata Postgres tooling
6//! actually needs (relkind, oids, persistence). All queries are
7//! parameter-less SELECTs to keep the surface boring and review-able.
8
9use serde::{Deserialize, Serialize};
10use tokio_postgres::{Client, Error as PgDriverError};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct DbSummary {
14    pub name: String,
15    /// True for `template0` / `template1`. Filtered out by `list_databases`
16    /// but the field is preserved so callers can show templates if desired.
17    pub is_template: bool,
18    pub owner: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SchemaSummary {
23    pub name: String,
24    pub owner: String,
25    /// True for `pg_catalog`, `information_schema`, `pg_toast`, etc.
26    pub is_system: bool,
27}
28
29/// Discriminator for tables / views / matviews / partitioned tables /
30/// foreign tables. Matches `pg_class.relkind` values that an explorer cares
31/// about. Sequences, indexes, composite types, and TOAST relations are
32/// excluded from `list_relations` and so do not appear here.
33#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
34pub enum RelationKind {
35    Table,
36    View,
37    MaterializedView,
38    PartitionedTable,
39    ForeignTable,
40}
41
42impl RelationKind {
43    fn from_relkind(c: i8) -> Option<Self> {
44        match c as u8 as char {
45            'r' => Some(Self::Table),
46            'v' => Some(Self::View),
47            'm' => Some(Self::MaterializedView),
48            'p' => Some(Self::PartitionedTable),
49            'f' => Some(Self::ForeignTable),
50            _ => None,
51        }
52    }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Relation {
57    pub schema: String,
58    pub name: String,
59    pub kind: RelationKind,
60    pub owner: String,
61    /// Estimated row count from `pg_class.reltuples`. `-1` if statistics
62    /// have never been gathered (fresh table).
63    pub estimated_rows: f32,
64}
65
66pub async fn list_databases(client: &Client) -> Result<Vec<DbSummary>, PgDriverError> {
67    // Excludes templates so the default tree is ergonomic. UI can issue a
68    // separate "show system" query if needed.
69    let rows = client
70        .query(
71            "SELECT d.datname,
72                    d.datistemplate,
73                    pg_catalog.pg_get_userbyid(d.datdba) AS owner
74               FROM pg_catalog.pg_database d
75              WHERE NOT d.datistemplate
76              ORDER BY d.datname",
77            &[],
78        )
79        .await?;
80    Ok(rows
81        .into_iter()
82        .map(|r| DbSummary {
83            name: r.get(0),
84            is_template: r.get(1),
85            owner: r.get(2),
86        })
87        .collect())
88}
89
90pub async fn list_schemas(client: &Client) -> Result<Vec<SchemaSummary>, PgDriverError> {
91    // We include system schemas but tag them so the UI can collapse them
92    // under a "System" group instead of polluting the top level.
93    let rows = client
94        .query(
95            "SELECT n.nspname,
96                    pg_catalog.pg_get_userbyid(n.nspowner) AS owner,
97                    (n.nspname IN ('pg_catalog', 'information_schema', 'pg_toast')
98                     OR n.nspname LIKE 'pg_temp_%'
99                     OR n.nspname LIKE 'pg_toast_temp_%') AS is_system
100               FROM pg_catalog.pg_namespace n
101              ORDER BY is_system, n.nspname",
102            &[],
103        )
104        .await?;
105    Ok(rows
106        .into_iter()
107        .map(|r| SchemaSummary {
108            name: r.get(0),
109            owner: r.get(1),
110            is_system: r.get(2),
111        })
112        .collect())
113}
114
115pub async fn list_relations(client: &Client, schema: &str) -> Result<Vec<Relation>, PgDriverError> {
116    // Bind the schema name so we can't be tricked into cross-schema scans
117    // by a malicious profile. The relkind filter list is fixed (matches
118    // `RelationKind` variants) and inlined as a SQL literal — binding
119    // a `Vec<i8>` and casting via `$2::char[]` doesn't round-trip
120    // because unquoted `char` in SQL is `bpchar`, not the internal
121    // single-byte `"char"` type that `pg_class.relkind` uses.
122    let rows = client
123        .query(
124            "SELECT n.nspname,
125                    c.relname,
126                    c.relkind,
127                    pg_catalog.pg_get_userbyid(c.relowner) AS owner,
128                    c.reltuples
129               FROM pg_catalog.pg_class c
130               JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
131              WHERE n.nspname = $1
132                AND c.relkind = ANY(ARRAY['r','v','m','p','f']::\"char\"[])
133              ORDER BY c.relname",
134            &[&schema],
135        )
136        .await?;
137
138    Ok(rows
139        .into_iter()
140        .filter_map(|r| {
141            let relkind: i8 = r.get(2);
142            RelationKind::from_relkind(relkind).map(|kind| Relation {
143                schema: r.get(0),
144                name: r.get(1),
145                kind,
146                owner: r.get(3),
147                estimated_rows: r.get(4),
148            })
149        })
150        .collect())
151}
152
153// =============================================================================
154// Sequences / Routines / Object Types
155//
156// These mirror DataGrip's schema-tree categories. Each kind lives in
157// its own pg_catalog table:
158//   - Sequences        → pg_class WHERE relkind='S'
159//   - Routines         → pg_proc (functions, procedures, aggregates,
160//                                 window functions)
161//   - Object types     → pg_type (composite, enum, domain, range)
162//
163// `list_schema_contents` runs all of these (plus the relation-kind
164// queries) concurrently for the same schema and returns the unified
165// SchemaContents — the schema browser's primary expand-a-schema call.
166// =============================================================================
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Sequence {
170    pub schema: String,
171    pub name: String,
172    pub owner: String,
173}
174
175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
176pub enum RoutineKind {
177    /// Regular SQL/PL function — `pg_proc.prokind = 'f'`.
178    Function,
179    /// Stored procedure — `prokind = 'p'`. Distinct from functions
180    /// in that they can manage transactions.
181    Procedure,
182    /// Aggregate function — `prokind = 'a'` (sum, avg, etc).
183    Aggregate,
184    /// Window function — `prokind = 'w'`.
185    Window,
186}
187
188impl RoutineKind {
189    fn from_prokind(c: i8) -> Option<Self> {
190        match c as u8 as char {
191            'f' => Some(Self::Function),
192            'p' => Some(Self::Procedure),
193            'a' => Some(Self::Aggregate),
194            'w' => Some(Self::Window),
195            _ => None,
196        }
197    }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct Routine {
202    pub schema: String,
203    pub name: String,
204    pub kind: RoutineKind,
205    pub owner: String,
206    /// Pretty-printed argument list `(integer, text)`. Built
207    /// server-side via `pg_get_function_identity_arguments` so
208    /// composite + array + qualified types render correctly.
209    pub argument_signature: String,
210    /// Return type name, when applicable. `None` for procedures
211    /// (they don't return a single value) and aggregates that
212    /// return record-shaped output.
213    pub return_type: Option<String>,
214}
215
216#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
217pub enum ObjectTypeKind {
218    /// Row-shaped composite type — `pg_type.typtype = 'c'`.
219    Composite,
220    /// Enum — `typtype = 'e'`.
221    Enum,
222    /// Domain (constrained alias of a base type) — `typtype = 'd'`.
223    Domain,
224    /// Range type — `typtype = 'r'` (or `'m'` for multirange in 14+).
225    Range,
226}
227
228impl ObjectTypeKind {
229    fn from_typtype(c: i8) -> Option<Self> {
230        match c as u8 as char {
231            'c' => Some(Self::Composite),
232            'e' => Some(Self::Enum),
233            'd' => Some(Self::Domain),
234            'r' | 'm' => Some(Self::Range),
235            _ => None,
236        }
237    }
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct ObjectType {
242    pub schema: String,
243    pub name: String,
244    pub kind: ObjectTypeKind,
245    pub owner: String,
246}
247
248/// Unified schema-contents view used by the tree's "expand a schema"
249/// path. Six-way grouping mirrors DataGrip's tree. Tables include
250/// regular, partitioned, and foreign tables (all "data-bearing
251/// relations"); views and materialized views split out for clarity.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct SchemaContents {
254    pub tables: Vec<Relation>,
255    pub views: Vec<Relation>,
256    pub materialized_views: Vec<Relation>,
257    pub sequences: Vec<Sequence>,
258    pub routines: Vec<Routine>,
259    pub object_types: Vec<ObjectType>,
260}
261
262pub async fn list_sequences(client: &Client, schema: &str) -> Result<Vec<Sequence>, PgDriverError> {
263    let rows = client
264        .query(
265            "SELECT n.nspname,
266                    c.relname,
267                    pg_catalog.pg_get_userbyid(c.relowner) AS owner
268               FROM pg_catalog.pg_class c
269               JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
270              WHERE n.nspname = $1
271                AND c.relkind = 'S'
272              ORDER BY c.relname",
273            &[&schema],
274        )
275        .await?;
276    Ok(rows
277        .into_iter()
278        .map(|r| Sequence {
279            schema: r.get(0),
280            name: r.get(1),
281            owner: r.get(2),
282        })
283        .collect())
284}
285
286pub async fn list_routines(client: &Client, schema: &str) -> Result<Vec<Routine>, PgDriverError> {
287    // pg_get_function_identity_arguments gives the parameter list
288    // suitable for unique identification (no DEFAULTs / OUT params
289    // confused with IN). pg_get_function_result is the return type;
290    // it's NULL for procedures.
291    let rows = client
292        .query(
293            "SELECT n.nspname,
294                    p.proname,
295                    p.prokind,
296                    pg_catalog.pg_get_userbyid(p.proowner) AS owner,
297                    pg_catalog.pg_get_function_identity_arguments(p.oid) AS arg_sig,
298                    pg_catalog.pg_get_function_result(p.oid) AS ret_type
299               FROM pg_catalog.pg_proc p
300               JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
301              WHERE n.nspname = $1
302              ORDER BY p.proname, arg_sig",
303            &[&schema],
304        )
305        .await?;
306    Ok(rows
307        .into_iter()
308        .filter_map(|r| {
309            let prokind: i8 = r.get(2);
310            RoutineKind::from_prokind(prokind).map(|kind| Routine {
311                schema: r.get(0),
312                name: r.get(1),
313                kind,
314                owner: r.get(3),
315                argument_signature: r.get::<_, Option<String>>(4).unwrap_or_default(),
316                return_type: r.get::<_, Option<String>>(5),
317            })
318        })
319        .collect())
320}
321
322pub async fn list_object_types(
323    client: &Client,
324    schema: &str,
325) -> Result<Vec<ObjectType>, PgDriverError> {
326    // Excludes system-generated row types (typtype='c' but typrelid
327    // pointing at an existing relation — those are auto-created for
328    // every table and would clutter the tree). The
329    // `typrelid = 0 OR relkind = 'c'` filter keeps standalone
330    // composite types and excludes table row types.
331    let rows = client
332        .query(
333            "SELECT n.nspname,
334                    t.typname,
335                    t.typtype,
336                    pg_catalog.pg_get_userbyid(t.typowner) AS owner
337               FROM pg_catalog.pg_type t
338               JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
339          LEFT JOIN pg_catalog.pg_class c ON c.oid = t.typrelid
340              WHERE n.nspname = $1
341                AND t.typtype IN ('c', 'e', 'd', 'r', 'm')
342                AND (t.typrelid = 0 OR c.relkind = 'c')
343                AND NOT EXISTS (
344                    -- Exclude array element type variants — pg
345                    -- generates `_int4`, `_text`, etc. for every
346                    -- type. They show up as composite-of-the-base
347                    -- which is not user-meaningful in this view.
348                    SELECT 1 FROM pg_catalog.pg_type elem
349                     WHERE elem.typarray = t.oid
350                )
351              ORDER BY t.typname",
352            &[&schema],
353        )
354        .await?;
355    Ok(rows
356        .into_iter()
357        .filter_map(|r| {
358            let typtype: i8 = r.get(2);
359            ObjectTypeKind::from_typtype(typtype).map(|kind| ObjectType {
360                schema: r.get(0),
361                name: r.get(1),
362                kind,
363                owner: r.get(3),
364            })
365        })
366        .collect())
367}
368
369/// Concurrent fetch of all schema contents in one logical call.
370/// `tokio_postgres::Client` pipelines so the four queries overlap on
371/// the wire — total latency is roughly max(query_latency) rather
372/// than 4× a single query.
373pub async fn list_schema_contents(
374    client: &Client,
375    schema: &str,
376) -> Result<SchemaContents, PgDriverError> {
377    let (relations, sequences, routines, object_types) = tokio::try_join!(
378        list_relations(client, schema),
379        list_sequences(client, schema),
380        list_routines(client, schema),
381        list_object_types(client, schema),
382    )?;
383
384    // Categorize relations. Tables, partitioned tables, and foreign
385    // tables all live under the "Tables" header — they're all
386    // data-bearing relations the user can SELECT from.
387    let mut tables = Vec::new();
388    let mut views = Vec::new();
389    let mut materialized_views = Vec::new();
390    for r in relations {
391        match r.kind {
392            RelationKind::Table | RelationKind::PartitionedTable | RelationKind::ForeignTable => {
393                tables.push(r);
394            }
395            RelationKind::View => views.push(r),
396            RelationKind::MaterializedView => materialized_views.push(r),
397        }
398    }
399    Ok(SchemaContents {
400        tables,
401        views,
402        materialized_views,
403        sequences,
404        routines,
405        object_types,
406    })
407}
408
409/// Per-column metadata used by the INSERT form to pre-set Use
410/// DEFAULT and NULL toggles. Read from `pg_attribute` joined
411/// with `pg_attrdef` for the default-presence flag.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct ColumnDetail {
414    pub name: String,
415    /// `pg_type.typname` — feeds the INSERT form's per-column
416    /// `::type` cast.
417    pub type_name: String,
418    /// `attnotnull`. When true, the INSERT form refuses NULL.
419    pub not_null: bool,
420    /// `pg_attrdef` row exists for this column. When true, the
421    /// form defaults to "Use DEFAULT" so the user only fills in
422    /// what they want to override.
423    pub has_default: bool,
424    /// `attgenerated <> ''` — generated columns can't be set on
425    /// INSERT. The form omits these entirely.
426    pub is_generated: bool,
427}
428
429pub async fn describe_columns(
430    client: &Client,
431    schema: &str,
432    table: &str,
433) -> Result<Vec<ColumnDetail>, PgDriverError> {
434    // attnum > 0 filters out the system columns (oid, ctid, etc).
435    // attisdropped filters out logically-deleted columns whose
436    // entries linger for tuple-format compatibility.
437    let rows = client
438        .query(
439            "SELECT a.attname,
440                    pg_catalog.format_type(a.atttypid, NULL) AS type_name,
441                    a.attnotnull,
442                    (d.adbin IS NOT NULL) AS has_default,
443                    (a.attgenerated <> '') AS is_generated
444               FROM pg_catalog.pg_attribute a
445               JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
446               JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
447          LEFT JOIN pg_catalog.pg_attrdef d
448                 ON d.adrelid = a.attrelid AND d.adnum = a.attnum
449              WHERE n.nspname = $1
450                AND c.relname = $2
451                AND a.attnum > 0
452                AND NOT a.attisdropped
453              ORDER BY a.attnum",
454            &[&schema, &table],
455        )
456        .await?;
457    Ok(rows
458        .into_iter()
459        .map(|r| ColumnDetail {
460            name: r.get(0),
461            type_name: r.get(1),
462            not_null: r.get(2),
463            has_default: r.get(3),
464            is_generated: r.get(4),
465        })
466        .collect())
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn relation_kind_decodes_known_relkinds() {
475        assert_eq!(
476            RelationKind::from_relkind(b'r' as i8),
477            Some(RelationKind::Table)
478        );
479        assert_eq!(
480            RelationKind::from_relkind(b'v' as i8),
481            Some(RelationKind::View)
482        );
483        assert_eq!(
484            RelationKind::from_relkind(b'm' as i8),
485            Some(RelationKind::MaterializedView)
486        );
487        assert_eq!(
488            RelationKind::from_relkind(b'p' as i8),
489            Some(RelationKind::PartitionedTable)
490        );
491        assert_eq!(
492            RelationKind::from_relkind(b'f' as i8),
493            Some(RelationKind::ForeignTable)
494        );
495    }
496
497    #[test]
498    fn relation_kind_rejects_unknown() {
499        // 'i' = index, 'S' = sequence, 't' = TOAST, 'c' = composite type
500        assert_eq!(RelationKind::from_relkind(b'i' as i8), None);
501        assert_eq!(RelationKind::from_relkind(b'S' as i8), None);
502        assert_eq!(RelationKind::from_relkind(b't' as i8), None);
503        assert_eq!(RelationKind::from_relkind(b'c' as i8), None);
504    }
505
506    #[test]
507    fn routine_kind_decodes_known_prokinds() {
508        assert_eq!(
509            RoutineKind::from_prokind(b'f' as i8),
510            Some(RoutineKind::Function)
511        );
512        assert_eq!(
513            RoutineKind::from_prokind(b'p' as i8),
514            Some(RoutineKind::Procedure)
515        );
516        assert_eq!(
517            RoutineKind::from_prokind(b'a' as i8),
518            Some(RoutineKind::Aggregate)
519        );
520        assert_eq!(
521            RoutineKind::from_prokind(b'w' as i8),
522            Some(RoutineKind::Window)
523        );
524        assert_eq!(RoutineKind::from_prokind(b'?' as i8), None);
525    }
526
527    #[test]
528    fn object_type_kind_decodes_known_typtypes() {
529        assert_eq!(
530            ObjectTypeKind::from_typtype(b'c' as i8),
531            Some(ObjectTypeKind::Composite)
532        );
533        assert_eq!(
534            ObjectTypeKind::from_typtype(b'e' as i8),
535            Some(ObjectTypeKind::Enum)
536        );
537        assert_eq!(
538            ObjectTypeKind::from_typtype(b'd' as i8),
539            Some(ObjectTypeKind::Domain)
540        );
541        assert_eq!(
542            ObjectTypeKind::from_typtype(b'r' as i8),
543            Some(ObjectTypeKind::Range)
544        );
545        // multirange (PG 14+) groups with Range for tree purposes
546        assert_eq!(
547            ObjectTypeKind::from_typtype(b'm' as i8),
548            Some(ObjectTypeKind::Range)
549        );
550        // 'b' = base type (skip — not user-defined in the
551        // tree-browsing sense).
552        assert_eq!(ObjectTypeKind::from_typtype(b'b' as i8), None);
553    }
554}