Skip to main content

architect_sdk/db/
introspect.rs

1//! Physical-database introspection.
2//!
3//! Migration plans are computed by diffing two package configs — they never look at the
4//! database. When the physical database has drifted from what the old config describes (a
5//! partially applied upgrade, a tenant database provisioned from a newer template, columns added
6//! by RLS reconciliation or by hand), replaying such a plan fails on statements whose effect is
7//! already present: `ADD COLUMN "project_id"` on a table that already has it.
8//!
9//! [`DbSnapshot`] captures what actually exists so the migration executor can skip those steps
10//! instead of aborting. Everything here is best-effort: if introspection fails the snapshot is
11//! left empty ([`DbSnapshot::introspected`] stays `false`) and every step executes as before.
12
13use std::collections::{HashMap, HashSet};
14
15use sqlx::Row;
16
17use super::dialect::Dialect;
18use super::pool::Pool;
19
20/// What the database reports about one column.
21#[derive(Debug, Clone)]
22pub struct ColumnFacts {
23    /// Dialect-reported type string (e.g. `text`, `character varying(64)`), for diagnostics only.
24    pub data_type: String,
25    pub nullable: bool,
26    pub has_default: bool,
27}
28
29/// Physical state of one or more schemas: which tables, columns, indexes and constraints exist.
30///
31/// Identifier comparison is case-insensitive. The SDK generates every identifier from config and
32/// never creates two objects in one table whose names differ only by case, so folding case is
33/// safe here and keeps MySQL (case-insensitive column names) correct.
34#[derive(Debug, Clone, Default)]
35pub struct DbSnapshot {
36    /// `schema\u{1}table` → column name → facts. All keys lowercased.
37    columns: HashMap<String, HashMap<String, ColumnFacts>>,
38    /// `schema\u{1}index`, lowercased.
39    indexes: HashSet<String>,
40    /// `schema\u{1}table\u{1}constraint`, lowercased.
41    constraints: HashSet<String>,
42    /// Whether at least one introspection query succeeded. When `false`, absence of an object in
43    /// this snapshot proves nothing and callers must not skip steps on that basis.
44    pub introspected: bool,
45    /// Whether index names were readable for this dialect.
46    pub indexes_known: bool,
47    /// Whether constraint names were readable for this dialect.
48    pub constraints_known: bool,
49}
50
51fn table_key(schema: &str, table: &str) -> String {
52    format!("{}\u{1}{}", schema.to_lowercase(), table.to_lowercase())
53}
54
55impl DbSnapshot {
56    /// True when this table was seen in the database.
57    pub fn has_table(&self, schema: &str, table: &str) -> bool {
58        self.columns.contains_key(&table_key(schema, table))
59    }
60
61    pub fn has_column(&self, schema: &str, table: &str, column: &str) -> bool {
62        self.column(schema, table, column).is_some()
63    }
64
65    pub fn column(&self, schema: &str, table: &str, column: &str) -> Option<&ColumnFacts> {
66        self.columns
67            .get(&table_key(schema, table))
68            .and_then(|cols| cols.get(&column.to_lowercase()))
69    }
70
71    pub fn has_index(&self, schema: &str, index: &str) -> bool {
72        self.indexes.contains(&format!(
73            "{}\u{1}{}",
74            schema.to_lowercase(),
75            index.to_lowercase()
76        ))
77    }
78
79    pub fn has_constraint(&self, schema: &str, table: &str, constraint: &str) -> bool {
80        self.constraints.contains(&format!(
81            "{}\u{1}{}",
82            table_key(schema, table),
83            constraint.to_lowercase()
84        ))
85    }
86
87    // ── Mutators — keep the snapshot in step with DDL as it is executed ───────
88
89    pub fn add_column(&mut self, schema: &str, table: &str, column: &str, facts: ColumnFacts) {
90        self.columns
91            .entry(table_key(schema, table))
92            .or_default()
93            .insert(column.to_lowercase(), facts);
94    }
95
96    pub fn add_table(&mut self, schema: &str, table: &str) {
97        self.columns.entry(table_key(schema, table)).or_default();
98    }
99
100    pub fn remove_column(&mut self, schema: &str, table: &str, column: &str) {
101        if let Some(cols) = self.columns.get_mut(&table_key(schema, table)) {
102            cols.remove(&column.to_lowercase());
103        }
104    }
105
106    pub fn rename_column(&mut self, schema: &str, table: &str, from: &str, to: &str) {
107        if let Some(cols) = self.columns.get_mut(&table_key(schema, table)) {
108            if let Some(facts) = cols.remove(&from.to_lowercase()) {
109                cols.insert(to.to_lowercase(), facts);
110            }
111        }
112    }
113
114    /// Update a column's nullability, if the column is known.
115    pub fn set_nullable(&mut self, schema: &str, table: &str, column: &str, nullable: bool) {
116        if let Some(cols) = self.columns.get_mut(&table_key(schema, table)) {
117            if let Some(f) = cols.get_mut(&column.to_lowercase()) {
118                f.nullable = nullable;
119            }
120        }
121    }
122
123    /// Update whether a column carries a DEFAULT, if the column is known.
124    pub fn set_has_default(&mut self, schema: &str, table: &str, column: &str, has_default: bool) {
125        if let Some(cols) = self.columns.get_mut(&table_key(schema, table)) {
126            if let Some(f) = cols.get_mut(&column.to_lowercase()) {
127                f.has_default = has_default;
128            }
129        }
130    }
131
132    pub fn add_index(&mut self, schema: &str, index: &str) {
133        self.indexes.insert(format!(
134            "{}\u{1}{}",
135            schema.to_lowercase(),
136            index.to_lowercase()
137        ));
138    }
139
140    pub fn remove_index(&mut self, schema: &str, index: &str) {
141        self.indexes.remove(&format!(
142            "{}\u{1}{}",
143            schema.to_lowercase(),
144            index.to_lowercase()
145        ));
146    }
147
148    pub fn add_constraint(&mut self, schema: &str, table: &str, constraint: &str) {
149        self.constraints.insert(format!(
150            "{}\u{1}{}",
151            table_key(schema, table),
152            constraint.to_lowercase()
153        ));
154    }
155
156    pub fn remove_constraint(&mut self, schema: &str, table: &str, constraint: &str) {
157        self.constraints.remove(&format!(
158            "{}\u{1}{}",
159            table_key(schema, table),
160            constraint.to_lowercase()
161        ));
162    }
163}
164
165/// Read the physical state of `schemas` from `pool`.
166///
167/// Never fails: a query that errors (missing schema, no privileges, unsupported catalog) is
168/// logged and leaves that part of the snapshot empty.
169pub async fn introspect(pool: &Pool, dialect: &dyn Dialect, schemas: &[String]) -> DbSnapshot {
170    let mut snap = DbSnapshot {
171        indexes_known: true,
172        constraints_known: true,
173        ..Default::default()
174    };
175
176    for schema in schemas {
177        let sql = dialect.introspect_columns_sql(schema);
178        match sqlx::query(&sql).fetch_all(pool).await {
179            Ok(rows) => {
180                snap.introspected = true;
181                for row in rows {
182                    let (table, column) =
183                        match (row.try_get::<String, _>(0), row.try_get::<String, _>(1)) {
184                            (Ok(t), Ok(c)) => (t, c),
185                            _ => continue,
186                        };
187                    let facts = ColumnFacts {
188                        data_type: row.try_get::<String, _>(2).unwrap_or_default(),
189                        nullable: row
190                            .try_get::<String, _>(3)
191                            .map(|v| v.eq_ignore_ascii_case("YES"))
192                            .unwrap_or(true),
193                        has_default: row
194                            .try_get::<String, _>(4)
195                            .map(|v| v.eq_ignore_ascii_case("YES"))
196                            .unwrap_or(false),
197                    };
198                    snap.add_column(schema, &table, &column, facts);
199                }
200            }
201            Err(e) => {
202                tracing::warn!(schema = %schema, error = %e, "column introspection failed — migration steps for this schema will not be skipped");
203            }
204        }
205
206        match dialect.introspect_indexes_sql(schema) {
207            Some(sql) => match sqlx::query(&sql).fetch_all(pool).await {
208                Ok(rows) => {
209                    for row in rows {
210                        if let Ok(name) = row.try_get::<String, _>(0) {
211                            snap.add_index(schema, &name);
212                        }
213                    }
214                }
215                Err(e) => {
216                    snap.indexes_known = false;
217                    tracing::warn!(schema = %schema, error = %e, "index introspection failed");
218                }
219            },
220            None => snap.indexes_known = false,
221        }
222
223        match dialect.introspect_constraints_sql(schema) {
224            Some(sql) => match sqlx::query(&sql).fetch_all(pool).await {
225                Ok(rows) => {
226                    for row in rows {
227                        match (row.try_get::<String, _>(0), row.try_get::<String, _>(1)) {
228                            (Ok(table), Ok(name)) => snap.add_constraint(schema, &table, &name),
229                            _ => continue,
230                        }
231                    }
232                }
233                Err(e) => {
234                    snap.constraints_known = false;
235                    tracing::warn!(schema = %schema, error = %e, "constraint introspection failed");
236                }
237            },
238            None => snap.constraints_known = false,
239        }
240    }
241
242    snap
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn facts() -> ColumnFacts {
250        ColumnFacts {
251            data_type: "text".into(),
252            nullable: true,
253            has_default: false,
254        }
255    }
256
257    #[test]
258    fn lookups_are_case_insensitive() {
259        let mut snap = DbSnapshot::default();
260        snap.add_column("App", "Orders", "ProjectId", facts());
261        assert!(snap.has_column("app", "orders", "projectid"));
262        assert!(snap.has_table("APP", "ORDERS"));
263        assert!(!snap.has_column("app", "orders", "other"));
264    }
265
266    #[test]
267    fn empty_snapshot_knows_nothing() {
268        let snap = DbSnapshot::default();
269        assert!(!snap.introspected);
270        assert!(!snap.has_table("app", "orders"));
271    }
272
273    #[test]
274    fn rename_moves_facts_to_the_new_name() {
275        let mut snap = DbSnapshot::default();
276        snap.add_column("app", "orders", "note", facts());
277        snap.rename_column("app", "orders", "note", "remark");
278        assert!(!snap.has_column("app", "orders", "note"));
279        assert!(snap.has_column("app", "orders", "remark"));
280    }
281
282    #[test]
283    fn index_and_constraint_membership() {
284        let mut snap = DbSnapshot::default();
285        snap.add_index("app", "orders_user_idx");
286        snap.add_constraint("app", "orders", "fk_orders_user");
287        assert!(snap.has_index("app", "ORDERS_USER_IDX"));
288        assert!(snap.has_constraint("APP", "Orders", "fk_orders_user"));
289        assert!(!snap.has_constraint("app", "users", "fk_orders_user"));
290    }
291}