Skip to main content

sources_postgres/cdc/
introspection.rs

1//! Postgres implementation of [`SchemaIntrospection`] over `pg_catalog`.
2//!
3//! Enumerates every ordinary/partitioned base table in the user schemas (system
4//! schemas excluded) with its columns, primary key, and foreign keys, mapping
5//! each native column type to a suggested [`FlussoType`]. Runs read-only over
6//! the shared admin pool — the same small pool the slot check and lag probes
7//! use. Three catalog queries (columns, primary keys, foreign keys), assembled
8//! into a deterministic [`RelationalCatalog`].
9//!
10//! Foreign keys and composite primary keys are read straight from
11//! `pg_constraint`/`pg_index` with `unnest(... ) WITH ORDINALITY`, so a
12//! composite key's columns stay positionally aligned with the columns they
13//! reference — `information_schema.constraint_column_usage` does not guarantee
14//! that pairing.
15
16use async_trait::async_trait;
17use schema_core::common::ColumnName;
18use schema_core::{DatabaseSchema, FlussoType, TableName};
19use sources_core::{
20    ColumnShape, ForeignKey, RelationalCatalog, Result, SchemaIntrospection, SourceError,
21    TableShape,
22};
23use sqlx::{PgPool, Row};
24use std::collections::BTreeMap;
25
26use super::WalChangeCapture;
27
28#[async_trait]
29impl SchemaIntrospection for WalChangeCapture {
30    #[tracing::instrument(name = "wal.introspect", skip_all, err)]
31    async fn introspect(&self) -> Result<RelationalCatalog> {
32        let pool = self.admin_pool().await?;
33        introspect_catalog(pool).await
34    }
35}
36
37/// A `(schema, table)` key used to group catalog rows as they stream back.
38type TableKey = (String, String);
39
40/// Raw FK columns accumulated per constraint before newtype conversion.
41struct FkRows {
42    columns: Vec<String>,
43    ref_schema: String,
44    ref_table: String,
45    ref_columns: Vec<String>,
46}
47
48/// Read the relational catalog from `pool` — the query/assembly the trait
49/// method delegates to, kept free of `self` so it depends only on a pool.
50async fn introspect_catalog(pool: &PgPool) -> Result<RelationalCatalog> {
51    let mut tables: BTreeMap<TableKey, TableShape> = BTreeMap::new();
52
53    for row in sqlx::query(COLUMNS_SQL)
54        .fetch_all(pool)
55        .await
56        .map_err(query_err)?
57    {
58        let schema: String = get(&row, "schema")?;
59        let table: String = get(&row, "table")?;
60        let column: String = get(&row, "column")?;
61        let sql_type: String = get(&row, "sql_type")?;
62        let nullable: bool = get(&row, "nullable")?;
63
64        let shape = entry(&mut tables, &schema, &table)?;
65        shape.columns.push(ColumnShape {
66            name: column_name(&column)?,
67            suggested_type: suggest_type(&sql_type),
68            sql_type,
69            nullable,
70            is_primary_key: false,
71        });
72    }
73
74    for row in sqlx::query(PRIMARY_KEY_SQL)
75        .fetch_all(pool)
76        .await
77        .map_err(query_err)?
78    {
79        let schema: String = get(&row, "schema")?;
80        let table: String = get(&row, "table")?;
81        let column: String = get(&row, "column")?;
82
83        if let Some(shape) = tables.get_mut(&(schema, table)) {
84            let name = column_name(&column)?;
85            if let Some(col) = shape.columns.iter_mut().find(|c| c.name == name) {
86                col.is_primary_key = true;
87            }
88            shape.primary_key.push(name);
89        }
90    }
91
92    // Group FK columns by constraint (a `BTreeMap` keeps the per-table FK order
93    // deterministic) before converting any name, so `references_*` is set once
94    // and the composite columns accumulate in `WITH ORDINALITY` order.
95    let mut foreign_keys: BTreeMap<(TableKey, String), FkRows> = BTreeMap::new();
96    for row in sqlx::query(FOREIGN_KEY_SQL)
97        .fetch_all(pool)
98        .await
99        .map_err(query_err)?
100    {
101        let schema: String = get(&row, "schema")?;
102        let table: String = get(&row, "table")?;
103        let constraint: String = get(&row, "constraint_name")?;
104
105        let fk = foreign_keys
106            .entry(((schema, table), constraint))
107            .or_insert_with(|| FkRows {
108                columns: Vec::new(),
109                ref_schema: get(&row, "ref_schema").unwrap_or_default(),
110                ref_table: get(&row, "ref_table").unwrap_or_default(),
111                ref_columns: Vec::new(),
112            });
113        fk.columns.push(get(&row, "column")?);
114        fk.ref_columns.push(get(&row, "ref_column")?);
115    }
116    for (((schema, table), _), rows) in foreign_keys {
117        if let Some(shape) = tables.get_mut(&(schema, table)) {
118            shape.foreign_keys.push(ForeignKey {
119                columns: rows
120                    .columns
121                    .iter()
122                    .map(|c| column_name(c))
123                    .collect::<Result<_>>()?,
124                references_schema: database_schema(&rows.ref_schema)?,
125                references_table: table_name(&rows.ref_table)?,
126                references_columns: rows
127                    .ref_columns
128                    .iter()
129                    .map(|c| column_name(c))
130                    .collect::<Result<_>>()?,
131            });
132        }
133    }
134
135    Ok(RelationalCatalog {
136        tables: tables.into_values().collect(),
137    })
138}
139
140const COLUMNS_SQL: &str = "\
141SELECT n.nspname AS schema, c.relname AS \"table\", a.attname AS column, \
142       format_type(a.atttypid, a.atttypmod) AS sql_type, \
143       NOT a.attnotnull AS nullable \
144FROM pg_attribute a \
145JOIN pg_class c ON c.oid = a.attrelid \
146JOIN pg_namespace n ON n.oid = c.relnamespace \
147WHERE c.relkind IN ('r', 'p') \
148  AND a.attnum > 0 AND NOT a.attisdropped \
149  AND n.nspname NOT IN ('pg_catalog', 'information_schema') \
150  AND n.nspname NOT LIKE 'pg\\_%' \
151ORDER BY n.nspname, c.relname, a.attnum";
152
153const PRIMARY_KEY_SQL: &str = "\
154SELECT n.nspname AS schema, c.relname AS \"table\", att.attname AS column \
155FROM pg_index i \
156JOIN pg_class c ON c.oid = i.indrelid \
157JOIN pg_namespace n ON n.oid = c.relnamespace \
158JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE \
159JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum = k.attnum \
160WHERE i.indisprimary \
161  AND n.nspname NOT IN ('pg_catalog', 'information_schema') \
162ORDER BY n.nspname, c.relname, k.ord";
163
164const FOREIGN_KEY_SQL: &str = "\
165SELECT n.nspname AS schema, c.relname AS \"table\", con.conname AS constraint_name, \
166       att.attname AS column, fn.nspname AS ref_schema, fc.relname AS ref_table, \
167       fatt.attname AS ref_column \
168FROM pg_constraint con \
169JOIN pg_class c ON c.oid = con.conrelid \
170JOIN pg_namespace n ON n.oid = c.relnamespace \
171JOIN pg_class fc ON fc.oid = con.confrelid \
172JOIN pg_namespace fn ON fn.oid = fc.relnamespace \
173JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(conkey, confkey, ord) ON TRUE \
174JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = k.conkey \
175JOIN pg_attribute fatt ON fatt.attrelid = con.confrelid AND fatt.attnum = k.confkey \
176WHERE con.contype = 'f' \
177  AND n.nspname NOT IN ('pg_catalog', 'information_schema') \
178ORDER BY n.nspname, c.relname, con.conname, k.ord";
179
180fn entry<'a>(
181    tables: &'a mut BTreeMap<TableKey, TableShape>,
182    schema: &str,
183    table: &str,
184) -> Result<&'a mut TableShape> {
185    let key = (schema.to_owned(), table.to_owned());
186    if !tables.contains_key(&key) {
187        tables.insert(
188            key.clone(),
189            TableShape {
190                schema: database_schema(schema)?,
191                name: table_name(table)?,
192                columns: Vec::new(),
193                primary_key: Vec::new(),
194                foreign_keys: Vec::new(),
195            },
196        );
197    }
198    tables
199        .get_mut(&key)
200        .ok_or_else(|| SourceError::Query("table just inserted is missing".to_owned()))
201}
202
203fn get<T>(row: &sqlx::postgres::PgRow, col: &str) -> Result<T>
204where
205    T: for<'r> sqlx::Decode<'r, sqlx::Postgres> + sqlx::Type<sqlx::Postgres>,
206{
207    row.try_get(col).map_err(query_err)
208}
209
210fn query_err(e: sqlx::Error) -> SourceError {
211    SourceError::Query(e.to_string())
212}
213
214fn column_name(raw: &str) -> Result<ColumnName> {
215    ColumnName::try_new(raw).map_err(|e| SourceError::Query(format!("invalid column name: {e}")))
216}
217
218fn table_name(raw: &str) -> Result<TableName> {
219    TableName::try_new(raw).map_err(|e| SourceError::Query(format!("invalid table name: {e}")))
220}
221
222fn database_schema(raw: &str) -> Result<DatabaseSchema> {
223    DatabaseSchema::try_new(raw)
224        .map_err(|e| SourceError::Query(format!("invalid schema name: {e}")))
225}
226
227/// Map a Postgres native type (as `format_type` spells it) to the flusso field
228/// type a designer would most likely pick, or `None` when the choice is
229/// genuinely the user's (`text`-vs-`keyword`, an enum, an array, an unknown
230/// user type). Array types (`[]`) and types flusso has no scalar for return
231/// `None` so the designer surfaces the decision rather than guessing wrong.
232fn suggest_type(sql_type: &str) -> Option<FlussoType> {
233    let lower = sql_type.trim().to_ascii_lowercase();
234    if lower.ends_with("[]") {
235        return None;
236    }
237    // Strip a type modifier like `(255)` / `(10,2)` and any schema qualifier.
238    let base = lower
239        .split('(')
240        .next()
241        .unwrap_or(&lower)
242        .rsplit('.')
243        .next()
244        .unwrap_or(&lower)
245        .trim();
246    Some(match base {
247        "smallint" | "int2" | "smallserial" => FlussoType::Short,
248        "integer" | "int" | "int4" | "serial" => FlussoType::Integer,
249        "bigint" | "int8" | "bigserial" => FlussoType::Long,
250        "real" | "float4" => FlussoType::Float,
251        "double precision" | "float8" => FlussoType::Double,
252        "numeric" | "decimal" | "money" => FlussoType::Decimal,
253        "boolean" | "bool" => FlussoType::Boolean,
254        "uuid" => FlussoType::Uuid,
255        "text" => FlussoType::Text,
256        "character varying" | "varchar" | "character" | "char" | "bpchar" | "citext" | "name" => {
257            FlussoType::Keyword
258        }
259        "date" => FlussoType::Date,
260        "timestamp without time zone"
261        | "timestamp with time zone"
262        | "timestamp"
263        | "timestamptz" => FlussoType::Timestamp,
264        "json" | "jsonb" => FlussoType::Json,
265        "bytea" => FlussoType::Binary,
266        _ => return None,
267    })
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn maps_common_pg_types() {
276        assert_eq!(suggest_type("integer"), Some(FlussoType::Integer));
277        assert_eq!(
278            suggest_type("character varying(255)"),
279            Some(FlussoType::Keyword)
280        );
281        assert_eq!(suggest_type("text"), Some(FlussoType::Text));
282        assert_eq!(suggest_type("numeric(10,2)"), Some(FlussoType::Decimal));
283        assert_eq!(
284            suggest_type("timestamp with time zone"),
285            Some(FlussoType::Timestamp)
286        );
287        assert_eq!(suggest_type("uuid"), Some(FlussoType::Uuid));
288        assert_eq!(suggest_type("jsonb"), Some(FlussoType::Json));
289    }
290
291    #[test]
292    fn leaves_ambiguous_types_unsuggested() {
293        assert_eq!(suggest_type("integer[]"), None);
294        assert_eq!(suggest_type("time without time zone"), None);
295        assert_eq!(suggest_type("my_enum"), None);
296    }
297}