use prax_migrate::{
ColumnInfo as MigrateColumn, ConstraintInfo, EnumInfo as MigrateEnum,
IndexInfo as MigrateIndex, IntrospectionConfig, IntrospectionResult, SchemaBuilder,
TableInfo as MigrateTable,
};
use prax_query::introspection::{
ColumnInfo, DatabaseSchema, ForeignKeyInfo, IndexInfo, NormalizedType, ReferentialAction,
TableInfo,
};
use crate::error::CliResult;
pub fn schema_from_database(
db: &DatabaseSchema,
config: IntrospectionConfig,
) -> CliResult<IntrospectionResult> {
let mut builder = SchemaBuilder::new(config).with_tables(map_tables(db));
for table in &db.tables {
builder = builder
.with_columns(&table.name, map_columns(&table.columns))
.with_constraints(&table.name, map_constraints(table))
.with_indexes(
&table.name,
map_indexes(&table.indexes, &table.foreign_keys, &table.name),
);
}
builder = builder.with_enums(map_enums(db));
builder.build().map_err(|e| {
crate::error::CliError::Migration(format!(
"Failed to build schema from database introspection: {e}"
))
})
}
fn map_tables(db: &DatabaseSchema) -> Vec<MigrateTable> {
db.tables
.iter()
.map(|t| MigrateTable {
name: t.name.clone(),
schema: t
.schema
.clone()
.or_else(|| db.schema.clone())
.unwrap_or_else(|| "public".to_string()),
table_type: "BASE TABLE".to_string(),
comment: t.comment.clone(),
})
.collect()
}
fn map_columns(columns: &[ColumnInfo]) -> Vec<MigrateColumn> {
columns
.iter()
.enumerate()
.map(|(i, c)| MigrateColumn {
name: c.name.clone(),
data_type: c.db_type.clone(),
udt_name: udt_name_for(&c.normalized_type, &c.db_type),
character_maximum_length: c.max_length,
numeric_precision: c.precision,
is_nullable: c.nullable,
column_default: c.default.clone(),
ordinal_position: i as i32,
comment: c.comment.clone(),
})
.collect()
}
fn udt_name_for(normalized: &NormalizedType, db_type: &str) -> String {
match normalized {
NormalizedType::Int | NormalizedType::SmallInt => "int4".to_string(),
NormalizedType::BigInt => "int8".to_string(),
NormalizedType::Float => "float4".to_string(),
NormalizedType::Double => "float8".to_string(),
NormalizedType::Decimal { .. } => "numeric".to_string(),
NormalizedType::String
| NormalizedType::Text
| NormalizedType::VarChar { .. }
| NormalizedType::Char { .. } => "text".to_string(),
NormalizedType::Bytes => "bytea".to_string(),
NormalizedType::Boolean => "bool".to_string(),
NormalizedType::DateTime | NormalizedType::Timestamp => "timestamptz".to_string(),
NormalizedType::Date => "date".to_string(),
NormalizedType::Time => "time".to_string(),
NormalizedType::Json => "jsonb".to_string(),
NormalizedType::Uuid => "uuid".to_string(),
NormalizedType::Enum(name) => name.clone(),
NormalizedType::Array(_) => "ARRAY".to_string(),
NormalizedType::Unknown(_) => db_type.to_string(),
}
}
fn map_constraints(table: &TableInfo) -> Vec<ConstraintInfo> {
let mut constraints = Vec::new();
if !table.primary_key.is_empty() {
constraints.push(ConstraintInfo {
name: format!("{}_pkey", table.name),
constraint_type: "PRIMARY KEY".to_string(),
table_name: table.name.clone(),
columns: table.primary_key.clone(),
referenced_table: None,
referenced_columns: None,
on_delete: None,
on_update: None,
});
}
for uc in &table.unique_constraints {
constraints.push(ConstraintInfo {
name: uc.name.clone(),
constraint_type: "UNIQUE".to_string(),
table_name: table.name.clone(),
columns: uc.columns.clone(),
referenced_table: None,
referenced_columns: None,
on_delete: None,
on_update: None,
});
}
for fk in &table.foreign_keys {
constraints.push(map_foreign_key(fk, &table.name));
}
constraints
}
fn map_foreign_key(fk: &ForeignKeyInfo, table_name: &str) -> ConstraintInfo {
ConstraintInfo {
name: fk.name.clone(),
constraint_type: "FOREIGN KEY".to_string(),
table_name: table_name.to_string(),
columns: fk.columns.clone(),
referenced_table: Some(fk.referenced_table.clone()),
referenced_columns: Some(fk.referenced_columns.clone()),
on_delete: referential_action_sql(fk.on_delete),
on_update: referential_action_sql(fk.on_update),
}
}
fn referential_action_sql(action: ReferentialAction) -> Option<String> {
match action {
ReferentialAction::NoAction => None,
ReferentialAction::Restrict => Some("RESTRICT".to_string()),
ReferentialAction::Cascade => Some("CASCADE".to_string()),
ReferentialAction::SetNull => Some("SET NULL".to_string()),
ReferentialAction::SetDefault => Some("SET DEFAULT".to_string()),
}
}
fn map_indexes(
indexes: &[IndexInfo],
foreign_keys: &[ForeignKeyInfo],
table_name: &str,
) -> Vec<MigrateIndex> {
indexes
.iter()
.filter(|idx| {
if idx.is_unique || idx.is_primary {
return true;
}
let cols: Vec<&str> = idx.columns.iter().map(|c| c.name.as_str()).collect();
!foreign_keys.iter().any(|fk| {
fk.columns.len() == cols.len()
&& fk
.columns
.iter()
.map(String::as_str)
.eq(cols.iter().copied())
})
})
.map(|idx| MigrateIndex {
name: idx.name.clone(),
table_name: table_name.to_string(),
columns: idx.columns.iter().map(|c| c.name.clone()).collect(),
is_unique: idx.is_unique,
is_primary: idx.is_primary,
index_method: idx
.index_type
.clone()
.unwrap_or_else(|| "btree".to_string()),
})
.collect()
}
fn map_enums(db: &DatabaseSchema) -> Vec<MigrateEnum> {
db.enums
.iter()
.map(|e| MigrateEnum {
name: e.name.clone(),
values: e.values.clone(),
schema: e
.schema
.clone()
.or_else(|| db.schema.clone())
.unwrap_or_else(|| "public".to_string()),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use prax_query::introspection::{EnumInfo, IndexColumn, UniqueConstraint};
use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
fn column(name: &str, normalized: NormalizedType, nullable: bool) -> ColumnInfo {
ColumnInfo {
name: name.to_string(),
db_type: "".to_string(),
normalized_type: normalized,
nullable,
..Default::default()
}
}
#[test]
fn udt_name_maps_normalized_types_to_recognized_short_names() {
assert_eq!(udt_name_for(&NormalizedType::Int, ""), "int4");
assert_eq!(udt_name_for(&NormalizedType::BigInt, ""), "int8");
assert_eq!(udt_name_for(&NormalizedType::Boolean, ""), "bool");
assert_eq!(udt_name_for(&NormalizedType::DateTime, ""), "timestamptz");
assert_eq!(udt_name_for(&NormalizedType::Uuid, ""), "uuid");
assert_eq!(udt_name_for(&NormalizedType::Json, ""), "jsonb");
assert_eq!(
udt_name_for(&NormalizedType::VarChar { length: Some(255) }, ""),
"text"
);
assert_eq!(
udt_name_for(&NormalizedType::Enum("Role".to_string()), ""),
"Role"
);
assert_eq!(
udt_name_for(
&NormalizedType::Unknown("geography".to_string()),
"geography"
),
"geography"
);
}
#[test]
fn referential_actions_map_to_sql_keywords() {
assert_eq!(referential_action_sql(ReferentialAction::NoAction), None);
assert_eq!(
referential_action_sql(ReferentialAction::Cascade),
Some("CASCADE".to_string())
);
assert_eq!(
referential_action_sql(ReferentialAction::SetNull),
Some("SET NULL".to_string())
);
}
#[test]
fn maps_a_simple_table_to_a_model_with_columns_and_pk() {
let db = DatabaseSchema {
name: "db".to_string(),
schema: Some("public".to_string()),
tables: vec![TableInfo {
name: "users".to_string(),
schema: Some("public".to_string()),
columns: vec![
column("id", NormalizedType::BigInt, false),
column("email", NormalizedType::Text, false),
column("name", NormalizedType::Text, true),
],
primary_key: vec!["id".to_string()],
..Default::default()
}],
..Default::default()
};
let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
let model = result.schema.get_model("Users").expect("Users model");
let id = model.get_field("id").expect("id field");
assert!(id.has_attribute("id"));
assert!(matches!(
&id.field_type,
FieldType::Scalar(ScalarType::BigInt)
));
let email = model.get_field("email").expect("email field");
assert_eq!(email.modifier, TypeModifier::Required);
assert!(matches!(
&email.field_type,
FieldType::Scalar(ScalarType::String)
));
let name = model.get_field("name").expect("name field");
assert_eq!(name.modifier, TypeModifier::Optional);
}
#[test]
fn maps_foreign_keys_to_relation_fields() {
let db = DatabaseSchema {
name: "db".to_string(),
schema: Some("public".to_string()),
tables: vec![
TableInfo {
name: "users".to_string(),
columns: vec![column("id", NormalizedType::BigInt, false)],
primary_key: vec!["id".to_string()],
..Default::default()
},
TableInfo {
name: "posts".to_string(),
columns: vec![
column("id", NormalizedType::BigInt, false),
column("author_id", NormalizedType::BigInt, false),
],
primary_key: vec!["id".to_string()],
foreign_keys: vec![ForeignKeyInfo {
name: "posts_author_id_fkey".to_string(),
columns: vec!["author_id".to_string()],
referenced_table: "users".to_string(),
referenced_schema: None,
referenced_columns: vec!["id".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
..Default::default()
},
],
..Default::default()
};
let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
let posts = result.schema.get_model("Posts").expect("Posts model");
let author = posts.get_field("author").expect("relation field");
let rel = author
.extract_attributes()
.relation
.expect("@relation present");
assert_eq!(rel.fields, ["author_id"]);
assert_eq!(rel.references, ["id"]);
}
#[test]
fn maps_enums_and_enum_typed_columns() {
let db = DatabaseSchema {
name: "db".to_string(),
schema: Some("public".to_string()),
tables: vec![TableInfo {
name: "users".to_string(),
columns: vec![
column("id", NormalizedType::BigInt, false),
column("role", NormalizedType::Enum("role".to_string()), false),
],
primary_key: vec!["id".to_string()],
..Default::default()
}],
enums: vec![EnumInfo {
name: "role".to_string(),
schema: Some("public".to_string()),
values: vec!["ADMIN".to_string(), "USER".to_string()],
}],
..Default::default()
};
let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
assert!(result.schema.get_enum("Role").is_some());
let users = result.schema.get_model("Users").expect("Users model");
let role = users.get_field("role").expect("role field");
assert!(matches!(&role.field_type, FieldType::Enum(_)));
}
#[test]
fn maps_multi_column_unique_index() {
let db = DatabaseSchema {
name: "db".to_string(),
schema: Some("public".to_string()),
tables: vec![TableInfo {
name: "memberships".to_string(),
columns: vec![
column("team_id", NormalizedType::BigInt, false),
column("user_id", NormalizedType::BigInt, false),
],
primary_key: vec!["team_id".to_string(), "user_id".to_string()],
indexes: vec![IndexInfo {
name: "uq_membership".to_string(),
columns: vec![
IndexColumn {
name: "team_id".to_string(),
..Default::default()
},
IndexColumn {
name: "user_id".to_string(),
..Default::default()
},
],
is_unique: true,
is_primary: false,
index_type: Some("btree".to_string()),
filter: None,
}],
..Default::default()
}],
..Default::default()
};
let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
let model = result
.schema
.get_model("Memberships")
.expect("Memberships model");
assert!(model.get_field("team_id").unwrap().has_attribute("id"));
assert!(model.get_field("user_id").unwrap().has_attribute("id"));
assert!(model.get_attribute("unique").is_some());
}
#[test]
fn excluded_tables_are_skipped() {
let db = DatabaseSchema {
name: "db".to_string(),
schema: Some("public".to_string()),
tables: vec![TableInfo {
name: "_prax_migrations".to_string(),
columns: vec![column("id", NormalizedType::BigInt, false)],
primary_key: vec!["id".to_string()],
..Default::default()
}],
..Default::default()
};
let result = schema_from_database(&db, IntrospectionConfig::default()).unwrap();
assert!(result.schema.get_model("PraxMigrations").is_none());
assert!(result.schema.models.is_empty());
}
const ROUNDTRIP_PRAX: &str = r#"
model User {
id BigInt @id
email String @unique
@@map("users")
}
model Post {
id BigInt @id
title String
author_id BigInt
author User @relation(fields: [author_id], references: [id], map: "posts_author_id_fkey")
@@map("posts")
}
"#;
fn roundtrip_database() -> DatabaseSchema {
DatabaseSchema {
name: "db".to_string(),
schema: Some("public".to_string()),
tables: vec![
TableInfo {
name: "users".to_string(),
schema: Some("public".to_string()),
columns: vec![
column("id", NormalizedType::BigInt, false),
column("email", NormalizedType::Text, false),
],
primary_key: vec!["id".to_string()],
unique_constraints: vec![UniqueConstraint {
name: "users_email_key".to_string(),
columns: vec!["email".to_string()],
}],
indexes: vec![IndexInfo {
name: "users_email_key".to_string(),
columns: vec![IndexColumn {
name: "email".to_string(),
..Default::default()
}],
is_unique: true,
is_primary: false,
index_type: Some("btree".to_string()),
filter: None,
}],
..Default::default()
},
TableInfo {
name: "posts".to_string(),
schema: Some("public".to_string()),
columns: vec![
column("id", NormalizedType::BigInt, false),
column("title", NormalizedType::Text, false),
column("author_id", NormalizedType::BigInt, false),
],
primary_key: vec!["id".to_string()],
foreign_keys: vec![ForeignKeyInfo {
name: "posts_author_id_fkey".to_string(),
columns: vec!["author_id".to_string()],
referenced_table: "users".to_string(),
referenced_schema: None,
referenced_columns: vec!["id".to_string()],
on_delete: ReferentialAction::NoAction,
on_update: ReferentialAction::NoAction,
}],
..Default::default()
},
],
..Default::default()
}
}
#[test]
fn introspected_source_matching_target_yields_empty_diff() {
use prax_migrate::SchemaDiffer;
let target = prax_schema::parse_schema(ROUNDTRIP_PRAX).unwrap();
let source = schema_from_database(&roundtrip_database(), IntrospectionConfig::default())
.unwrap()
.schema;
let diff = SchemaDiffer::new(target)
.with_source(source)
.diff()
.unwrap();
assert!(
diff.is_empty(),
"expected no spurious churn, got: {}",
diff.summary()
);
}
#[test]
fn introspected_source_with_defaults_round_trips_empty() {
use prax_migrate::SchemaDiffer;
let mut active = column("active", NormalizedType::Boolean, false);
active.default = Some("true".to_string());
let mut score = column("score", NormalizedType::Int, false);
score.default = Some("0".to_string());
let mut label = column("label", NormalizedType::Text, false);
label.default = Some("'draft'".to_string());
let db = DatabaseSchema {
name: "db".to_string(),
schema: Some("public".to_string()),
tables: vec![TableInfo {
name: "widgets".to_string(),
schema: Some("public".to_string()),
columns: vec![
column("id", NormalizedType::BigInt, false),
active,
score,
label,
],
primary_key: vec!["id".to_string()],
..Default::default()
}],
..Default::default()
};
let target = prax_schema::parse_schema(
r#"
model Widget {
id BigInt @id
active Boolean @default(true)
score Int @default(0)
label String @default("draft")
@@map("widgets")
}
"#,
)
.unwrap();
let source = schema_from_database(&db, IntrospectionConfig::default())
.unwrap()
.schema;
let diff = SchemaDiffer::new(target)
.with_source(source)
.diff()
.unwrap();
assert!(
diff.is_empty(),
"defaulted columns should round-trip clean, got: {}",
diff.summary()
);
}
#[test]
fn introspected_source_missing_column_yields_only_that_delta() {
use prax_migrate::SchemaDiffer;
let mut db = roundtrip_database();
let target = prax_schema::parse_schema(
r#"
model User {
id BigInt @id
email String @unique
bio String?
@@map("users")
}
model Post {
id BigInt @id
title String
author_id BigInt
author User @relation(fields: [author_id], references: [id], map: "posts_author_id_fkey")
@@map("posts")
}
"#,
)
.unwrap();
db.tables[0].columns.retain(|c| c.name != "bio");
let source = schema_from_database(&db, IntrospectionConfig::default())
.unwrap()
.schema;
let diff = SchemaDiffer::new(target)
.with_source(source)
.diff()
.unwrap();
assert!(diff.create_models.is_empty(), "no new tables expected");
assert_eq!(diff.alter_models.len(), 1, "exactly one altered model");
let alter = &diff.alter_models[0];
assert_eq!(alter.table_name, "users");
assert_eq!(alter.add_fields.len(), 1);
assert_eq!(alter.add_fields[0].column_name, "bio");
assert!(alter.drop_fields.is_empty());
}
}