Skip to main content

prax_cli/commands/
schema_from_db.rs

1//! Map a `prax-query` introspection result to a `prax_schema::Schema`.
2//!
3//! `prax migrate dev`/`diff` need the *current* database structure expressed
4//! as a `prax_schema::Schema` so it can be fed to `prax_migrate::SchemaDiffer`
5//! as the diff **source**. The database driver (see [`crate::commands::introspect`])
6//! produces a [`prax_query::introspection::DatabaseSchema`]; the migration
7//! engine's [`prax_migrate::SchemaBuilder`] already knows how to turn its own
8//! raw introspection structs (`TableInfo`/`ColumnInfo`/`ConstraintInfo`/
9//! `IndexInfo`/`EnumInfo`) into a `Schema`, complete with foreign-key relation
10//! synthesis, `@map`, `@@index`/`@@unique`, and primary-key detection — and
11//! that output is proven to round-trip cleanly with the differ.
12//!
13//! This module bridges the two: it translates the query-layer `DatabaseSchema`
14//! into the migration engine's introspection structs and runs them through
15//! `SchemaBuilder`. Keeping the translation here (rather than in `prax-migrate`)
16//! preserves the crate layering — `prax-migrate` depends only on `prax-schema`,
17//! while the CLI already depends on both `prax-migrate` and `prax-query`.
18//!
19//! ## Round-trip fidelity
20//!
21//! The differ compares fields by name (SQL type, nullability, default),
22//! foreign keys by constraint name, and indexes by name. For a database that
23//! already matches its `.prax` schema to diff to *empty* (the "no spurious
24//! churn" property), the mapped source must reproduce the same constructs the
25//! target schema produces. Two mismatches are inherent to reverse-engineering
26//! and documented as limitations rather than papered over:
27//!
28//! - **Field vs column names.** A `.prax` field `authorId` mapped to column
29//!   `author_id` reverse-engineers to a field named `author_id`. Schemas whose
30//!   field names differ from their column names will show spurious add/drop
31//!   churn; schemas whose field names match their columns (snake_case
32//!   throughout) round-trip cleanly.
33//! - **Foreign-key constraint names.** The target auto-derives `fk_<table>_<cols>`
34//!   unless the relation carries `@relation(map: "...")`. Introspection reports
35//!   the *real* database constraint name. When they differ the differ proposes
36//!   dropping/adding the FK; pin the name with `@relation(map: ...)` to avoid it.
37
38use prax_migrate::{
39    ColumnInfo as MigrateColumn, ConstraintInfo, EnumInfo as MigrateEnum,
40    IndexInfo as MigrateIndex, IntrospectionConfig, IntrospectionResult, SchemaBuilder,
41    TableInfo as MigrateTable,
42};
43use prax_query::introspection::{
44    ColumnInfo, DatabaseSchema, ForeignKeyInfo, IndexInfo, NormalizedType, ReferentialAction,
45    TableInfo,
46};
47
48use crate::error::CliResult;
49
50/// Translate a query-layer [`DatabaseSchema`] into a `prax_schema::Schema`
51/// suitable as a diff source, using the migration engine's `SchemaBuilder`.
52///
53/// `config` controls which tables are included/excluded (e.g. a foreign
54/// runner's migration bookkeeping table); pass
55/// [`IntrospectionConfig::default`] for the standard exclusions.
56pub fn schema_from_database(
57    db: &DatabaseSchema,
58    config: IntrospectionConfig,
59) -> CliResult<IntrospectionResult> {
60    let mut builder = SchemaBuilder::new(config).with_tables(map_tables(db));
61
62    for table in &db.tables {
63        builder = builder
64            .with_columns(&table.name, map_columns(&table.columns))
65            .with_constraints(&table.name, map_constraints(table))
66            .with_indexes(&table.name, map_indexes(&table.indexes, &table.name));
67    }
68
69    builder = builder.with_enums(map_enums(db));
70
71    builder.build().map_err(|e| {
72        crate::error::CliError::Migration(format!(
73            "Failed to build schema from database introspection: {e}"
74        ))
75    })
76}
77
78/// Map every discovered table (base tables only; the query layer's `db pull`
79/// separates views into `DatabaseSchema::views`, so anything in `tables` is a
80/// base table) to the engine's `TableInfo`.
81fn map_tables(db: &DatabaseSchema) -> Vec<MigrateTable> {
82    db.tables
83        .iter()
84        .map(|t| MigrateTable {
85            name: t.name.clone(),
86            schema: t
87                .schema
88                .clone()
89                .or_else(|| db.schema.clone())
90                .unwrap_or_else(|| "public".to_string()),
91            table_type: "BASE TABLE".to_string(),
92            comment: t.comment.clone(),
93        })
94        .collect()
95}
96
97/// Map columns, deriving a canonical `udt_name` from the normalized type so
98/// the engine's `sql_type_to_prax` lands on the same `ScalarType` the target
99/// schema produces.
100fn map_columns(columns: &[ColumnInfo]) -> Vec<MigrateColumn> {
101    columns
102        .iter()
103        .enumerate()
104        .map(|(i, c)| MigrateColumn {
105            name: c.name.clone(),
106            data_type: c.db_type.clone(),
107            udt_name: udt_name_for(&c.normalized_type, &c.db_type),
108            character_maximum_length: c.max_length,
109            numeric_precision: c.precision,
110            is_nullable: c.nullable,
111            column_default: c.default.clone(),
112            ordinal_position: i as i32,
113            comment: c.comment.clone(),
114        })
115        .collect()
116}
117
118/// Derive a PostgreSQL `udt_name`-equivalent for a normalized type.
119///
120/// The engine's `SchemaBuilder::sql_type_to_prax` matches on `udt_name`
121/// first (falling back to `data_type`). Mapping the normalized type to the
122/// canonical short udt string it recognizes keeps type resolution robust
123/// even when `db_type` carries a display form (e.g. "character varying").
124fn udt_name_for(normalized: &NormalizedType, _db_type: &str) -> String {
125    match normalized {
126        NormalizedType::Int | NormalizedType::SmallInt => "int4".to_string(),
127        NormalizedType::BigInt => "int8".to_string(),
128        NormalizedType::Float => "float4".to_string(),
129        NormalizedType::Double => "float8".to_string(),
130        NormalizedType::Decimal { .. } => "numeric".to_string(),
131        NormalizedType::String
132        | NormalizedType::Text
133        | NormalizedType::VarChar { .. }
134        | NormalizedType::Char { .. } => "text".to_string(),
135        NormalizedType::Bytes => "bytea".to_string(),
136        NormalizedType::Boolean => "bool".to_string(),
137        NormalizedType::DateTime | NormalizedType::Timestamp => "timestamptz".to_string(),
138        NormalizedType::Date => "date".to_string(),
139        NormalizedType::Time => "time".to_string(),
140        NormalizedType::Json => "jsonb".to_string(),
141        NormalizedType::Uuid => "uuid".to_string(),
142        // Enum reference: the engine matches the udt_name against known enum
143        // names, so the enum type name must be passed through verbatim.
144        NormalizedType::Enum(name) => name.clone(),
145        // Arrays have no first-class Prax scalar; the engine treats the
146        // "ARRAY" data_type as Json. Fall through to db_type so its fallback
147        // path applies.
148        NormalizedType::Array(_) => "ARRAY".to_string(),
149        // An unrecognized type carries the *udt name* the introspector read
150        // (e.g. a Postgres enum type `global_role`, which reports
151        // `data_type = "USER-DEFINED"` but a real `udt_name`). Pass the udt
152        // name — NOT `db_type` — so the engine can resolve it against the
153        // introspected enum types. Using `db_type` here would hand the engine
154        // the literal `"USER-DEFINED"`, which resolves to nothing and made it
155        // skip every enum-bearing table (appearing as spurious new tables in
156        // the diff).
157        NormalizedType::Unknown(udt) => udt.clone(),
158    }
159}
160
161/// Map a table's primary key, foreign keys, and unique constraints to the
162/// engine's flat `ConstraintInfo` list. Single-column primary keys become
163/// `@id`; multi-column primary keys are carried as one PRIMARY KEY constraint
164/// (the engine reads all its columns). Unique constraints and foreign keys are
165/// mapped through so they are not re-proposed by the differ.
166fn map_constraints(table: &TableInfo) -> Vec<ConstraintInfo> {
167    let mut constraints = Vec::new();
168
169    if !table.primary_key.is_empty() {
170        constraints.push(ConstraintInfo {
171            name: format!("{}_pkey", table.name),
172            constraint_type: "PRIMARY KEY".to_string(),
173            table_name: table.name.clone(),
174            columns: table.primary_key.clone(),
175            referenced_table: None,
176            referenced_columns: None,
177            on_delete: None,
178            on_update: None,
179        });
180    }
181
182    for uc in &table.unique_constraints {
183        constraints.push(ConstraintInfo {
184            name: uc.name.clone(),
185            constraint_type: "UNIQUE".to_string(),
186            table_name: table.name.clone(),
187            columns: uc.columns.clone(),
188            referenced_table: None,
189            referenced_columns: None,
190            on_delete: None,
191            on_update: None,
192        });
193    }
194
195    for fk in &table.foreign_keys {
196        constraints.push(map_foreign_key(fk, &table.name));
197    }
198
199    constraints
200}
201
202/// Map a foreign key, translating referential actions to the SQL keyword
203/// form the engine expects (`NoAction` collapses to `None` — the SQL default
204/// — so it is not rendered redundantly).
205fn map_foreign_key(fk: &ForeignKeyInfo, table_name: &str) -> ConstraintInfo {
206    ConstraintInfo {
207        name: fk.name.clone(),
208        constraint_type: "FOREIGN KEY".to_string(),
209        table_name: table_name.to_string(),
210        columns: fk.columns.clone(),
211        referenced_table: Some(fk.referenced_table.clone()),
212        referenced_columns: Some(fk.referenced_columns.clone()),
213        on_delete: referential_action_sql(fk.on_delete),
214        on_update: referential_action_sql(fk.on_update),
215    }
216}
217
218/// Render a referential action as the SQL keyword the engine stores, or
219/// `None` for the default `NO ACTION` (which needs no clause).
220fn referential_action_sql(action: ReferentialAction) -> Option<String> {
221    match action {
222        ReferentialAction::NoAction => None,
223        ReferentialAction::Restrict => Some("RESTRICT".to_string()),
224        ReferentialAction::Cascade => Some("CASCADE".to_string()),
225        ReferentialAction::SetNull => Some("SET NULL".to_string()),
226        ReferentialAction::SetDefault => Some("SET DEFAULT".to_string()),
227    }
228}
229
230/// Map indexes, flattening the query layer's `IndexColumn` (which carries sort
231/// order/nulls position) to the engine's plain column-name list.
232///
233/// Every index reaching this function is carried through verbatim. MySQL's
234/// implicit FK-backing indexes (which would otherwise churn the diff against
235/// a `.prax` that only declares the relation) are filtered earlier, in the
236/// MySQL introspector itself (`commands::introspect::mysql`), since Postgres
237/// and MSSQL don't auto-index FK columns and shouldn't have their real
238/// `@@index`es dropped.
239fn map_indexes(indexes: &[IndexInfo], table_name: &str) -> Vec<MigrateIndex> {
240    // Every non-primary index is carried into the diff source verbatim.
241    //
242    // A previous version dropped non-unique indexes whose columns matched a
243    // foreign key, on the theory that an FK implies an index. PostgreSQL does
244    // NOT auto-create an index for a foreign key (only the *referenced* side's
245    // PK/unique is indexed), so those `<table>_<col>_idx` indexes are real,
246    // intentional objects. Dropping them left the source without them, so a
247    // schema `@@index` on an FK column looked new and churned an index that
248    // already existed. Keep them.
249    indexes
250        .iter()
251        .map(|idx| MigrateIndex {
252            name: idx.name.clone(),
253            table_name: table_name.to_string(),
254            columns: idx.columns.iter().map(|c| c.name.clone()).collect(),
255            is_unique: idx.is_unique,
256            is_primary: idx.is_primary,
257            index_method: idx
258                .index_type
259                .clone()
260                .unwrap_or_else(|| "btree".to_string()),
261        })
262        .collect()
263}
264
265/// Map enum types, carrying the schema-qualified name through.
266fn map_enums(db: &DatabaseSchema) -> Vec<MigrateEnum> {
267    db.enums
268        .iter()
269        .map(|e| MigrateEnum {
270            name: e.name.clone(),
271            values: e.values.clone(),
272            // "public" is only a meaningful default for PostgreSQL, whose
273            // introspector always populates `db.schema` before this runs.
274            // MySQL has no schema-namespace concept and never sets one, so
275            // falling all the way through to a hardcoded "public" here would
276            // mislabel it; leave it empty rather than claim a schema that
277            // doesn't exist.
278            schema: e
279                .schema
280                .clone()
281                .or_else(|| db.schema.clone())
282                .unwrap_or_default(),
283        })
284        .collect()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use prax_query::introspection::{EnumInfo, IndexColumn, UniqueConstraint};
291    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
292
293    fn column(name: &str, normalized: NormalizedType, nullable: bool) -> ColumnInfo {
294        ColumnInfo {
295            name: name.to_string(),
296            db_type: "".to_string(),
297            normalized_type: normalized,
298            nullable,
299            ..Default::default()
300        }
301    }
302
303    /// `prax_query::introspection::sanitize_identifier`/`sanitize_variants`
304    /// are duplicated in `prax_migrate::introspect` (that crate depends only
305    /// on `prax-schema`, not on `prax-query`) so `db pull`'s written schema
306    /// and `migrate dev`'s diff source apply the identical transform to a
307    /// MySQL enum's raw values. This crate depends on both, so it's the one
308    /// place that can assert the two copies haven't drifted apart.
309    #[test]
310    fn sanitize_identifier_matches_between_prax_query_and_prax_migrate() {
311        for raw in ["active", "in-progress", "1", "", "it's ok", "日本語"] {
312            assert_eq!(
313                prax_query::introspection::sanitize_identifier(raw),
314                prax_migrate::introspect::sanitize_identifier(raw),
315                "sanitize_identifier({raw:?}) diverged between prax-query and prax-migrate"
316            );
317        }
318    }
319
320    #[test]
321    fn sanitize_variants_matches_between_prax_query_and_prax_migrate() {
322        let raw = vec![
323            "in-progress".to_string(),
324            "in_progress".to_string(),
325            "done".to_string(),
326        ];
327        assert_eq!(
328            prax_query::introspection::sanitize_variants(&raw),
329            prax_migrate::introspect::sanitize_variants(&raw),
330        );
331    }
332
333    /// `prax_query::introspection::pascal_case` and
334    /// `prax_migrate::introspect::to_pascal_case` are two more copies of the
335    /// same transform, duplicated for the same crate-layering reason as
336    /// `sanitize_identifier`/`sanitize_variants` above — they must derive
337    /// the same name for the same raw input, or `db pull`'s written schema
338    /// and `migrate dev`'s diff source disagree on an enum/model's name and
339    /// churn every run.
340    #[test]
341    fn pascal_case_matches_between_prax_query_and_prax_migrate() {
342        for raw in ["users_status", "role", "FooBar", "a__b", "1099-forms", ""] {
343            assert_eq!(
344                prax_query::introspection::pascal_case(raw),
345                prax_migrate::introspect::to_pascal_case(raw),
346                "pascal_case({raw:?}) diverged between prax-query and prax-migrate"
347            );
348        }
349    }
350
351    #[test]
352    fn udt_name_maps_normalized_types_to_recognized_short_names() {
353        assert_eq!(udt_name_for(&NormalizedType::Int, ""), "int4");
354        assert_eq!(udt_name_for(&NormalizedType::BigInt, ""), "int8");
355        assert_eq!(udt_name_for(&NormalizedType::Boolean, ""), "bool");
356        assert_eq!(udt_name_for(&NormalizedType::DateTime, ""), "timestamptz");
357        assert_eq!(udt_name_for(&NormalizedType::Uuid, ""), "uuid");
358        assert_eq!(udt_name_for(&NormalizedType::Json, ""), "jsonb");
359        assert_eq!(
360            udt_name_for(&NormalizedType::VarChar { length: Some(255) }, ""),
361            "text"
362        );
363        assert_eq!(
364            udt_name_for(&NormalizedType::Enum("Role".to_string()), ""),
365            "Role"
366        );
367        // Unknown falls back to the raw db_type so the engine's data_type path applies.
368        assert_eq!(
369            udt_name_for(
370                &NormalizedType::Unknown("geography".to_string()),
371                "geography"
372            ),
373            "geography"
374        );
375    }
376
377    #[test]
378    fn referential_actions_map_to_sql_keywords() {
379        assert_eq!(referential_action_sql(ReferentialAction::NoAction), None);
380        assert_eq!(
381            referential_action_sql(ReferentialAction::Cascade),
382            Some("CASCADE".to_string())
383        );
384        assert_eq!(
385            referential_action_sql(ReferentialAction::SetNull),
386            Some("SET NULL".to_string())
387        );
388    }
389
390    #[test]
391    fn maps_a_simple_table_to_a_model_with_columns_and_pk() {
392        let db = DatabaseSchema {
393            name: "db".to_string(),
394            schema: Some("public".to_string()),
395            tables: vec![TableInfo {
396                name: "users".to_string(),
397                schema: Some("public".to_string()),
398                columns: vec![
399                    column("id", NormalizedType::BigInt, false),
400                    column("email", NormalizedType::Text, false),
401                    column("name", NormalizedType::Text, true),
402                ],
403                primary_key: vec!["id".to_string()],
404                ..Default::default()
405            }],
406            ..Default::default()
407        };
408
409        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
410        let model = result.schema.get_model("Users").expect("Users model");
411
412        let id = model.get_field("id").expect("id field");
413        assert!(id.has_attribute("id"));
414        assert!(matches!(
415            &id.field_type,
416            FieldType::Scalar(ScalarType::BigInt)
417        ));
418
419        let email = model.get_field("email").expect("email field");
420        assert_eq!(email.modifier, TypeModifier::Required);
421        assert!(matches!(
422            &email.field_type,
423            FieldType::Scalar(ScalarType::String)
424        ));
425
426        let name = model.get_field("name").expect("name field");
427        assert_eq!(name.modifier, TypeModifier::Optional);
428    }
429
430    #[test]
431    fn maps_foreign_keys_to_relation_fields() {
432        let db = DatabaseSchema {
433            name: "db".to_string(),
434            schema: Some("public".to_string()),
435            tables: vec![
436                TableInfo {
437                    name: "users".to_string(),
438                    columns: vec![column("id", NormalizedType::BigInt, false)],
439                    primary_key: vec!["id".to_string()],
440                    ..Default::default()
441                },
442                TableInfo {
443                    name: "posts".to_string(),
444                    columns: vec![
445                        column("id", NormalizedType::BigInt, false),
446                        column("author_id", NormalizedType::BigInt, false),
447                    ],
448                    primary_key: vec!["id".to_string()],
449                    foreign_keys: vec![ForeignKeyInfo {
450                        name: "posts_author_id_fkey".to_string(),
451                        columns: vec!["author_id".to_string()],
452                        referenced_table: "users".to_string(),
453                        referenced_schema: None,
454                        referenced_columns: vec!["id".to_string()],
455                        on_delete: ReferentialAction::Cascade,
456                        on_update: ReferentialAction::NoAction,
457                    }],
458                    ..Default::default()
459                },
460            ],
461            ..Default::default()
462        };
463
464        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
465        let posts = result.schema.get_model("Posts").expect("Posts model");
466        let author = posts.get_field("author").expect("relation field");
467        let rel = author
468            .extract_attributes()
469            .relation
470            .expect("@relation present");
471        assert_eq!(rel.fields, ["author_id"]);
472        assert_eq!(rel.references, ["id"]);
473    }
474
475    #[test]
476    fn maps_enums_and_enum_typed_columns() {
477        let db = DatabaseSchema {
478            name: "db".to_string(),
479            schema: Some("public".to_string()),
480            tables: vec![TableInfo {
481                name: "users".to_string(),
482                columns: vec![
483                    column("id", NormalizedType::BigInt, false),
484                    column("role", NormalizedType::Enum("role".to_string()), false),
485                ],
486                primary_key: vec!["id".to_string()],
487                ..Default::default()
488            }],
489            enums: vec![EnumInfo {
490                name: "role".to_string(),
491                schema: Some("public".to_string()),
492                values: vec!["ADMIN".to_string(), "USER".to_string()],
493            }],
494            ..Default::default()
495        };
496
497        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
498        let role_enum = result.schema.get_enum("Role").expect("Role enum");
499        // `@@map` must pin the real Postgres type name, or generated SQL
500        // (CREATE/ALTER/DROP TYPE) targets the nonexistent "Role" instead of
501        // the real "role" type.
502        assert_eq!(role_enum.database_name(), "role");
503        let users = result.schema.get_model("Users").expect("Users model");
504        let role = users.get_field("role").expect("role field");
505        assert!(matches!(&role.field_type, FieldType::Enum(_)));
506    }
507
508    #[test]
509    fn enum_variant_needing_sanitization_pins_its_real_value_with_map() {
510        let db = DatabaseSchema {
511            name: "db".to_string(),
512            schema: None,
513            tables: vec![TableInfo {
514                name: "tasks".to_string(),
515                columns: vec![column(
516                    "status",
517                    NormalizedType::Enum("tasks_status".to_string()),
518                    false,
519                )],
520                ..Default::default()
521            }],
522            enums: vec![EnumInfo {
523                name: "tasks_status".to_string(),
524                schema: None,
525                // MySQL enum values are unrestricted text; "in-progress"
526                // isn't a legal `.prax` identifier and gets sanitized to
527                // `in_progress` — `db_value()` must still resolve to the
528                // real value, or generated SQL never matches what's
529                // actually stored in the database.
530                values: vec!["in-progress".to_string(), "done".to_string()],
531            }],
532            ..Default::default()
533        };
534
535        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
536        let status_enum = result.schema.get_enum("TasksStatus").expect("enum");
537        let in_progress = status_enum
538            .get_variant("in_progress")
539            .expect("sanitized variant");
540        assert_eq!(in_progress.db_value(), "in-progress");
541        let done = status_enum
542            .get_variant("done")
543            .expect("unsanitized variant");
544        assert_eq!(done.db_value(), "done");
545    }
546
547    #[test]
548    fn generated_schema_with_quotes_in_enum_values_parses_back_losslessly() {
549        // `generate_prax_schema` (`db pull`'s text writer) and `build_enum`
550        // (the diff source's AST builder) must agree: values containing
551        // `"`/`\` are backslash-escaped on write and unescaped on parse, so
552        // a `db pull` → re-read round-trip preserves the real value instead
553        // of silently mangling it.
554        let db = DatabaseSchema {
555            name: "db".to_string(),
556            schema: None,
557            tables: vec![TableInfo {
558                name: "tasks".to_string(),
559                columns: vec![column(
560                    "status",
561                    NormalizedType::Enum("task_status".to_string()),
562                    false,
563                )],
564                ..Default::default()
565            }],
566            enums: vec![prax_query::introspection::EnumInfo {
567                name: "task_status".to_string(),
568                schema: None,
569                values: vec!["say \"hi\"".to_string(), "a\\b".to_string()],
570            }],
571            ..Default::default()
572        };
573
574        let text = prax_query::introspection::generate_prax_schema(&db);
575        let parsed = prax_schema::parse_schema(&text).expect("generated schema must parse");
576        let status = parsed.get_enum("TaskStatus").expect("enum");
577        let values: Vec<&str> = status.variants.iter().map(|v| v.db_value()).collect();
578        assert!(
579            values.contains(&"say \"hi\""),
580            "quote value lost, got: {values:?} from:\n{text}"
581        );
582        assert!(
583            values.contains(&"a\\b"),
584            "backslash value lost, got: {values:?} from:\n{text}"
585        );
586    }
587
588    #[test]
589    fn maps_multi_column_unique_index() {
590        let db = DatabaseSchema {
591            name: "db".to_string(),
592            schema: Some("public".to_string()),
593            tables: vec![TableInfo {
594                name: "memberships".to_string(),
595                columns: vec![
596                    column("team_id", NormalizedType::BigInt, false),
597                    column("user_id", NormalizedType::BigInt, false),
598                ],
599                primary_key: vec!["team_id".to_string(), "user_id".to_string()],
600                indexes: vec![IndexInfo {
601                    name: "uq_membership".to_string(),
602                    columns: vec![
603                        IndexColumn {
604                            name: "team_id".to_string(),
605                            ..Default::default()
606                        },
607                        IndexColumn {
608                            name: "user_id".to_string(),
609                            ..Default::default()
610                        },
611                    ],
612                    is_unique: true,
613                    is_primary: false,
614                    index_type: Some("btree".to_string()),
615                    filter: None,
616                }],
617                ..Default::default()
618            }],
619            ..Default::default()
620        };
621
622        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
623        let model = result
624            .schema
625            .get_model("Memberships")
626            .expect("Memberships model");
627        // Composite PK -> both id fields carry @id.
628        assert!(model.get_field("team_id").unwrap().has_attribute("id"));
629        assert!(model.get_field("user_id").unwrap().has_attribute("id"));
630        // Multi-column unique index -> @@unique.
631        assert!(model.get_attribute("unique").is_some());
632    }
633
634    #[test]
635    fn excluded_tables_are_skipped() {
636        let db = DatabaseSchema {
637            name: "db".to_string(),
638            schema: Some("public".to_string()),
639            tables: vec![TableInfo {
640                name: "_prax_migrations".to_string(),
641                columns: vec![column("id", NormalizedType::BigInt, false)],
642                primary_key: vec!["id".to_string()],
643                ..Default::default()
644            }],
645            ..Default::default()
646        };
647
648        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
649        assert!(result.schema.get_model("PraxMigrations").is_none());
650        assert!(result.schema.models.is_empty());
651    }
652
653    // -- Introspection round-trip (the single most important property) -------
654    //
655    // A database already at the target schema must diff to *empty* — no
656    // spurious churn. This is exercised purely in-memory (no live DB) by
657    // constructing a DatabaseSchema that mirrors a `.prax`, mapping it to the
658    // diff source, and diffing the parsed `.prax` (target) against it. It
659    // doubles as the foreign-history case: the mapped source is derived from
660    // real structure, never from prax migration history.
661
662    /// A `.prax` whose field names and constraint names line up with what a
663    /// snake_case Postgres database reports, so the round-trip is clean. FK
664    /// constraint name is pinned with `@relation(map:)` to match the DB.
665    const ROUNDTRIP_PRAX: &str = r#"
666        model User {
667            id    BigInt @id
668            email String @unique
669
670            @@map("users")
671        }
672
673        model Post {
674            id        BigInt @id
675            title     String
676            author_id BigInt
677            author    User   @relation(fields: [author_id], references: [id], map: "posts_author_id_fkey")
678
679            @@map("posts")
680        }
681    "#;
682
683    /// The `DatabaseSchema` a Postgres introspection of `ROUNDTRIP_PRAX` would
684    /// produce (snake_case columns, real pkey/fkey constraint names).
685    fn roundtrip_database() -> DatabaseSchema {
686        DatabaseSchema {
687            name: "db".to_string(),
688            schema: Some("public".to_string()),
689            tables: vec![
690                TableInfo {
691                    name: "users".to_string(),
692                    schema: Some("public".to_string()),
693                    columns: vec![
694                        column("id", NormalizedType::BigInt, false),
695                        column("email", NormalizedType::Text, false),
696                    ],
697                    primary_key: vec!["id".to_string()],
698                    unique_constraints: vec![UniqueConstraint {
699                        name: "users_email_key".to_string(),
700                        columns: vec!["email".to_string()],
701                    }],
702                    indexes: vec![IndexInfo {
703                        name: "users_email_key".to_string(),
704                        columns: vec![IndexColumn {
705                            name: "email".to_string(),
706                            ..Default::default()
707                        }],
708                        is_unique: true,
709                        is_primary: false,
710                        index_type: Some("btree".to_string()),
711                        filter: None,
712                    }],
713                    ..Default::default()
714                },
715                TableInfo {
716                    name: "posts".to_string(),
717                    schema: Some("public".to_string()),
718                    columns: vec![
719                        column("id", NormalizedType::BigInt, false),
720                        column("title", NormalizedType::Text, false),
721                        column("author_id", NormalizedType::BigInt, false),
722                    ],
723                    primary_key: vec!["id".to_string()],
724                    foreign_keys: vec![ForeignKeyInfo {
725                        name: "posts_author_id_fkey".to_string(),
726                        columns: vec!["author_id".to_string()],
727                        referenced_table: "users".to_string(),
728                        referenced_schema: None,
729                        referenced_columns: vec!["id".to_string()],
730                        on_delete: ReferentialAction::NoAction,
731                        on_update: ReferentialAction::NoAction,
732                    }],
733                    ..Default::default()
734                },
735            ],
736            ..Default::default()
737        }
738    }
739
740    #[test]
741    fn introspected_source_matching_target_yields_empty_diff() {
742        use prax_migrate::SchemaDiffer;
743
744        let target = prax_schema::parse_schema(ROUNDTRIP_PRAX).unwrap();
745        let source = schema_from_database(&roundtrip_database(), IntrospectionConfig::default())
746            .unwrap()
747            .schema;
748
749        let diff = SchemaDiffer::new(target)
750            .with_source(source)
751            .diff()
752            .unwrap();
753        assert!(
754            diff.is_empty(),
755            "expected no spurious churn, got: {}",
756            diff.summary()
757        );
758    }
759
760    #[test]
761    fn introspected_source_with_defaults_round_trips_empty() {
762        // Columns carrying @default must round-trip to an empty diff: the
763        // introspected default expression, parsed by SchemaBuilder, must
764        // render to the same ANSI default the target .prax produces.
765        use prax_migrate::SchemaDiffer;
766
767        let mut active = column("active", NormalizedType::Boolean, false);
768        active.default = Some("true".to_string());
769        let mut score = column("score", NormalizedType::Int, false);
770        score.default = Some("0".to_string());
771        let mut label = column("label", NormalizedType::Text, false);
772        label.default = Some("'draft'".to_string());
773
774        let db = DatabaseSchema {
775            name: "db".to_string(),
776            schema: Some("public".to_string()),
777            tables: vec![TableInfo {
778                name: "widgets".to_string(),
779                schema: Some("public".to_string()),
780                columns: vec![
781                    column("id", NormalizedType::BigInt, false),
782                    active,
783                    score,
784                    label,
785                ],
786                primary_key: vec!["id".to_string()],
787                ..Default::default()
788            }],
789            ..Default::default()
790        };
791
792        let target = prax_schema::parse_schema(
793            r#"
794            model Widget {
795                id     BigInt  @id
796                active Boolean @default(true)
797                score  Int     @default(0)
798                label  String  @default("draft")
799                @@map("widgets")
800            }
801            "#,
802        )
803        .unwrap();
804
805        let source = schema_from_database(&db, IntrospectionConfig::default())
806            .unwrap()
807            .schema;
808        let diff = SchemaDiffer::new(target)
809            .with_source(source)
810            .diff()
811            .unwrap();
812        assert!(
813            diff.is_empty(),
814            "defaulted columns should round-trip clean, got: {}",
815            diff.summary()
816        );
817    }
818
819    #[test]
820    fn introspected_source_missing_column_yields_only_that_delta() {
821        // Foreign-history case: the DB (mapped source) predates a new column;
822        // diffing the newer .prax against it must yield exactly one added
823        // field and nothing else.
824        use prax_migrate::SchemaDiffer;
825
826        let mut db = roundtrip_database();
827        // Target gains a `bio` column on users that the DB does not have.
828        let target = prax_schema::parse_schema(
829            r#"
830            model User {
831                id    BigInt  @id
832                email String  @unique
833                bio   String?
834
835                @@map("users")
836            }
837
838            model Post {
839                id        BigInt @id
840                title     String
841                author_id BigInt
842                author    User   @relation(fields: [author_id], references: [id], map: "posts_author_id_fkey")
843
844                @@map("posts")
845            }
846            "#,
847        )
848        .unwrap();
849        // Ensure the DB users table lacks `bio`.
850        db.tables[0].columns.retain(|c| c.name != "bio");
851
852        let source = schema_from_database(&db, IntrospectionConfig::default())
853            .unwrap()
854            .schema;
855        let diff = SchemaDiffer::new(target)
856            .with_source(source)
857            .diff()
858            .unwrap();
859
860        assert!(diff.create_models.is_empty(), "no new tables expected");
861        assert_eq!(diff.alter_models.len(), 1, "exactly one altered model");
862        let alter = &diff.alter_models[0];
863        assert_eq!(alter.table_name, "users");
864        assert_eq!(alter.add_fields.len(), 1);
865        assert_eq!(alter.add_fields[0].column_name, "bio");
866        assert!(alter.drop_fields.is_empty());
867    }
868}