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        // An unrecognized type carries the *udt name* the introspector read
153        // (e.g. a Postgres enum type `global_role`, which reports
154        // `data_type = "USER-DEFINED"` but a real `udt_name`). Pass the udt
155        // name — NOT `db_type` — so the engine can resolve it against the
156        // introspected enum types. Using `db_type` here would hand the engine
157        // the literal `"USER-DEFINED"`, which resolves to nothing and made it
158        // skip every enum-bearing table (appearing as spurious new tables in
159        // the diff).
160        NormalizedType::Unknown(udt) => udt.clone(),
161    }
162}
163
164/// Map a table's primary key, foreign keys, and unique constraints to the
165/// engine's flat `ConstraintInfo` list. Single-column primary keys become
166/// `@id`; multi-column primary keys are carried as one PRIMARY KEY constraint
167/// (the engine reads all its columns). Unique constraints and foreign keys are
168/// mapped through so they are not re-proposed by the differ.
169fn map_constraints(table: &TableInfo) -> Vec<ConstraintInfo> {
170    let mut constraints = Vec::new();
171
172    if !table.primary_key.is_empty() {
173        constraints.push(ConstraintInfo {
174            name: format!("{}_pkey", table.name),
175            constraint_type: "PRIMARY KEY".to_string(),
176            table_name: table.name.clone(),
177            columns: table.primary_key.clone(),
178            referenced_table: None,
179            referenced_columns: None,
180            on_delete: None,
181            on_update: None,
182        });
183    }
184
185    for uc in &table.unique_constraints {
186        constraints.push(ConstraintInfo {
187            name: uc.name.clone(),
188            constraint_type: "UNIQUE".to_string(),
189            table_name: table.name.clone(),
190            columns: uc.columns.clone(),
191            referenced_table: None,
192            referenced_columns: None,
193            on_delete: None,
194            on_update: None,
195        });
196    }
197
198    for fk in &table.foreign_keys {
199        constraints.push(map_foreign_key(fk, &table.name));
200    }
201
202    constraints
203}
204
205/// Map a foreign key, translating referential actions to the SQL keyword
206/// form the engine expects (`NoAction` collapses to `None` — the SQL default
207/// — so it is not rendered redundantly).
208fn map_foreign_key(fk: &ForeignKeyInfo, table_name: &str) -> ConstraintInfo {
209    ConstraintInfo {
210        name: fk.name.clone(),
211        constraint_type: "FOREIGN KEY".to_string(),
212        table_name: table_name.to_string(),
213        columns: fk.columns.clone(),
214        referenced_table: Some(fk.referenced_table.clone()),
215        referenced_columns: Some(fk.referenced_columns.clone()),
216        on_delete: referential_action_sql(fk.on_delete),
217        on_update: referential_action_sql(fk.on_update),
218    }
219}
220
221/// Render a referential action as the SQL keyword the engine stores, or
222/// `None` for the default `NO ACTION` (which needs no clause).
223fn referential_action_sql(action: ReferentialAction) -> Option<String> {
224    match action {
225        ReferentialAction::NoAction => None,
226        ReferentialAction::Restrict => Some("RESTRICT".to_string()),
227        ReferentialAction::Cascade => Some("CASCADE".to_string()),
228        ReferentialAction::SetNull => Some("SET NULL".to_string()),
229        ReferentialAction::SetDefault => Some("SET DEFAULT".to_string()),
230    }
231}
232
233/// Map indexes, flattening the query layer's `IndexColumn` (which carries sort
234/// order/nulls position) to the engine's plain column-name list.
235///
236/// Non-unique indexes that merely back a foreign key are dropped: several
237/// engines (notably MySQL) auto-create an index for every FK, but the schema
238/// DSL models the relation, not its implicit backing index — emitting it as
239/// `@@index` would make an introspected schema diff dirty against a `.prax`
240/// that only declares the relation.
241fn map_indexes(
242    indexes: &[IndexInfo],
243    _foreign_keys: &[ForeignKeyInfo],
244    table_name: &str,
245) -> Vec<MigrateIndex> {
246    // Every non-primary index is carried into the diff source verbatim.
247    //
248    // A previous version dropped non-unique indexes whose columns matched a
249    // foreign key, on the theory that an FK implies an index. PostgreSQL does
250    // NOT auto-create an index for a foreign key (only the *referenced* side's
251    // PK/unique is indexed), so those `<table>_<col>_idx` indexes are real,
252    // intentional objects. Dropping them left the source without them, so a
253    // schema `@@index` on an FK column looked new and churned an index that
254    // already existed. Keep them.
255    indexes
256        .iter()
257        .map(|idx| MigrateIndex {
258            name: idx.name.clone(),
259            table_name: table_name.to_string(),
260            columns: idx.columns.iter().map(|c| c.name.clone()).collect(),
261            is_unique: idx.is_unique,
262            is_primary: idx.is_primary,
263            index_method: idx
264                .index_type
265                .clone()
266                .unwrap_or_else(|| "btree".to_string()),
267        })
268        .collect()
269}
270
271/// Map enum types, carrying the schema-qualified name through.
272fn map_enums(db: &DatabaseSchema) -> Vec<MigrateEnum> {
273    db.enums
274        .iter()
275        .map(|e| MigrateEnum {
276            name: e.name.clone(),
277            values: e.values.clone(),
278            schema: e
279                .schema
280                .clone()
281                .or_else(|| db.schema.clone())
282                .unwrap_or_else(|| "public".to_string()),
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    #[test]
304    fn udt_name_maps_normalized_types_to_recognized_short_names() {
305        assert_eq!(udt_name_for(&NormalizedType::Int, ""), "int4");
306        assert_eq!(udt_name_for(&NormalizedType::BigInt, ""), "int8");
307        assert_eq!(udt_name_for(&NormalizedType::Boolean, ""), "bool");
308        assert_eq!(udt_name_for(&NormalizedType::DateTime, ""), "timestamptz");
309        assert_eq!(udt_name_for(&NormalizedType::Uuid, ""), "uuid");
310        assert_eq!(udt_name_for(&NormalizedType::Json, ""), "jsonb");
311        assert_eq!(
312            udt_name_for(&NormalizedType::VarChar { length: Some(255) }, ""),
313            "text"
314        );
315        assert_eq!(
316            udt_name_for(&NormalizedType::Enum("Role".to_string()), ""),
317            "Role"
318        );
319        // Unknown falls back to the raw db_type so the engine's data_type path applies.
320        assert_eq!(
321            udt_name_for(
322                &NormalizedType::Unknown("geography".to_string()),
323                "geography"
324            ),
325            "geography"
326        );
327    }
328
329    #[test]
330    fn referential_actions_map_to_sql_keywords() {
331        assert_eq!(referential_action_sql(ReferentialAction::NoAction), None);
332        assert_eq!(
333            referential_action_sql(ReferentialAction::Cascade),
334            Some("CASCADE".to_string())
335        );
336        assert_eq!(
337            referential_action_sql(ReferentialAction::SetNull),
338            Some("SET NULL".to_string())
339        );
340    }
341
342    #[test]
343    fn maps_a_simple_table_to_a_model_with_columns_and_pk() {
344        let db = DatabaseSchema {
345            name: "db".to_string(),
346            schema: Some("public".to_string()),
347            tables: vec![TableInfo {
348                name: "users".to_string(),
349                schema: Some("public".to_string()),
350                columns: vec![
351                    column("id", NormalizedType::BigInt, false),
352                    column("email", NormalizedType::Text, false),
353                    column("name", NormalizedType::Text, true),
354                ],
355                primary_key: vec!["id".to_string()],
356                ..Default::default()
357            }],
358            ..Default::default()
359        };
360
361        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
362        let model = result.schema.get_model("Users").expect("Users model");
363
364        let id = model.get_field("id").expect("id field");
365        assert!(id.has_attribute("id"));
366        assert!(matches!(
367            &id.field_type,
368            FieldType::Scalar(ScalarType::BigInt)
369        ));
370
371        let email = model.get_field("email").expect("email field");
372        assert_eq!(email.modifier, TypeModifier::Required);
373        assert!(matches!(
374            &email.field_type,
375            FieldType::Scalar(ScalarType::String)
376        ));
377
378        let name = model.get_field("name").expect("name field");
379        assert_eq!(name.modifier, TypeModifier::Optional);
380    }
381
382    #[test]
383    fn maps_foreign_keys_to_relation_fields() {
384        let db = DatabaseSchema {
385            name: "db".to_string(),
386            schema: Some("public".to_string()),
387            tables: vec![
388                TableInfo {
389                    name: "users".to_string(),
390                    columns: vec![column("id", NormalizedType::BigInt, false)],
391                    primary_key: vec!["id".to_string()],
392                    ..Default::default()
393                },
394                TableInfo {
395                    name: "posts".to_string(),
396                    columns: vec![
397                        column("id", NormalizedType::BigInt, false),
398                        column("author_id", NormalizedType::BigInt, false),
399                    ],
400                    primary_key: vec!["id".to_string()],
401                    foreign_keys: vec![ForeignKeyInfo {
402                        name: "posts_author_id_fkey".to_string(),
403                        columns: vec!["author_id".to_string()],
404                        referenced_table: "users".to_string(),
405                        referenced_schema: None,
406                        referenced_columns: vec!["id".to_string()],
407                        on_delete: ReferentialAction::Cascade,
408                        on_update: ReferentialAction::NoAction,
409                    }],
410                    ..Default::default()
411                },
412            ],
413            ..Default::default()
414        };
415
416        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
417        let posts = result.schema.get_model("Posts").expect("Posts model");
418        let author = posts.get_field("author").expect("relation field");
419        let rel = author
420            .extract_attributes()
421            .relation
422            .expect("@relation present");
423        assert_eq!(rel.fields, ["author_id"]);
424        assert_eq!(rel.references, ["id"]);
425    }
426
427    #[test]
428    fn maps_enums_and_enum_typed_columns() {
429        let db = DatabaseSchema {
430            name: "db".to_string(),
431            schema: Some("public".to_string()),
432            tables: vec![TableInfo {
433                name: "users".to_string(),
434                columns: vec![
435                    column("id", NormalizedType::BigInt, false),
436                    column("role", NormalizedType::Enum("role".to_string()), false),
437                ],
438                primary_key: vec!["id".to_string()],
439                ..Default::default()
440            }],
441            enums: vec![EnumInfo {
442                name: "role".to_string(),
443                schema: Some("public".to_string()),
444                values: vec!["ADMIN".to_string(), "USER".to_string()],
445            }],
446            ..Default::default()
447        };
448
449        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
450        assert!(result.schema.get_enum("Role").is_some());
451        let users = result.schema.get_model("Users").expect("Users model");
452        let role = users.get_field("role").expect("role field");
453        assert!(matches!(&role.field_type, FieldType::Enum(_)));
454    }
455
456    #[test]
457    fn maps_multi_column_unique_index() {
458        let db = DatabaseSchema {
459            name: "db".to_string(),
460            schema: Some("public".to_string()),
461            tables: vec![TableInfo {
462                name: "memberships".to_string(),
463                columns: vec![
464                    column("team_id", NormalizedType::BigInt, false),
465                    column("user_id", NormalizedType::BigInt, false),
466                ],
467                primary_key: vec!["team_id".to_string(), "user_id".to_string()],
468                indexes: vec![IndexInfo {
469                    name: "uq_membership".to_string(),
470                    columns: vec![
471                        IndexColumn {
472                            name: "team_id".to_string(),
473                            ..Default::default()
474                        },
475                        IndexColumn {
476                            name: "user_id".to_string(),
477                            ..Default::default()
478                        },
479                    ],
480                    is_unique: true,
481                    is_primary: false,
482                    index_type: Some("btree".to_string()),
483                    filter: None,
484                }],
485                ..Default::default()
486            }],
487            ..Default::default()
488        };
489
490        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
491        let model = result
492            .schema
493            .get_model("Memberships")
494            .expect("Memberships model");
495        // Composite PK -> both id fields carry @id.
496        assert!(model.get_field("team_id").unwrap().has_attribute("id"));
497        assert!(model.get_field("user_id").unwrap().has_attribute("id"));
498        // Multi-column unique index -> @@unique.
499        assert!(model.get_attribute("unique").is_some());
500    }
501
502    #[test]
503    fn excluded_tables_are_skipped() {
504        let db = DatabaseSchema {
505            name: "db".to_string(),
506            schema: Some("public".to_string()),
507            tables: vec![TableInfo {
508                name: "_prax_migrations".to_string(),
509                columns: vec![column("id", NormalizedType::BigInt, false)],
510                primary_key: vec!["id".to_string()],
511                ..Default::default()
512            }],
513            ..Default::default()
514        };
515
516        let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
517        assert!(result.schema.get_model("PraxMigrations").is_none());
518        assert!(result.schema.models.is_empty());
519    }
520
521    // -- Introspection round-trip (the single most important property) -------
522    //
523    // A database already at the target schema must diff to *empty* — no
524    // spurious churn. This is exercised purely in-memory (no live DB) by
525    // constructing a DatabaseSchema that mirrors a `.prax`, mapping it to the
526    // diff source, and diffing the parsed `.prax` (target) against it. It
527    // doubles as the foreign-history case: the mapped source is derived from
528    // real structure, never from prax migration history.
529
530    /// A `.prax` whose field names and constraint names line up with what a
531    /// snake_case Postgres database reports, so the round-trip is clean. FK
532    /// constraint name is pinned with `@relation(map:)` to match the DB.
533    const ROUNDTRIP_PRAX: &str = r#"
534        model User {
535            id    BigInt @id
536            email String @unique
537
538            @@map("users")
539        }
540
541        model Post {
542            id        BigInt @id
543            title     String
544            author_id BigInt
545            author    User   @relation(fields: [author_id], references: [id], map: "posts_author_id_fkey")
546
547            @@map("posts")
548        }
549    "#;
550
551    /// The `DatabaseSchema` a Postgres introspection of `ROUNDTRIP_PRAX` would
552    /// produce (snake_case columns, real pkey/fkey constraint names).
553    fn roundtrip_database() -> DatabaseSchema {
554        DatabaseSchema {
555            name: "db".to_string(),
556            schema: Some("public".to_string()),
557            tables: vec![
558                TableInfo {
559                    name: "users".to_string(),
560                    schema: Some("public".to_string()),
561                    columns: vec![
562                        column("id", NormalizedType::BigInt, false),
563                        column("email", NormalizedType::Text, false),
564                    ],
565                    primary_key: vec!["id".to_string()],
566                    unique_constraints: vec![UniqueConstraint {
567                        name: "users_email_key".to_string(),
568                        columns: vec!["email".to_string()],
569                    }],
570                    indexes: vec![IndexInfo {
571                        name: "users_email_key".to_string(),
572                        columns: vec![IndexColumn {
573                            name: "email".to_string(),
574                            ..Default::default()
575                        }],
576                        is_unique: true,
577                        is_primary: false,
578                        index_type: Some("btree".to_string()),
579                        filter: None,
580                    }],
581                    ..Default::default()
582                },
583                TableInfo {
584                    name: "posts".to_string(),
585                    schema: Some("public".to_string()),
586                    columns: vec![
587                        column("id", NormalizedType::BigInt, false),
588                        column("title", NormalizedType::Text, false),
589                        column("author_id", NormalizedType::BigInt, false),
590                    ],
591                    primary_key: vec!["id".to_string()],
592                    foreign_keys: vec![ForeignKeyInfo {
593                        name: "posts_author_id_fkey".to_string(),
594                        columns: vec!["author_id".to_string()],
595                        referenced_table: "users".to_string(),
596                        referenced_schema: None,
597                        referenced_columns: vec!["id".to_string()],
598                        on_delete: ReferentialAction::NoAction,
599                        on_update: ReferentialAction::NoAction,
600                    }],
601                    ..Default::default()
602                },
603            ],
604            ..Default::default()
605        }
606    }
607
608    #[test]
609    fn introspected_source_matching_target_yields_empty_diff() {
610        use prax_migrate::SchemaDiffer;
611
612        let target = prax_schema::parse_schema(ROUNDTRIP_PRAX).unwrap();
613        let source = schema_from_database(&roundtrip_database(), IntrospectionConfig::default())
614            .unwrap()
615            .schema;
616
617        let diff = SchemaDiffer::new(target)
618            .with_source(source)
619            .diff()
620            .unwrap();
621        assert!(
622            diff.is_empty(),
623            "expected no spurious churn, got: {}",
624            diff.summary()
625        );
626    }
627
628    #[test]
629    fn introspected_source_with_defaults_round_trips_empty() {
630        // Columns carrying @default must round-trip to an empty diff: the
631        // introspected default expression, parsed by SchemaBuilder, must
632        // render to the same ANSI default the target .prax produces.
633        use prax_migrate::SchemaDiffer;
634
635        let mut active = column("active", NormalizedType::Boolean, false);
636        active.default = Some("true".to_string());
637        let mut score = column("score", NormalizedType::Int, false);
638        score.default = Some("0".to_string());
639        let mut label = column("label", NormalizedType::Text, false);
640        label.default = Some("'draft'".to_string());
641
642        let db = DatabaseSchema {
643            name: "db".to_string(),
644            schema: Some("public".to_string()),
645            tables: vec![TableInfo {
646                name: "widgets".to_string(),
647                schema: Some("public".to_string()),
648                columns: vec![
649                    column("id", NormalizedType::BigInt, false),
650                    active,
651                    score,
652                    label,
653                ],
654                primary_key: vec!["id".to_string()],
655                ..Default::default()
656            }],
657            ..Default::default()
658        };
659
660        let target = prax_schema::parse_schema(
661            r#"
662            model Widget {
663                id     BigInt  @id
664                active Boolean @default(true)
665                score  Int     @default(0)
666                label  String  @default("draft")
667                @@map("widgets")
668            }
669            "#,
670        )
671        .unwrap();
672
673        let source = schema_from_database(&db, IntrospectionConfig::default())
674            .unwrap()
675            .schema;
676        let diff = SchemaDiffer::new(target)
677            .with_source(source)
678            .diff()
679            .unwrap();
680        assert!(
681            diff.is_empty(),
682            "defaulted columns should round-trip clean, got: {}",
683            diff.summary()
684        );
685    }
686
687    #[test]
688    fn introspected_source_missing_column_yields_only_that_delta() {
689        // Foreign-history case: the DB (mapped source) predates a new column;
690        // diffing the newer .prax against it must yield exactly one added
691        // field and nothing else.
692        use prax_migrate::SchemaDiffer;
693
694        let mut db = roundtrip_database();
695        // Target gains a `bio` column on users that the DB does not have.
696        let target = prax_schema::parse_schema(
697            r#"
698            model User {
699                id    BigInt  @id
700                email String  @unique
701                bio   String?
702
703                @@map("users")
704            }
705
706            model Post {
707                id        BigInt @id
708                title     String
709                author_id BigInt
710                author    User   @relation(fields: [author_id], references: [id], map: "posts_author_id_fkey")
711
712                @@map("posts")
713            }
714            "#,
715        )
716        .unwrap();
717        // Ensure the DB users table lacks `bio`.
718        db.tables[0].columns.retain(|c| c.name != "bio");
719
720        let source = schema_from_database(&db, IntrospectionConfig::default())
721            .unwrap()
722            .schema;
723        let diff = SchemaDiffer::new(target)
724            .with_source(source)
725            .diff()
726            .unwrap();
727
728        assert!(diff.create_models.is_empty(), "no new tables expected");
729        assert_eq!(diff.alter_models.len(), 1, "exactly one altered model");
730        let alter = &diff.alter_models[0];
731        assert_eq!(alter.table_name, "users");
732        assert_eq!(alter.add_fields.len(), 1);
733        assert_eq!(alter.add_fields[0].column_name, "bio");
734        assert!(alter.drop_fields.is_empty());
735    }
736}