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(
67                &table.name,
68                map_indexes(&table.indexes, &table.foreign_keys, &table.name),
69            );
70    }
71
72    builder = builder.with_enums(map_enums(db));
73
74    builder.build().map_err(|e| {
75        crate::error::CliError::Migration(format!(
76            "Failed to build schema from database introspection: {e}"
77        ))
78    })
79}
80
81/// Map every discovered table (base tables only; the query layer's `db pull`
82/// separates views into `DatabaseSchema::views`, so anything in `tables` is a
83/// base table) to the engine's `TableInfo`.
84fn map_tables(db: &DatabaseSchema) -> Vec<MigrateTable> {
85    db.tables
86        .iter()
87        .map(|t| MigrateTable {
88            name: t.name.clone(),
89            schema: t
90                .schema
91                .clone()
92                .or_else(|| db.schema.clone())
93                .unwrap_or_else(|| "public".to_string()),
94            table_type: "BASE TABLE".to_string(),
95            comment: t.comment.clone(),
96        })
97        .collect()
98}
99
100/// Map columns, deriving a canonical `udt_name` from the normalized type so
101/// the engine's `sql_type_to_prax` lands on the same `ScalarType` the target
102/// schema produces.
103fn map_columns(columns: &[ColumnInfo]) -> Vec<MigrateColumn> {
104    columns
105        .iter()
106        .enumerate()
107        .map(|(i, c)| MigrateColumn {
108            name: c.name.clone(),
109            data_type: c.db_type.clone(),
110            udt_name: udt_name_for(&c.normalized_type, &c.db_type),
111            character_maximum_length: c.max_length,
112            numeric_precision: c.precision,
113            is_nullable: c.nullable,
114            column_default: c.default.clone(),
115            ordinal_position: i as i32,
116            comment: c.comment.clone(),
117        })
118        .collect()
119}
120
121/// Derive a PostgreSQL `udt_name`-equivalent for a normalized type.
122///
123/// The engine's `SchemaBuilder::sql_type_to_prax` matches on `udt_name`
124/// first (falling back to `data_type`). Mapping the normalized type to the
125/// canonical short udt string it recognizes keeps type resolution robust
126/// even when `db_type` carries a display form (e.g. "character varying").
127fn udt_name_for(normalized: &NormalizedType, db_type: &str) -> String {
128    match normalized {
129        NormalizedType::Int | NormalizedType::SmallInt => "int4".to_string(),
130        NormalizedType::BigInt => "int8".to_string(),
131        NormalizedType::Float => "float4".to_string(),
132        NormalizedType::Double => "float8".to_string(),
133        NormalizedType::Decimal { .. } => "numeric".to_string(),
134        NormalizedType::String
135        | NormalizedType::Text
136        | NormalizedType::VarChar { .. }
137        | NormalizedType::Char { .. } => "text".to_string(),
138        NormalizedType::Bytes => "bytea".to_string(),
139        NormalizedType::Boolean => "bool".to_string(),
140        NormalizedType::DateTime | NormalizedType::Timestamp => "timestamptz".to_string(),
141        NormalizedType::Date => "date".to_string(),
142        NormalizedType::Time => "time".to_string(),
143        NormalizedType::Json => "jsonb".to_string(),
144        NormalizedType::Uuid => "uuid".to_string(),
145        // Enum reference: the engine matches the udt_name against known enum
146        // names, so the enum type name must be passed through verbatim.
147        NormalizedType::Enum(name) => name.clone(),
148        // Arrays have no first-class Prax scalar; the engine treats the
149        // "ARRAY" data_type as Json. Fall through to db_type so its fallback
150        // path applies.
151        NormalizedType::Array(_) => "ARRAY".to_string(),
152        NormalizedType::Unknown(_) => db_type.to_string(),
153    }
154}
155
156/// Map a table's primary key, foreign keys, and unique constraints to the
157/// engine's flat `ConstraintInfo` list. Single-column primary keys become
158/// `@id`; multi-column primary keys are carried as one PRIMARY KEY constraint
159/// (the engine reads all its columns). Unique constraints and foreign keys are
160/// mapped through so they are not re-proposed by the differ.
161fn map_constraints(table: &TableInfo) -> Vec<ConstraintInfo> {
162    let mut constraints = Vec::new();
163
164    if !table.primary_key.is_empty() {
165        constraints.push(ConstraintInfo {
166            name: format!("{}_pkey", table.name),
167            constraint_type: "PRIMARY KEY".to_string(),
168            table_name: table.name.clone(),
169            columns: table.primary_key.clone(),
170            referenced_table: None,
171            referenced_columns: None,
172            on_delete: None,
173            on_update: None,
174        });
175    }
176
177    for uc in &table.unique_constraints {
178        constraints.push(ConstraintInfo {
179            name: uc.name.clone(),
180            constraint_type: "UNIQUE".to_string(),
181            table_name: table.name.clone(),
182            columns: uc.columns.clone(),
183            referenced_table: None,
184            referenced_columns: None,
185            on_delete: None,
186            on_update: None,
187        });
188    }
189
190    for fk in &table.foreign_keys {
191        constraints.push(map_foreign_key(fk, &table.name));
192    }
193
194    constraints
195}
196
197/// Map a foreign key, translating referential actions to the SQL keyword
198/// form the engine expects (`NoAction` collapses to `None` — the SQL default
199/// — so it is not rendered redundantly).
200fn map_foreign_key(fk: &ForeignKeyInfo, table_name: &str) -> ConstraintInfo {
201    ConstraintInfo {
202        name: fk.name.clone(),
203        constraint_type: "FOREIGN KEY".to_string(),
204        table_name: table_name.to_string(),
205        columns: fk.columns.clone(),
206        referenced_table: Some(fk.referenced_table.clone()),
207        referenced_columns: Some(fk.referenced_columns.clone()),
208        on_delete: referential_action_sql(fk.on_delete),
209        on_update: referential_action_sql(fk.on_update),
210    }
211}
212
213/// Render a referential action as the SQL keyword the engine stores, or
214/// `None` for the default `NO ACTION` (which needs no clause).
215fn referential_action_sql(action: ReferentialAction) -> Option<String> {
216    match action {
217        ReferentialAction::NoAction => None,
218        ReferentialAction::Restrict => Some("RESTRICT".to_string()),
219        ReferentialAction::Cascade => Some("CASCADE".to_string()),
220        ReferentialAction::SetNull => Some("SET NULL".to_string()),
221        ReferentialAction::SetDefault => Some("SET DEFAULT".to_string()),
222    }
223}
224
225/// Map indexes, flattening the query layer's `IndexColumn` (which carries sort
226/// order/nulls position) to the engine's plain column-name list.
227///
228/// Non-unique indexes that merely back a foreign key are dropped: several
229/// engines (notably MySQL) auto-create an index for every FK, but the schema
230/// DSL models the relation, not its implicit backing index — emitting it as
231/// `@@index` would make an introspected schema diff dirty against a `.prax`
232/// that only declares the relation.
233fn map_indexes(
234    indexes: &[IndexInfo],
235    foreign_keys: &[ForeignKeyInfo],
236    table_name: &str,
237) -> Vec<MigrateIndex> {
238    indexes
239        .iter()
240        .filter(|idx| {
241            if idx.is_unique || idx.is_primary {
242                return true;
243            }
244            let cols: Vec<&str> = idx.columns.iter().map(|c| c.name.as_str()).collect();
245            // Drop if some FK's column list is exactly this index's columns.
246            !foreign_keys.iter().any(|fk| {
247                fk.columns.len() == cols.len()
248                    && fk
249                        .columns
250                        .iter()
251                        .map(String::as_str)
252                        .eq(cols.iter().copied())
253            })
254        })
255        .map(|idx| MigrateIndex {
256            name: idx.name.clone(),
257            table_name: table_name.to_string(),
258            columns: idx.columns.iter().map(|c| c.name.clone()).collect(),
259            is_unique: idx.is_unique,
260            is_primary: idx.is_primary,
261            index_method: idx
262                .index_type
263                .clone()
264                .unwrap_or_else(|| "btree".to_string()),
265        })
266        .collect()
267}
268
269/// Map enum types, carrying the schema-qualified name through.
270fn map_enums(db: &DatabaseSchema) -> Vec<MigrateEnum> {
271    db.enums
272        .iter()
273        .map(|e| MigrateEnum {
274            name: e.name.clone(),
275            values: e.values.clone(),
276            schema: e
277                .schema
278                .clone()
279                .or_else(|| db.schema.clone())
280                .unwrap_or_else(|| "public".to_string()),
281        })
282        .collect()
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use prax_query::introspection::{EnumInfo, IndexColumn, UniqueConstraint};
289    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
290
291    fn column(name: &str, normalized: NormalizedType, nullable: bool) -> ColumnInfo {
292        ColumnInfo {
293            name: name.to_string(),
294            db_type: "".to_string(),
295            normalized_type: normalized,
296            nullable,
297            ..Default::default()
298        }
299    }
300
301    #[test]
302    fn udt_name_maps_normalized_types_to_recognized_short_names() {
303        assert_eq!(udt_name_for(&NormalizedType::Int, ""), "int4");
304        assert_eq!(udt_name_for(&NormalizedType::BigInt, ""), "int8");
305        assert_eq!(udt_name_for(&NormalizedType::Boolean, ""), "bool");
306        assert_eq!(udt_name_for(&NormalizedType::DateTime, ""), "timestamptz");
307        assert_eq!(udt_name_for(&NormalizedType::Uuid, ""), "uuid");
308        assert_eq!(udt_name_for(&NormalizedType::Json, ""), "jsonb");
309        assert_eq!(
310            udt_name_for(&NormalizedType::VarChar { length: Some(255) }, ""),
311            "text"
312        );
313        assert_eq!(
314            udt_name_for(&NormalizedType::Enum("Role".to_string()), ""),
315            "Role"
316        );
317        // Unknown falls back to the raw db_type so the engine's data_type path applies.
318        assert_eq!(
319            udt_name_for(
320                &NormalizedType::Unknown("geography".to_string()),
321                "geography"
322            ),
323            "geography"
324        );
325    }
326
327    #[test]
328    fn referential_actions_map_to_sql_keywords() {
329        assert_eq!(referential_action_sql(ReferentialAction::NoAction), None);
330        assert_eq!(
331            referential_action_sql(ReferentialAction::Cascade),
332            Some("CASCADE".to_string())
333        );
334        assert_eq!(
335            referential_action_sql(ReferentialAction::SetNull),
336            Some("SET NULL".to_string())
337        );
338    }
339
340    #[test]
341    fn maps_a_simple_table_to_a_model_with_columns_and_pk() {
342        let db = DatabaseSchema {
343            name: "db".to_string(),
344            schema: Some("public".to_string()),
345            tables: vec![TableInfo {
346                name: "users".to_string(),
347                schema: Some("public".to_string()),
348                columns: vec![
349                    column("id", NormalizedType::BigInt, false),
350                    column("email", NormalizedType::Text, false),
351                    column("name", NormalizedType::Text, true),
352                ],
353                primary_key: vec!["id".to_string()],
354                ..Default::default()
355            }],
356            ..Default::default()
357        };
358
359        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
360        let model = result.schema.get_model("Users").expect("Users model");
361
362        let id = model.get_field("id").expect("id field");
363        assert!(id.has_attribute("id"));
364        assert!(matches!(
365            &id.field_type,
366            FieldType::Scalar(ScalarType::BigInt)
367        ));
368
369        let email = model.get_field("email").expect("email field");
370        assert_eq!(email.modifier, TypeModifier::Required);
371        assert!(matches!(
372            &email.field_type,
373            FieldType::Scalar(ScalarType::String)
374        ));
375
376        let name = model.get_field("name").expect("name field");
377        assert_eq!(name.modifier, TypeModifier::Optional);
378    }
379
380    #[test]
381    fn maps_foreign_keys_to_relation_fields() {
382        let db = DatabaseSchema {
383            name: "db".to_string(),
384            schema: Some("public".to_string()),
385            tables: vec![
386                TableInfo {
387                    name: "users".to_string(),
388                    columns: vec![column("id", NormalizedType::BigInt, false)],
389                    primary_key: vec!["id".to_string()],
390                    ..Default::default()
391                },
392                TableInfo {
393                    name: "posts".to_string(),
394                    columns: vec![
395                        column("id", NormalizedType::BigInt, false),
396                        column("author_id", NormalizedType::BigInt, false),
397                    ],
398                    primary_key: vec!["id".to_string()],
399                    foreign_keys: vec![ForeignKeyInfo {
400                        name: "posts_author_id_fkey".to_string(),
401                        columns: vec!["author_id".to_string()],
402                        referenced_table: "users".to_string(),
403                        referenced_schema: None,
404                        referenced_columns: vec!["id".to_string()],
405                        on_delete: ReferentialAction::Cascade,
406                        on_update: ReferentialAction::NoAction,
407                    }],
408                    ..Default::default()
409                },
410            ],
411            ..Default::default()
412        };
413
414        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
415        let posts = result.schema.get_model("Posts").expect("Posts model");
416        let author = posts.get_field("author").expect("relation field");
417        let rel = author
418            .extract_attributes()
419            .relation
420            .expect("@relation present");
421        assert_eq!(rel.fields, ["author_id"]);
422        assert_eq!(rel.references, ["id"]);
423    }
424
425    #[test]
426    fn maps_enums_and_enum_typed_columns() {
427        let db = DatabaseSchema {
428            name: "db".to_string(),
429            schema: Some("public".to_string()),
430            tables: vec![TableInfo {
431                name: "users".to_string(),
432                columns: vec![
433                    column("id", NormalizedType::BigInt, false),
434                    column("role", NormalizedType::Enum("role".to_string()), false),
435                ],
436                primary_key: vec!["id".to_string()],
437                ..Default::default()
438            }],
439            enums: vec![EnumInfo {
440                name: "role".to_string(),
441                schema: Some("public".to_string()),
442                values: vec!["ADMIN".to_string(), "USER".to_string()],
443            }],
444            ..Default::default()
445        };
446
447        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
448        assert!(result.schema.get_enum("Role").is_some());
449        let users = result.schema.get_model("Users").expect("Users model");
450        let role = users.get_field("role").expect("role field");
451        assert!(matches!(&role.field_type, FieldType::Enum(_)));
452    }
453
454    #[test]
455    fn maps_multi_column_unique_index() {
456        let db = DatabaseSchema {
457            name: "db".to_string(),
458            schema: Some("public".to_string()),
459            tables: vec![TableInfo {
460                name: "memberships".to_string(),
461                columns: vec![
462                    column("team_id", NormalizedType::BigInt, false),
463                    column("user_id", NormalizedType::BigInt, false),
464                ],
465                primary_key: vec!["team_id".to_string(), "user_id".to_string()],
466                indexes: vec![IndexInfo {
467                    name: "uq_membership".to_string(),
468                    columns: vec![
469                        IndexColumn {
470                            name: "team_id".to_string(),
471                            ..Default::default()
472                        },
473                        IndexColumn {
474                            name: "user_id".to_string(),
475                            ..Default::default()
476                        },
477                    ],
478                    is_unique: true,
479                    is_primary: false,
480                    index_type: Some("btree".to_string()),
481                    filter: None,
482                }],
483                ..Default::default()
484            }],
485            ..Default::default()
486        };
487
488        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
489        let model = result
490            .schema
491            .get_model("Memberships")
492            .expect("Memberships model");
493        // Composite PK -> both id fields carry @id.
494        assert!(model.get_field("team_id").unwrap().has_attribute("id"));
495        assert!(model.get_field("user_id").unwrap().has_attribute("id"));
496        // Multi-column unique index -> @@unique.
497        assert!(model.get_attribute("unique").is_some());
498    }
499
500    #[test]
501    fn excluded_tables_are_skipped() {
502        let db = DatabaseSchema {
503            name: "db".to_string(),
504            schema: Some("public".to_string()),
505            tables: vec![TableInfo {
506                name: "_prax_migrations".to_string(),
507                columns: vec![column("id", NormalizedType::BigInt, false)],
508                primary_key: vec!["id".to_string()],
509                ..Default::default()
510            }],
511            ..Default::default()
512        };
513
514        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
515        assert!(result.schema.get_model("PraxMigrations").is_none());
516        assert!(result.schema.models.is_empty());
517    }
518
519    // -- Introspection round-trip (the single most important property) -------
520    //
521    // A database already at the target schema must diff to *empty* — no
522    // spurious churn. This is exercised purely in-memory (no live DB) by
523    // constructing a DatabaseSchema that mirrors a `.prax`, mapping it to the
524    // diff source, and diffing the parsed `.prax` (target) against it. It
525    // doubles as the foreign-history case: the mapped source is derived from
526    // real structure, never from prax migration history.
527
528    /// A `.prax` whose field names and constraint names line up with what a
529    /// snake_case Postgres database reports, so the round-trip is clean. FK
530    /// constraint name is pinned with `@relation(map:)` to match the DB.
531    const ROUNDTRIP_PRAX: &str = r#"
532        model User {
533            id    BigInt @id
534            email String @unique
535
536            @@map("users")
537        }
538
539        model Post {
540            id        BigInt @id
541            title     String
542            author_id BigInt
543            author    User   @relation(fields: [author_id], references: [id], map: "posts_author_id_fkey")
544
545            @@map("posts")
546        }
547    "#;
548
549    /// The `DatabaseSchema` a Postgres introspection of `ROUNDTRIP_PRAX` would
550    /// produce (snake_case columns, real pkey/fkey constraint names).
551    fn roundtrip_database() -> DatabaseSchema {
552        DatabaseSchema {
553            name: "db".to_string(),
554            schema: Some("public".to_string()),
555            tables: vec![
556                TableInfo {
557                    name: "users".to_string(),
558                    schema: Some("public".to_string()),
559                    columns: vec![
560                        column("id", NormalizedType::BigInt, false),
561                        column("email", NormalizedType::Text, false),
562                    ],
563                    primary_key: vec!["id".to_string()],
564                    unique_constraints: vec![UniqueConstraint {
565                        name: "users_email_key".to_string(),
566                        columns: vec!["email".to_string()],
567                    }],
568                    indexes: vec![IndexInfo {
569                        name: "users_email_key".to_string(),
570                        columns: vec![IndexColumn {
571                            name: "email".to_string(),
572                            ..Default::default()
573                        }],
574                        is_unique: true,
575                        is_primary: false,
576                        index_type: Some("btree".to_string()),
577                        filter: None,
578                    }],
579                    ..Default::default()
580                },
581                TableInfo {
582                    name: "posts".to_string(),
583                    schema: Some("public".to_string()),
584                    columns: vec![
585                        column("id", NormalizedType::BigInt, false),
586                        column("title", NormalizedType::Text, false),
587                        column("author_id", NormalizedType::BigInt, false),
588                    ],
589                    primary_key: vec!["id".to_string()],
590                    foreign_keys: vec![ForeignKeyInfo {
591                        name: "posts_author_id_fkey".to_string(),
592                        columns: vec!["author_id".to_string()],
593                        referenced_table: "users".to_string(),
594                        referenced_schema: None,
595                        referenced_columns: vec!["id".to_string()],
596                        on_delete: ReferentialAction::NoAction,
597                        on_update: ReferentialAction::NoAction,
598                    }],
599                    ..Default::default()
600                },
601            ],
602            ..Default::default()
603        }
604    }
605
606    #[test]
607    fn introspected_source_matching_target_yields_empty_diff() {
608        use prax_migrate::SchemaDiffer;
609
610        let target = prax_schema::parse_schema(ROUNDTRIP_PRAX).unwrap();
611        let source = schema_from_database(&roundtrip_database(), IntrospectionConfig::default())
612            .unwrap()
613            .schema;
614
615        let diff = SchemaDiffer::new(target)
616            .with_source(source)
617            .diff()
618            .unwrap();
619        assert!(
620            diff.is_empty(),
621            "expected no spurious churn, got: {}",
622            diff.summary()
623        );
624    }
625
626    #[test]
627    fn introspected_source_with_defaults_round_trips_empty() {
628        // Columns carrying @default must round-trip to an empty diff: the
629        // introspected default expression, parsed by SchemaBuilder, must
630        // render to the same ANSI default the target .prax produces.
631        use prax_migrate::SchemaDiffer;
632
633        let mut active = column("active", NormalizedType::Boolean, false);
634        active.default = Some("true".to_string());
635        let mut score = column("score", NormalizedType::Int, false);
636        score.default = Some("0".to_string());
637        let mut label = column("label", NormalizedType::Text, false);
638        label.default = Some("'draft'".to_string());
639
640        let db = DatabaseSchema {
641            name: "db".to_string(),
642            schema: Some("public".to_string()),
643            tables: vec![TableInfo {
644                name: "widgets".to_string(),
645                schema: Some("public".to_string()),
646                columns: vec![
647                    column("id", NormalizedType::BigInt, false),
648                    active,
649                    score,
650                    label,
651                ],
652                primary_key: vec!["id".to_string()],
653                ..Default::default()
654            }],
655            ..Default::default()
656        };
657
658        let target = prax_schema::parse_schema(
659            r#"
660            model Widget {
661                id     BigInt  @id
662                active Boolean @default(true)
663                score  Int     @default(0)
664                label  String  @default("draft")
665                @@map("widgets")
666            }
667            "#,
668        )
669        .unwrap();
670
671        let source = schema_from_database(&db, IntrospectionConfig::default())
672            .unwrap()
673            .schema;
674        let diff = SchemaDiffer::new(target)
675            .with_source(source)
676            .diff()
677            .unwrap();
678        assert!(
679            diff.is_empty(),
680            "defaulted columns should round-trip clean, got: {}",
681            diff.summary()
682        );
683    }
684
685    #[test]
686    fn introspected_source_missing_column_yields_only_that_delta() {
687        // Foreign-history case: the DB (mapped source) predates a new column;
688        // diffing the newer .prax against it must yield exactly one added
689        // field and nothing else.
690        use prax_migrate::SchemaDiffer;
691
692        let mut db = roundtrip_database();
693        // Target gains a `bio` column on users that the DB does not have.
694        let target = prax_schema::parse_schema(
695            r#"
696            model User {
697                id    BigInt  @id
698                email String  @unique
699                bio   String?
700
701                @@map("users")
702            }
703
704            model Post {
705                id        BigInt @id
706                title     String
707                author_id BigInt
708                author    User   @relation(fields: [author_id], references: [id], map: "posts_author_id_fkey")
709
710                @@map("posts")
711            }
712            "#,
713        )
714        .unwrap();
715        // Ensure the DB users table lacks `bio`.
716        db.tables[0].columns.retain(|c| c.name != "bio");
717
718        let source = schema_from_database(&db, IntrospectionConfig::default())
719            .unwrap()
720            .schema;
721        let diff = SchemaDiffer::new(target)
722            .with_source(source)
723            .diff()
724            .unwrap();
725
726        assert!(diff.create_models.is_empty(), "no new tables expected");
727        assert_eq!(diff.alter_models.len(), 1, "exactly one altered model");
728        let alter = &diff.alter_models[0];
729        assert_eq!(alter.table_name, "users");
730        assert_eq!(alter.add_fields.len(), 1);
731        assert_eq!(alter.add_fields[0].column_name, "bio");
732        assert!(alter.drop_fields.is_empty());
733    }
734}