Skip to main content

erdify_rs/
db.rs

1//! Schema extraction from the PostgreSQL system catalog.
2
3use 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
14/// Maximum delay for establishing the connection.
15const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
16
17/// Raw constraint as read from `pg_constraint`.
18struct ConstraintRow {
19    table_oid: Oid,
20    name: String,
21    /// `p` (primary key), `f` (foreign key), `u` (unique) or `c` (check).
22    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
30/// Raw index as read from `pg_index`.
31struct IndexRow {
32    table_oid: Oid,
33    name: String,
34    columns: Vec<String>,
35    is_unique: bool,
36}
37
38/// Raw column as read from `pg_attribute`.
39struct ColumnRow {
40    table_oid: Oid,
41    name: String,
42    data_type: String,
43    not_null: bool,
44    /// Raw `DEFAULT` expression from `pg_attrdef`, if any.
45    default_expr: Option<String>,
46}
47
48/// Identity of a table in the catalog.
49struct TableRow {
50    oid: Oid,
51    schema: String,
52    name: String,
53    /// `r`/`p` (table), `v` (view) or `m` (materialized view).
54    relkind: String,
55}
56
57/// Maps a `pg_class.relkind` value to the corresponding [`TableKind`].
58///
59/// The catalog query only ever returns `r`, `p`, `v` or `m` (see the
60/// `relkind IN (...)` filter in [`fetch_table_list`]), so any other value
61/// falls back to [`TableKind::Table`] rather than panicking.
62fn table_kind(relkind: &str) -> TableKind {
63    match relkind {
64        "v" => TableKind::View,
65        "m" => TableKind::MaterializedView,
66        _ => TableKind::Table,
67    }
68}
69
70/// Connection to the PostgreSQL database with a timeout.
71///
72/// # Errors
73///
74/// Returns [`ErdifyError::ConnectionTimeout`] if the connection doesn't
75/// succeed within the allotted time, [`ErdifyError::DatabaseConnection`] if
76/// the server refuses the connection.
77pub async fn connect(info: &ConnectionInfo) -> Result<Client, ErdifyError> {
78    // The configuration is built field by field rather than by concatenating
79    // a URL: a password containing `@`, `/` or `:` would break the
80    // connection string.
81    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
113/// Validates the connection by running a simple query.
114///
115/// # Errors
116///
117/// Returns [`ErdifyError::DatabaseConnection`] if the query fails.
118pub 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
126/// Fetches all tables of the specified schemas, along with their metadata.
127///
128/// Tables are returned sorted by `(schema, name)` so the output is
129/// reproducible across runs.
130///
131/// # Errors
132///
133/// Returns [`ErdifyError::QueryError`] if one of the catalog queries fails.
134pub 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    // The three queries are independent, but tokio-postgres serializes
150    // queries from the same client: chaining them remains the simplest approach.
151    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
162/// Lists ordinary and partitioned tables of the requested schemas.
163async fn fetch_table_list(client: &Client, schemas: &[&str]) -> Result<Vec<TableRow>, ErdifyError> {
164    // `$1` is NULL when no schema is requested: the predicate is then
165    // neutralized and only system schemas remain excluded. `v` (view) and
166    // `m` (materialized view) are included alongside ordinary/partitioned
167    // tables so they're rendered as entities too.
168    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
202/// Loads the columns (name, type, nullability) of the requested tables.
203async fn fetch_columns(client: &Client, oids: &[Oid]) -> Result<Vec<ColumnRow>, ErdifyError> {
204    // `format_type` returns the type as PostgreSQL displays it, including for
205    // user-defined types and domains: no "unknown" type. The `LEFT JOIN` on
206    // `pg_attrdef` stays empty for views/materialized views, which can't
207    // declare column defaults: `default_expr` is naturally `NULL` for them.
208    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
238/// Loads the PK, FK, UNIQUE, and CHECK constraints of the requested tables.
239async fn fetch_constraints(
240    client: &Client,
241    oids: &[Oid],
242) -> Result<Vec<ConstraintRow>, ErdifyError> {
243    // `WITH ORDINALITY` preserves the column order declared in the
244    // constraint, which a plain join on `pg_attribute` doesn't guarantee.
245    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
291/// Loads the indexes of the requested tables, excluding the primary key index.
292async fn fetch_indexes(client: &Client, oids: &[Oid]) -> Result<Vec<IndexRow>, ErdifyError> {
293    // `indkey` is an `int2vector`: the explicit cast to `smallint[]` allows
294    // using `unnest ... WITH ORDINALITY`. Entries of 0 (expression columns)
295    // don't join any row and are naturally ignored.
296    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
328/// Assembles the raw catalog rows into [`Table`]s, sorted by `(schema, name)`.
329fn assemble_tables(
330    table_rows: Vec<TableRow>,
331    columns: Vec<ColumnRow>,
332    constraints: Vec<ConstraintRow>,
333    indexes: Vec<IndexRow>,
334) -> Vec<Table> {
335    // The order of `table_rows` comes from an `ORDER BY`: it's preserved by
336    // indexing positions rather than iterating a `HashMap`.
337    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                // `ref_schema` / `ref_table` are NULL if the target table
375                // disappeared between two queries: the FK is then unusable.
376                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
412/// Warns on stderr for each requested schema that has no table.
413fn 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
426/// Warns on stderr for each requested table that couldn't be found.
427fn 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
448/// Verifies at compile time that the query parameters remain `ToSql`.
449const _: 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        // Ne panique pas : le message est écrit sur stderr, sans valeur de retour à vérifier.
703        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        // Ne panique pas : le message est écrit sur stderr, sans valeur de retour à vérifier.
729        warn_missing_tables(&["users", "ghost"], &[table]);
730    }
731}