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(
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
81fn 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
100fn 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
121fn 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 NormalizedType::Enum(name) => name.clone(),
148 NormalizedType::Array(_) => "ARRAY".to_string(),
152 NormalizedType::Unknown(udt) => udt.clone(),
161 }
162}
163
164fn 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
205fn 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
221fn 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
233fn map_indexes(
242 indexes: &[IndexInfo],
243 _foreign_keys: &[ForeignKeyInfo],
244 table_name: &str,
245) -> Vec<MigrateIndex> {
246 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
271fn 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 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 assert!(model.get_field("team_id").unwrap().has_attribute("id"));
497 assert!(model.get_field("user_id").unwrap().has_attribute("id"));
498 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 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 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 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 use prax_migrate::SchemaDiffer;
693
694 let mut db = roundtrip_database();
695 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 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}