1use std::collections::{HashMap, HashSet};
14
15use sqlx::Row;
16
17use super::dialect::Dialect;
18use super::pool::Pool;
19
20#[derive(Debug, Clone)]
22pub struct ColumnFacts {
23 pub data_type: String,
25 pub nullable: bool,
26 pub has_default: bool,
27}
28
29#[derive(Debug, Clone, Default)]
35pub struct DbSnapshot {
36 columns: HashMap<String, HashMap<String, ColumnFacts>>,
38 indexes: HashSet<String>,
40 constraints: HashSet<String>,
42 pub introspected: bool,
45 pub indexes_known: bool,
47 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 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 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 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 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
165pub 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}