1use 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
37type TableKey = (String, String);
39
40struct FkRows {
42 columns: Vec<String>,
43 ref_schema: String,
44 ref_table: String,
45 ref_columns: Vec<String>,
46}
47
48async 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 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
227fn 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 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}