1use super::backend::Backend;
32use super::pool::DbPool;
33use super::{DbError, ModelRow, Value};
34
35#[derive(Debug, Clone, PartialEq)]
37#[non_exhaustive]
38pub struct DbSchema {
39 pub backend: Backend,
41 pub tables: Vec<TableSchema>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub struct TableSchema {
49 pub name: String,
50 pub columns: Vec<ColumnSchema>,
52 pub primary_key: Vec<String>,
54 pub indexes: Vec<IndexSchema>,
58 pub foreign_keys: Vec<ForeignKeySchema>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct ColumnSchema {
66 pub name: String,
67 pub data_type: String,
70 pub nullable: bool,
71 pub default: Option<String>,
73 pub primary_key: bool,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79#[non_exhaustive]
80pub struct IndexSchema {
81 pub name: String,
82 pub columns: Vec<String>,
84 pub unique: bool,
85 pub primary: bool,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91#[non_exhaustive]
92pub struct ForeignKeySchema {
93 pub name: Option<String>,
96 pub columns: Vec<String>,
97 pub references_table: String,
98 pub references_columns: Vec<String>,
100 pub on_update: String,
102 pub on_delete: String,
103}
104
105impl DbSchema {
106 pub fn table(&self, name: &str) -> Option<&TableSchema> {
108 self.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name))
109 }
110
111 pub fn to_json(&self) -> String {
116 let tables: Vec<String> = self.tables.iter().map(TableSchema::to_json).collect();
117 format!(r#"{{"backend":{},"tables":[{}]}}"#, quoted(self.backend.name()), tables.join(","))
118 }
119}
120
121impl TableSchema {
122 pub fn column(&self, name: &str) -> Option<&ColumnSchema> {
124 self.columns.iter().find(|c| c.name.eq_ignore_ascii_case(name))
125 }
126
127 fn to_json(&self) -> String {
128 let columns: Vec<String> = self
129 .columns
130 .iter()
131 .map(|c| {
132 format!(
133 r#"{{"name":{},"type":{},"nullable":{},"default":{},"primary_key":{}}}"#,
134 quoted(&c.name),
135 quoted(&c.data_type),
136 c.nullable,
137 c.default.as_deref().map(quoted).unwrap_or_else(|| "null".to_string()),
138 c.primary_key
139 )
140 })
141 .collect();
142 let indexes: Vec<String> = self
143 .indexes
144 .iter()
145 .map(|i| format!(r#"{{"name":{},"columns":{},"unique":{},"primary":{}}}"#, quoted(&i.name), list(&i.columns), i.unique, i.primary))
146 .collect();
147 let foreign_keys: Vec<String> = self
148 .foreign_keys
149 .iter()
150 .map(|f| {
151 format!(
152 r#"{{"name":{},"columns":{},"references":{{"table":{},"columns":{}}},"on_update":{},"on_delete":{}}}"#,
153 f.name.as_deref().map(quoted).unwrap_or_else(|| "null".to_string()),
154 list(&f.columns),
155 quoted(&f.references_table),
156 list(&f.references_columns),
157 quoted(&f.on_update),
158 quoted(&f.on_delete)
159 )
160 })
161 .collect();
162 format!(
163 r#"{{"name":{},"columns":[{}],"primary_key":{},"indexes":[{}],"foreign_keys":[{}]}}"#,
164 quoted(&self.name),
165 columns.join(","),
166 list(&self.primary_key),
167 indexes.join(","),
168 foreign_keys.join(",")
169 )
170 }
171}
172
173fn quoted(s: &str) -> String {
174 format!("\"{}\"", crate::core::json::escape(s))
175}
176
177fn list(items: &[String]) -> String {
178 format!("[{}]", items.iter().map(|s| quoted(s)).collect::<Vec<_>>().join(","))
179}
180
181fn text(row: &ModelRow, column: &str) -> Option<String> {
184 let value = row.columns.iter().find(|(name, _)| name.eq_ignore_ascii_case(column)).map(|(_, v)| v.clone())?;
185 match value {
186 Value::Null => None,
187 Value::Bool(b) => Some(b.to_string()),
188 Value::Int(n) => Some(n.to_string()),
189 Value::Float(f) => Some(f.to_string()),
190 Value::Text(s) => Some(s),
191 Value::Bytes(b) => Some(String::from_utf8_lossy(&b).into_owned()),
192 }
193}
194
195fn truthy(row: &ModelRow, column: &str) -> bool {
196 matches!(text(row, column).as_deref(), Some("1" | "true" | "t" | "YES" | "yes"))
197}
198
199fn table(name: String) -> TableSchema {
200 TableSchema { name, columns: Vec::new(), primary_key: Vec::new(), indexes: Vec::new(), foreign_keys: Vec::new() }
201}
202
203fn push_index(indexes: &mut Vec<IndexSchema>, index: String, unique: bool, primary: bool, column: Option<String>) {
206 if indexes.last().map(|i| i.name != index).unwrap_or(true) {
207 indexes.push(IndexSchema { name: index, columns: Vec::new(), unique, primary });
208 }
209 if let (Some(last), Some(column)) = (indexes.last_mut(), column) {
210 last.columns.push(column);
211 }
212}
213
214#[allow(clippy::too_many_arguments)]
215fn push_foreign_key(
216 keys: &mut Vec<ForeignKeySchema>,
217 name: Option<String>,
218 group: &str,
219 column: String,
220 references_table: String,
221 references_column: Option<String>,
222 on_update: String,
223 on_delete: String,
224 groups: &mut Vec<String>,
225) {
226 if groups.last().map(|g| g != group).unwrap_or(true) {
227 groups.push(group.to_string());
228 keys.push(ForeignKeySchema { name, columns: Vec::new(), references_table, references_columns: Vec::new(), on_update, on_delete });
229 }
230 if let Some(last) = keys.last_mut() {
231 last.columns.push(column);
232 if let Some(c) = references_column {
233 last.references_columns.push(c);
234 }
235 }
236}
237
238impl DbPool {
239 pub async fn schema(&self) -> Result<DbSchema, DbError> {
242 let pool = self.primary();
243 let mut tables = match pool.backend() {
244 #[cfg(feature = "model-sqlite")]
245 Backend::Sqlite => sqlite(pool).await?,
246 #[cfg(feature = "model-postgres")]
247 Backend::Postgres => postgres(pool).await?,
248 #[cfg(feature = "model-mysql")]
249 Backend::MySql => mysql(pool).await?,
250 };
251 tables.sort_by(|a, b| a.name.cmp(&b.name));
252 for t in &mut tables {
253 let primary_key = t.primary_key.clone();
254 for c in &mut t.columns {
255 c.primary_key = primary_key.iter().any(|k| k.eq_ignore_ascii_case(&c.name));
256 }
257 }
258 let keys: Vec<(String, Vec<String>)> = tables.iter().map(|t| (t.name.clone(), t.primary_key.clone())).collect();
260 for t in &mut tables {
261 for fk in &mut t.foreign_keys {
262 if fk.references_columns.is_empty() {
263 if let Some((_, pk)) = keys.iter().find(|(name, _)| name.eq_ignore_ascii_case(&fk.references_table)) {
264 fk.references_columns = pk.clone();
265 }
266 }
267 }
268 }
269 Ok(DbSchema { backend: pool.backend(), tables })
270 }
271}
272
273#[cfg(feature = "model-sqlite")]
274async fn sqlite(pool: &DbPool) -> Result<Vec<TableSchema>, DbError> {
275 let names = pool
276 .query_rows("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", &[])
277 .await?;
278 let mut tables = Vec::new();
279 for row in names {
280 let Some(name) = text(&row, "name") else { continue };
281 let arg = [Value::Text(name.clone())];
282 let mut t = table(name);
283 let mut key: Vec<(i64, String)> = Vec::new();
284 for row in pool
285 .query_rows(r#"SELECT name, type, "notnull" AS not_null, dflt_value, pk FROM pragma_table_info(?) ORDER BY cid"#, &arg)
286 .await?
287 {
288 let column = text(&row, "name").unwrap_or_default();
289 if let Some(position) = text(&row, "pk").and_then(|p| p.parse::<i64>().ok()).filter(|p| *p > 0) {
290 key.push((position, column.clone()));
291 }
292 t.columns.push(ColumnSchema {
293 name: column,
294 data_type: text(&row, "type").unwrap_or_default(),
295 nullable: !truthy(&row, "not_null"),
296 default: text(&row, "dflt_value"),
297 primary_key: false,
298 });
299 }
300 key.sort();
301 t.primary_key = key.into_iter().map(|(_, c)| c).collect();
302 for row in pool.query_rows(r#"SELECT name, "unique" AS is_unique, origin FROM pragma_index_list(?) ORDER BY name"#, &arg).await? {
303 let Some(index) = text(&row, "name") else { continue };
304 let (unique, primary) = (truthy(&row, "is_unique"), text(&row, "origin").as_deref() == Some("pk"));
305 let columns = pool.query_rows("SELECT name FROM pragma_index_info(?) ORDER BY seqno", &[Value::Text(index.clone())]).await?;
306 push_index(&mut t.indexes, index.clone(), unique, primary, None);
307 for c in columns {
308 push_index(&mut t.indexes, index.clone(), unique, primary, text(&c, "name"));
309 }
310 }
311 let mut groups = Vec::new();
312 for row in pool
313 .query_rows(
314 r#"SELECT id, "table" AS ref_table, "from" AS from_column, "to" AS to_column, on_update, on_delete FROM pragma_foreign_key_list(?) ORDER BY id, seq"#,
315 &arg,
316 )
317 .await?
318 {
319 push_foreign_key(
320 &mut t.foreign_keys,
321 None,
322 &text(&row, "id").unwrap_or_default(),
323 text(&row, "from_column").unwrap_or_default(),
324 text(&row, "ref_table").unwrap_or_default(),
325 text(&row, "to_column"),
326 text(&row, "on_update").unwrap_or_else(|| "NO ACTION".to_string()),
327 text(&row, "on_delete").unwrap_or_else(|| "NO ACTION".to_string()),
328 &mut groups,
329 );
330 }
331 tables.push(t);
332 }
333 Ok(tables)
334}
335
336#[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
340async fn catalog(pool: &DbPool, table_sql: &str, column_sql: &str, index_sql: &str, key_sql: &str) -> Result<Vec<TableSchema>, DbError> {
341 let mut tables: Vec<TableSchema> = pool.query_rows(table_sql, &[]).await?.iter().filter_map(|r| text(r, "table_name")).map(table).collect();
342 let find = |tables: &[TableSchema], name: &str| tables.iter().position(|t| t.name == name);
343 for row in pool.query_rows(column_sql, &[]).await? {
344 let Some(i) = text(&row, "table_name").and_then(|n| find(&tables, &n)) else { continue };
345 tables[i].columns.push(ColumnSchema {
346 name: text(&row, "column_name").unwrap_or_default(),
347 data_type: text(&row, "data_type").unwrap_or_default(),
348 nullable: truthy(&row, "is_nullable"),
349 default: text(&row, "column_default"),
350 primary_key: false,
351 });
352 }
353 for row in pool.query_rows(index_sql, &[]).await? {
354 let Some(i) = text(&row, "table_name").and_then(|n| find(&tables, &n)) else { continue };
355 let (index, primary) = (text(&row, "index_name").unwrap_or_default(), truthy(&row, "is_primary"));
356 let column = text(&row, "column_name");
357 if primary {
358 tables[i].primary_key.extend(column.clone());
359 }
360 push_index(&mut tables[i].indexes, index, truthy(&row, "is_unique"), primary, column);
361 }
362 let mut groups: Vec<Vec<String>> = vec![Vec::new(); tables.len()];
363 for row in pool.query_rows(key_sql, &[]).await? {
364 let Some(i) = text(&row, "table_name").and_then(|n| find(&tables, &n)) else { continue };
365 let name = text(&row, "constraint_name").unwrap_or_default();
366 push_foreign_key(
367 &mut tables[i].foreign_keys,
368 Some(name.clone()),
369 &name,
370 text(&row, "column_name").unwrap_or_default(),
371 text(&row, "ref_table").unwrap_or_default(),
372 text(&row, "ref_column"),
373 text(&row, "on_update").unwrap_or_else(|| "NO ACTION".to_string()),
374 text(&row, "on_delete").unwrap_or_else(|| "NO ACTION".to_string()),
375 &mut groups[i],
376 );
377 }
378 Ok(tables)
379}
380
381#[cfg(feature = "model-postgres")]
382async fn postgres(pool: &DbPool) -> Result<Vec<TableSchema>, DbError> {
383 catalog(
384 pool,
385 "SELECT table_name::text AS table_name FROM information_schema.tables \
386 WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name",
387 "SELECT table_name::text AS table_name, column_name::text AS column_name, data_type::text AS data_type, \
388 is_nullable::text AS is_nullable, column_default::text AS column_default \
389 FROM information_schema.columns WHERE table_schema = current_schema() ORDER BY table_name, ordinal_position",
390 "SELECT t.relname::text AS table_name, i.relname::text AS index_name, ix.indisunique AS is_unique, \
391 ix.indisprimary AS is_primary, a.attname::text AS column_name \
392 FROM pg_index ix \
393 JOIN pg_class t ON t.oid = ix.indrelid \
394 JOIN pg_class i ON i.oid = ix.indexrelid \
395 JOIN pg_namespace ns ON ns.oid = t.relnamespace \
396 CROSS JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord) \
397 JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum \
398 WHERE ns.nspname = current_schema() \
399 ORDER BY t.relname, i.relname, k.ord",
400 "SELECT t.relname::text AS table_name, c.conname::text AS constraint_name, a.attname::text AS column_name, \
401 rt.relname::text AS ref_table, ra.attname::text AS ref_column, \
402 CASE c.confupdtype WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' ELSE 'NO ACTION' END AS on_update, \
403 CASE c.confdeltype WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' ELSE 'NO ACTION' END AS on_delete \
404 FROM pg_constraint c \
405 JOIN pg_class t ON t.oid = c.conrelid \
406 JOIN pg_class rt ON rt.oid = c.confrelid \
407 JOIN pg_namespace ns ON ns.oid = t.relnamespace \
408 CROSS JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS k(attnum, refnum, ord) \
409 JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum \
410 JOIN pg_attribute ra ON ra.attrelid = c.confrelid AND ra.attnum = k.refnum \
411 WHERE c.contype = 'f' AND ns.nspname = current_schema() \
412 ORDER BY t.relname, c.conname, k.ord",
413 )
414 .await
415}
416
417#[cfg(feature = "model-mysql")]
418async fn mysql(pool: &DbPool) -> Result<Vec<TableSchema>, DbError> {
419 catalog(
420 pool,
421 "SELECT CAST(TABLE_NAME AS CHAR) AS table_name FROM information_schema.TABLES \
422 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME",
423 "SELECT CAST(TABLE_NAME AS CHAR) AS table_name, CAST(COLUMN_NAME AS CHAR) AS column_name, \
424 CAST(COLUMN_TYPE AS CHAR) AS data_type, CAST(IS_NULLABLE AS CHAR) AS is_nullable, \
425 CAST(COLUMN_DEFAULT AS CHAR) AS column_default \
426 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME, ORDINAL_POSITION",
427 "SELECT CAST(TABLE_NAME AS CHAR) AS table_name, CAST(INDEX_NAME AS CHAR) AS index_name, \
428 CAST(NON_UNIQUE = 0 AS SIGNED) AS is_unique, CAST(INDEX_NAME = 'PRIMARY' AS SIGNED) AS is_primary, \
429 CAST(COLUMN_NAME AS CHAR) AS column_name \
430 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() \
431 ORDER BY TABLE_NAME, INDEX_NAME = 'PRIMARY' DESC, INDEX_NAME, SEQ_IN_INDEX",
432 "SELECT CAST(k.TABLE_NAME AS CHAR) AS table_name, CAST(k.CONSTRAINT_NAME AS CHAR) AS constraint_name, \
433 CAST(k.COLUMN_NAME AS CHAR) AS column_name, CAST(k.REFERENCED_TABLE_NAME AS CHAR) AS ref_table, \
434 CAST(k.REFERENCED_COLUMN_NAME AS CHAR) AS ref_column, CAST(r.UPDATE_RULE AS CHAR) AS on_update, \
435 CAST(r.DELETE_RULE AS CHAR) AS on_delete \
436 FROM information_schema.KEY_COLUMN_USAGE k \
437 JOIN information_schema.REFERENTIAL_CONSTRAINTS r \
438 ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND r.TABLE_NAME = k.TABLE_NAME \
439 WHERE k.TABLE_SCHEMA = DATABASE() AND k.REFERENCED_TABLE_NAME IS NOT NULL \
440 ORDER BY k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION",
441 )
442 .await
443}