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(_) => db_type.to_string(),
153 }
154}
155
156fn 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
197fn 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
213fn 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
225fn 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 !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
269fn 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 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 assert!(model.get_field("team_id").unwrap().has_attribute("id"));
495 assert!(model.get_field("user_id").unwrap().has_attribute("id"));
496 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 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 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 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 use prax_migrate::SchemaDiffer;
691
692 let mut db = roundtrip_database();
693 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 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}