1use crate::config::ConnectionInfo;
4use crate::errors::ErdifyError;
5use crate::schema::{
6 CheckConstraint, Column, ForeignKey, IndexInfo, SYSTEM_SCHEMAS, Table, TableKind,
7 UniqueConstraint,
8};
9use std::collections::{HashMap, HashSet};
10use tokio::time::{Duration, timeout};
11use tokio_postgres::types::{Oid, ToSql};
12use tokio_postgres::{Client, NoTls};
13
14const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
16
17struct ConstraintRow {
19 table_oid: Oid,
20 name: String,
21 kind: String,
23 columns: Vec<String>,
24 ref_schema: Option<String>,
25 ref_table: Option<String>,
26 ref_columns: Vec<String>,
27 definition: String,
28}
29
30struct IndexRow {
32 table_oid: Oid,
33 name: String,
34 columns: Vec<String>,
35 is_unique: bool,
36}
37
38struct ColumnRow {
40 table_oid: Oid,
41 name: String,
42 data_type: String,
43 not_null: bool,
44 default_expr: Option<String>,
46}
47
48struct TableRow {
50 oid: Oid,
51 schema: String,
52 name: String,
53 relkind: String,
55}
56
57fn table_kind(relkind: &str) -> TableKind {
63 match relkind {
64 "v" => TableKind::View,
65 "m" => TableKind::MaterializedView,
66 _ => TableKind::Table,
67 }
68}
69
70pub async fn connect(info: &ConnectionInfo) -> Result<Client, ErdifyError> {
78 let mut config = tokio_postgres::Config::new();
82 config
83 .host(&info.host)
84 .port(info.port)
85 .dbname(&info.database)
86 .connect_timeout(CONNECT_TIMEOUT);
87
88 if !info.user.is_empty() {
89 config.user(&info.user);
90 }
91 if !info.password.is_empty() {
92 config.password(&info.password);
93 }
94
95 let fut = config.connect(NoTls);
96
97 match timeout(CONNECT_TIMEOUT, fut).await {
98 Ok(Ok((client, connection))) => {
99 tokio::spawn(async move {
100 if let Err(e) = connection.await {
101 eprintln!("warning: connection interrupted: {e}");
102 }
103 });
104
105 ping(&client).await?;
106 Ok(client)
107 }
108 Ok(Err(e)) => Err(ErdifyError::DatabaseConnection(e.to_string())),
109 Err(_) => Err(ErdifyError::ConnectionTimeout),
110 }
111}
112
113pub async fn ping(client: &Client) -> Result<(), ErdifyError> {
119 client
120 .query_one("SELECT 1", &[])
121 .await
122 .map_err(|e| ErdifyError::DatabaseConnection(e.to_string()))?;
123 Ok(())
124}
125
126pub async fn fetch_tables(
135 client: &Client,
136 schemas: &[&str],
137 tables_filter: &[&str],
138 ignore_tables: &[&str],
139) -> Result<Vec<Table>, ErdifyError> {
140 let table_rows = fetch_table_list(client, schemas).await?;
141 warn_missing_schemas(schemas, &table_rows);
142
143 if table_rows.is_empty() {
144 return Ok(Vec::new());
145 }
146
147 let oids: Vec<Oid> = table_rows.iter().map(|t| t.oid).collect();
148
149 let columns = fetch_columns(client, &oids).await?;
152 let constraints = fetch_constraints(client, &oids).await?;
153 let indexes = fetch_indexes(client, &oids).await?;
154
155 let tables = assemble_tables(table_rows, columns, constraints, indexes);
156 let tables = crate::schema::filter_tables(tables, schemas, tables_filter, ignore_tables);
157 warn_missing_tables(tables_filter, &tables);
158
159 Ok(tables)
160}
161
162async fn fetch_table_list(client: &Client, schemas: &[&str]) -> Result<Vec<TableRow>, ErdifyError> {
164 let query = "\
169 SELECT c.oid, n.nspname AS schema_name, c.relname AS table_name, \
170 c.relkind::text AS relkind \
171 FROM pg_class c \
172 JOIN pg_namespace n ON n.oid = c.relnamespace \
173 WHERE c.relkind IN ('r', 'p', 'v', 'm') \
174 AND NOT c.relispartition \
175 AND n.nspname <> ALL($2) \
176 AND ($1::text[] IS NULL OR n.nspname = ANY($1)) \
177 ORDER BY n.nspname, c.relname";
178
179 let schemas_param: Option<Vec<&str>> = if schemas.is_empty() {
180 None
181 } else {
182 Some(schemas.to_vec())
183 };
184 let system: Vec<&str> = SYSTEM_SCHEMAS.to_vec();
185
186 let rows = client
187 .query(query, &[&schemas_param, &system])
188 .await
189 .map_err(|e| ErdifyError::QueryError(e.to_string()))?;
190
191 Ok(rows
192 .into_iter()
193 .map(|row| TableRow {
194 oid: row.get("oid"),
195 schema: row.get("schema_name"),
196 name: row.get("table_name"),
197 relkind: row.get("relkind"),
198 })
199 .collect())
200}
201
202async fn fetch_columns(client: &Client, oids: &[Oid]) -> Result<Vec<ColumnRow>, ErdifyError> {
204 let query = "\
209 SELECT a.attrelid AS table_oid, \
210 a.attname AS column_name, \
211 format_type(a.atttypid, a.atttypmod) AS data_type, \
212 a.attnotnull AS not_null, \
213 pg_get_expr(ad.adbin, ad.adrelid) AS default_expr \
214 FROM pg_attribute a \
215 LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum \
216 WHERE a.attrelid = ANY($1) \
217 AND a.attnum > 0 \
218 AND NOT a.attisdropped \
219 ORDER BY a.attrelid, a.attnum";
220
221 let rows = client
222 .query(query, &[&oids])
223 .await
224 .map_err(|e| ErdifyError::QueryError(e.to_string()))?;
225
226 Ok(rows
227 .into_iter()
228 .map(|row| ColumnRow {
229 table_oid: row.get("table_oid"),
230 name: row.get("column_name"),
231 data_type: row.get("data_type"),
232 not_null: row.get("not_null"),
233 default_expr: row.get("default_expr"),
234 })
235 .collect())
236}
237
238async fn fetch_constraints(
240 client: &Client,
241 oids: &[Oid],
242) -> Result<Vec<ConstraintRow>, ErdifyError> {
243 let query = "\
246 SELECT co.conrelid AS table_oid, \
247 co.conname AS constraint_name, \
248 co.contype::text AS constraint_type, \
249 ARRAY( \
250 SELECT a.attname \
251 FROM unnest(co.conkey) WITH ORDINALITY AS k(attnum, ord) \
252 JOIN pg_attribute a ON a.attrelid = co.conrelid AND a.attnum = k.attnum \
253 ORDER BY k.ord \
254 ) AS columns, \
255 rn.nspname AS ref_schema, \
256 rc.relname AS ref_table, \
257 ARRAY( \
258 SELECT a.attname \
259 FROM unnest(co.confkey) WITH ORDINALITY AS k(attnum, ord) \
260 JOIN pg_attribute a ON a.attrelid = co.confrelid AND a.attnum = k.attnum \
261 ORDER BY k.ord \
262 ) AS ref_columns, \
263 pg_get_constraintdef(co.oid) AS definition \
264 FROM pg_constraint co \
265 LEFT JOIN pg_class rc ON rc.oid = co.confrelid \
266 LEFT JOIN pg_namespace rn ON rn.oid = rc.relnamespace \
267 WHERE co.conrelid = ANY($1) \
268 AND co.contype IN ('p', 'f', 'u', 'c') \
269 ORDER BY co.conrelid, co.conname";
270
271 let rows = client
272 .query(query, &[&oids])
273 .await
274 .map_err(|e| ErdifyError::QueryError(e.to_string()))?;
275
276 Ok(rows
277 .into_iter()
278 .map(|row| ConstraintRow {
279 table_oid: row.get("table_oid"),
280 name: row.get("constraint_name"),
281 kind: row.get("constraint_type"),
282 columns: row.get("columns"),
283 ref_schema: row.get("ref_schema"),
284 ref_table: row.get("ref_table"),
285 ref_columns: row.get("ref_columns"),
286 definition: row.get("definition"),
287 })
288 .collect())
289}
290
291async fn fetch_indexes(client: &Client, oids: &[Oid]) -> Result<Vec<IndexRow>, ErdifyError> {
293 let query = "\
297 SELECT ix.indrelid AS table_oid, \
298 i.relname AS index_name, \
299 ix.indisunique AS is_unique, \
300 ARRAY( \
301 SELECT a.attname \
302 FROM unnest(ix.indkey::smallint[]) WITH ORDINALITY AS k(attnum, ord) \
303 JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = k.attnum \
304 ORDER BY k.ord \
305 ) AS columns \
306 FROM pg_index ix \
307 JOIN pg_class i ON i.oid = ix.indexrelid \
308 WHERE ix.indrelid = ANY($1) \
309 AND NOT ix.indisprimary \
310 ORDER BY ix.indrelid, i.relname";
311
312 let rows = client
313 .query(query, &[&oids])
314 .await
315 .map_err(|e| ErdifyError::QueryError(e.to_string()))?;
316
317 Ok(rows
318 .into_iter()
319 .map(|row| IndexRow {
320 table_oid: row.get("table_oid"),
321 name: row.get("index_name"),
322 columns: row.get("columns"),
323 is_unique: row.get("is_unique"),
324 })
325 .collect())
326}
327
328fn assemble_tables(
330 table_rows: Vec<TableRow>,
331 columns: Vec<ColumnRow>,
332 constraints: Vec<ConstraintRow>,
333 indexes: Vec<IndexRow>,
334) -> Vec<Table> {
335 let mut tables: Vec<Table> = Vec::with_capacity(table_rows.len());
338 let mut position: HashMap<Oid, usize> = HashMap::with_capacity(table_rows.len());
339
340 for row in table_rows {
341 position.insert(row.oid, tables.len());
342 tables.push(Table {
343 schema: row.schema,
344 name: row.name,
345 kind: table_kind(&row.relkind),
346 ..Table::default()
347 });
348 }
349
350 for col in columns {
351 let Some(&idx) = position.get(&col.table_oid) else {
352 continue;
353 };
354 let table = &mut tables[idx];
355 if col.not_null {
356 table.not_null_cols.insert(col.name.clone());
357 }
358 table.columns.push(Column {
359 name: col.name,
360 data_type: col.data_type,
361 default: col.default_expr,
362 });
363 }
364
365 for c in constraints {
366 let Some(&idx) = position.get(&c.table_oid) else {
367 continue;
368 };
369 let table = &mut tables[idx];
370
371 match c.kind.as_str() {
372 "p" => table.primary_keys = c.columns,
373 "f" => {
374 if let (Some(to_schema), Some(to_table)) = (c.ref_schema, c.ref_table) {
377 table.foreign_keys.push(ForeignKey {
378 name: c.name,
379 from_columns: c.columns,
380 to_schema,
381 to_table,
382 to_columns: c.ref_columns,
383 });
384 }
385 }
386 "u" => table.unique_constraints.push(UniqueConstraint {
387 name: c.name,
388 columns: c.columns,
389 }),
390 "c" => table.check_constraints.push(CheckConstraint {
391 name: c.name,
392 definition: c.definition,
393 }),
394 _ => {}
395 }
396 }
397
398 for idx_row in indexes {
399 let Some(&idx) = position.get(&idx_row.table_oid) else {
400 continue;
401 };
402 tables[idx].indexes.push(IndexInfo {
403 name: idx_row.name,
404 columns: idx_row.columns,
405 is_unique: idx_row.is_unique,
406 });
407 }
408
409 tables
410}
411
412fn warn_missing_schemas(requested: &[&str], found: &[TableRow]) {
414 if requested.is_empty() {
415 return;
416 }
417
418 let present: HashSet<&str> = found.iter().map(|t| t.schema.as_str()).collect();
419 for schema in requested {
420 if !present.contains(schema) {
421 eprintln!("warning: no table found in schema \"{schema}\"");
422 }
423 }
424}
425
426fn warn_missing_tables(requested: &[&str], found: &[Table]) {
428 if requested.is_empty() {
429 return;
430 }
431
432 let present: HashSet<&str> = found.iter().map(|t| t.name.as_str()).collect();
433 let missing: Vec<&&str> = requested
434 .iter()
435 .filter(|t| !present.contains(**t))
436 .collect();
437
438 if !missing.is_empty() {
439 let list = missing
440 .iter()
441 .map(|t| format!("\"{t}\""))
442 .collect::<Vec<_>>()
443 .join(", ");
444 eprintln!("warning: table(s) not found: {list}");
445 }
446}
447
448const _: fn() = || {
450 fn assert_to_sql<T: ToSql + Sync>() {}
451 assert_to_sql::<Vec<Oid>>();
452 assert_to_sql::<Option<Vec<&str>>>();
453};
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 fn table_row(oid: Oid, schema: &str, name: &str) -> TableRow {
460 table_row_with_kind(oid, schema, name, "r")
461 }
462
463 fn table_row_with_kind(oid: Oid, schema: &str, name: &str, relkind: &str) -> TableRow {
464 TableRow {
465 oid,
466 schema: schema.to_string(),
467 name: name.to_string(),
468 relkind: relkind.to_string(),
469 }
470 }
471
472 #[test]
473 fn assemble_tables_preserves_catalog_order() {
474 let rows = vec![
475 table_row(1, "extended", "audit"),
476 table_row(2, "public", "orders"),
477 table_row(3, "public", "users"),
478 ];
479
480 let tables = assemble_tables(rows, Vec::new(), Vec::new(), Vec::new());
481
482 let keys: Vec<_> = tables.iter().map(Table::key).collect();
483 assert_eq!(
484 keys,
485 vec![
486 ("extended", "audit"),
487 ("public", "orders"),
488 ("public", "users"),
489 ]
490 );
491 }
492
493 #[test]
494 fn assemble_tables_attaches_columns_and_not_null() {
495 let rows = vec![table_row(1, "public", "users")];
496 let columns = vec![
497 ColumnRow {
498 table_oid: 1,
499 name: "id".to_string(),
500 data_type: "integer".to_string(),
501 not_null: true,
502 default_expr: None,
503 },
504 ColumnRow {
505 table_oid: 1,
506 name: "bio".to_string(),
507 data_type: "text".to_string(),
508 not_null: false,
509 default_expr: None,
510 },
511 ];
512
513 let tables = assemble_tables(rows, columns, Vec::new(), Vec::new());
514
515 assert_eq!(tables[0].columns.len(), 2);
516 assert_eq!(tables[0].columns[0].name, "id");
517 assert!(tables[0].not_null_cols.contains("id"));
518 assert!(!tables[0].not_null_cols.contains("bio"));
519 }
520
521 #[test]
522 fn assemble_tables_attaches_column_default() {
523 let rows = vec![table_row(1, "public", "users")];
524 let columns = vec![
525 ColumnRow {
526 table_oid: 1,
527 name: "created_at".to_string(),
528 data_type: "timestamp".to_string(),
529 not_null: true,
530 default_expr: Some("now()".to_string()),
531 },
532 ColumnRow {
533 table_oid: 1,
534 name: "bio".to_string(),
535 data_type: "text".to_string(),
536 not_null: false,
537 default_expr: None,
538 },
539 ];
540
541 let tables = assemble_tables(rows, columns, Vec::new(), Vec::new());
542
543 assert_eq!(tables[0].columns[0].default, Some("now()".to_string()));
544 assert_eq!(tables[0].columns[1].default, None);
545 }
546
547 #[test]
548 fn assemble_tables_assigns_kind_from_relkind() {
549 let rows = vec![
550 table_row_with_kind(1, "public", "users", "r"),
551 table_row_with_kind(2, "public", "orders_p1", "p"),
552 table_row_with_kind(3, "public", "orders_summary", "v"),
553 table_row_with_kind(4, "public", "orders_summary_mat", "m"),
554 ];
555
556 let tables = assemble_tables(rows, Vec::new(), Vec::new(), Vec::new());
557
558 assert_eq!(tables[0].kind, TableKind::Table);
559 assert_eq!(tables[1].kind, TableKind::Table);
560 assert_eq!(tables[2].kind, TableKind::View);
561 assert_eq!(tables[3].kind, TableKind::MaterializedView);
562 }
563
564 #[test]
565 fn assemble_tables_dispatches_constraints_by_type() {
566 let rows = vec![table_row(1, "public", "orders")];
567 let constraints = vec![
568 ConstraintRow {
569 table_oid: 1,
570 name: "orders_pkey".to_string(),
571 kind: "p".to_string(),
572 columns: vec!["id".to_string()],
573 ref_schema: None,
574 ref_table: None,
575 ref_columns: Vec::new(),
576 definition: "PRIMARY KEY (id)".to_string(),
577 },
578 ConstraintRow {
579 table_oid: 1,
580 name: "orders_user_fkey".to_string(),
581 kind: "f".to_string(),
582 columns: vec!["user_id".to_string()],
583 ref_schema: Some("public".to_string()),
584 ref_table: Some("users".to_string()),
585 ref_columns: vec!["id".to_string()],
586 definition: "FOREIGN KEY (user_id) REFERENCES users(id)".to_string(),
587 },
588 ConstraintRow {
589 table_oid: 1,
590 name: "orders_ref_key".to_string(),
591 kind: "u".to_string(),
592 columns: vec!["reference".to_string()],
593 ref_schema: None,
594 ref_table: None,
595 ref_columns: Vec::new(),
596 definition: "UNIQUE (reference)".to_string(),
597 },
598 ConstraintRow {
599 table_oid: 1,
600 name: "orders_total_check".to_string(),
601 kind: "c".to_string(),
602 columns: vec!["total".to_string()],
603 ref_schema: None,
604 ref_table: None,
605 ref_columns: Vec::new(),
606 definition: "CHECK ((total > 0))".to_string(),
607 },
608 ];
609
610 let tables = assemble_tables(rows, Vec::new(), constraints, Vec::new());
611 let table = &tables[0];
612
613 assert_eq!(table.primary_keys, vec!["id".to_string()]);
614 assert_eq!(table.foreign_keys.len(), 1);
615 assert_eq!(table.foreign_keys[0].to_schema, "public");
616 assert_eq!(table.foreign_keys[0].to_table, "users");
617 assert_eq!(table.unique_constraints[0].name, "orders_ref_key");
618 assert_eq!(table.check_constraints[0].definition, "CHECK ((total > 0))");
619 }
620
621 #[test]
622 fn assemble_tables_drops_foreign_key_without_target() {
623 let rows = vec![table_row(1, "public", "orders")];
624 let constraints = vec![ConstraintRow {
625 table_oid: 1,
626 name: "dangling".to_string(),
627 kind: "f".to_string(),
628 columns: vec!["user_id".to_string()],
629 ref_schema: None,
630 ref_table: None,
631 ref_columns: Vec::new(),
632 definition: String::new(),
633 }];
634
635 let tables = assemble_tables(rows, Vec::new(), constraints, Vec::new());
636
637 assert!(tables[0].foreign_keys.is_empty());
638 }
639
640 #[test]
641 fn assemble_tables_ignores_rows_of_unknown_tables() {
642 let rows = vec![table_row(1, "public", "users")];
643 let columns = vec![ColumnRow {
644 table_oid: 999,
645 name: "ghost".to_string(),
646 data_type: "text".to_string(),
647 not_null: false,
648 default_expr: None,
649 }];
650
651 let tables = assemble_tables(rows, columns, Vec::new(), Vec::new());
652
653 assert!(tables[0].columns.is_empty());
654 }
655
656 #[test]
657 fn assemble_tables_attaches_indexes() {
658 let rows = vec![table_row(1, "public", "users")];
659 let indexes = vec![IndexRow {
660 table_oid: 1,
661 name: "idx_users_email".to_string(),
662 columns: vec!["email".to_string()],
663 is_unique: true,
664 }];
665
666 let tables = assemble_tables(rows, Vec::new(), Vec::new(), indexes);
667
668 assert_eq!(tables[0].indexes.len(), 1);
669 assert_eq!(tables[0].indexes[0].name, "idx_users_email");
670 assert!(tables[0].indexes[0].is_unique);
671 }
672
673 #[test]
674 fn assemble_tables_ignores_indexes_of_unknown_tables() {
675 let rows = vec![table_row(1, "public", "users")];
676 let indexes = vec![IndexRow {
677 table_oid: 999,
678 name: "ghost_idx".to_string(),
679 columns: Vec::new(),
680 is_unique: false,
681 }];
682
683 let tables = assemble_tables(rows, Vec::new(), Vec::new(), indexes);
684
685 assert!(tables[0].indexes.is_empty());
686 }
687
688 #[test]
689 fn warn_missing_schemas_does_nothing_when_no_schema_requested() {
690 warn_missing_schemas(&[], &[]);
691 }
692
693 #[test]
694 fn warn_missing_schemas_does_nothing_when_all_present() {
695 let found = vec![table_row(1, "public", "users")];
696 warn_missing_schemas(&["public"], &found);
697 }
698
699 #[test]
700 fn warn_missing_schemas_warns_on_absent_schema() {
701 let found = vec![table_row(1, "public", "users")];
702 warn_missing_schemas(&["public", "extended"], &found);
704 }
705
706 #[test]
707 fn warn_missing_tables_does_nothing_when_no_table_requested() {
708 warn_missing_tables(&[], &[]);
709 }
710
711 #[test]
712 fn warn_missing_tables_does_nothing_when_all_present() {
713 let table = Table {
714 schema: "public".to_string(),
715 name: "users".to_string(),
716 ..Table::default()
717 };
718 warn_missing_tables(&["users"], &[table]);
719 }
720
721 #[test]
722 fn warn_missing_tables_warns_on_absent_table() {
723 let table = Table {
724 schema: "public".to_string(),
725 name: "users".to_string(),
726 ..Table::default()
727 };
728 warn_missing_tables(&["users", "ghost"], &[table]);
730 }
731}