1use 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
50pub 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
78fn 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
97fn 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
118fn 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 NormalizedType::Enum(name) => name.clone(),
145 NormalizedType::Array(_) => "ARRAY".to_string(),
149 NormalizedType::Unknown(udt) => udt.clone(),
158 }
159}
160
161fn 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
202fn 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
218fn 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
230fn map_indexes(indexes: &[IndexInfo], table_name: &str) -> Vec<MigrateIndex> {
240 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
265fn 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 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 #[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 #[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 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 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 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 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 assert!(model.get_field("team_id").unwrap().has_attribute("id"));
629 assert!(model.get_field("user_id").unwrap().has_attribute("id"));
630 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 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 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 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 use prax_migrate::SchemaDiffer;
825
826 let mut db = roundtrip_database();
827 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 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}