#![cfg(test)]
use super::*;
use crate::cli::makemigration::{collect_destructive_messages, topological_sort_changes};
fn parse_model(src: &str) -> ParsedSchema {
parse_schema_from_source(src)
.unwrap()
.expect("model! source should parse")
.1
}
fn col<'a>(schema: &'a ParsedSchema, name: &str) -> &'a ParsedColumn {
schema
.columns
.iter()
.find(|c| c.name == name)
.unwrap_or_else(|| panic!("column `{name}` not found"))
}
fn change(
is_new_table: bool,
added_fks: Vec<ParsedFk>,
dropped_columns: Vec<ParsedColumn>,
) -> Changes {
Changes {
table_name: "t".into(),
added_columns: vec![],
dropped_columns,
modified_columns: vec![],
added_fks,
dropped_fks: vec![],
added_indexes: vec![],
dropped_indexes: vec![],
is_new_table,
renamed_columns: vec![],
enum_renames: vec![],
enum_value_adds: vec![],
enum_value_drops: vec![],
}
}
fn cascade_fk() -> ParsedFk {
ParsedFk {
from_column: "parent_id".into(),
to_table: "parent".into(),
to_column: "id".into(),
on_delete: "CASCADE".into(),
on_update: "NoAction".into(),
}
}
const BLOG_SRC: &str = r#"
use runique::prelude::*;
model! {
Blog,
table: "blog",
pk: id => Pk,
{
title: text [required],
view_count: int [default: 0],
is_active: bool [default: true],
}
}
"#;
#[test]
fn literal_default_is_parsed() {
let schema = parse_model(BLOG_SRC);
assert_eq!(
col(&schema, "view_count").default_value.as_deref(),
Some("0")
);
assert_eq!(
col(&schema, "is_active").default_value.as_deref(),
Some("true")
);
}
#[test]
fn literal_default_is_emitted_in_create() {
let sql = generate_create_file(&parse_model(BLOG_SRC));
assert!(sql.contains(".default(0)"), "int default missing:\n{sql}");
assert!(
sql.contains(".default(true)"),
"bool default missing:\n{sql}"
);
}
#[test]
fn columns_are_not_null_unless_declared_nullable() {
let schema = parse_model(
r#"model! { T, table: "t", pk: id => i32, {
flag: bool [default: true],
name: text,
count: int [required],
note: int [nullable],
} }"#,
);
assert!(!col(&schema, "flag").nullable);
assert!(!col(&schema, "name").nullable);
assert!(!col(&schema, "count").nullable);
assert!(col(&schema, "note").nullable);
}
#[test]
fn snapshot_default_does_not_set_has_default_now() {
let schema = parse_model(BLOG_SRC);
let snapshot = generate_snapshot_file(&schema);
let reparsed = parse_seaorm_source(&snapshot).expect("snapshot should reparse");
assert!(
!col(&reparsed, "view_count").has_default_now,
"literal default must not be read back as a timestamp default"
);
}
#[test]
fn snapshot_round_trip_is_stable() {
let schema = parse_model(BLOG_SRC);
let snapshot = generate_snapshot_file(&schema);
let reparsed = parse_seaorm_source(&snapshot).expect("snapshot should reparse");
let changes = diff_schemas(&reparsed, &schema);
assert!(
changes.is_empty(),
"round-trip not stable, spurious changes: {changes:?}"
);
}
#[test]
fn cascade_fk_on_new_table_is_not_destructive() {
let changes = vec![change(true, vec![cascade_fk()], vec![])];
assert!(
collect_destructive_messages(&changes).is_empty(),
"CASCADE FK on a brand-new table must not be flagged destructive"
);
}
#[test]
fn cascade_fk_on_existing_table_is_destructive() {
let changes = vec![change(false, vec![cascade_fk()], vec![])];
let msgs = collect_destructive_messages(&changes);
assert!(
msgs.iter().any(|m| m.contains("CASCADE")),
"CASCADE FK on an existing table must be flagged: {msgs:?}"
);
}
#[test]
fn drop_column_is_destructive() {
let dropped = vec![ParsedColumn {
name: "removed".into(),
..Default::default()
}];
let changes = vec![change(false, vec![], dropped)];
let msgs = collect_destructive_messages(&changes);
assert!(
msgs.iter().any(|m| m.contains("DROP COLUMN")),
"dropping a column must be flagged: {msgs:?}"
);
}
const ENUM_V1: &str = r#"
use runique::prelude::*;
model! {
Post,
table: "post",
pk: id => Pk,
enums: {
Status: [Draft="Draft", Published="Published"],
},
{
status: choice [enum(Status), required],
}
}
"#;
const ENUM_V2: &str = r#"
use runique::prelude::*;
model! {
Post,
table: "post",
pk: id => Pk,
enums: {
Status: [Draft="Draft", Published="Published", Archived="Archived"],
},
{
status: choice [enum(Status), required],
}
}
"#;
#[test]
fn enum_value_addition_is_detected() {
let changes = diff_schemas(&parse_model(ENUM_V1), &parse_model(ENUM_V2));
assert!(
!changes.enum_value_adds.is_empty(),
"adding an enum variant should be detected: {changes:?}"
);
}
const ENUM_RENAMED: &str = r#"
use runique::prelude::*;
model! {
Post,
table: "post",
pk: id => Pk,
enums: {
Status: [Draft="Draft", Published="Release"],
},
{
status: choice [enum(Status), required],
}
}
"#;
#[test]
fn enum_rename_is_a_single_operation() {
let changes = diff_schemas(&parse_model(ENUM_V1), &parse_model(ENUM_RENAMED));
assert_eq!(
changes.enum_renames.len(),
1,
"exactly one rename: {changes:?}"
);
let (col, enum_name, old, new) = &changes.enum_renames[0];
assert_eq!(
(col.as_str(), enum_name.as_str(), old.as_str(), new.as_str()),
("status", "Status", "Published", "Release")
);
assert!(
changes.enum_value_adds.is_empty(),
"rename must not appear as an add"
);
assert!(
changes.enum_value_drops.is_empty(),
"rename must not appear as a drop"
);
}
#[test]
fn enum_rename_branches_on_runtime_backend() {
let changes = diff_schemas(&parse_model(ENUM_V1), &parse_model(ENUM_RENAMED));
let sql = generate_alter_file(&changes);
assert!(
sql.contains("get_database_backend() == sea_orm::DbBackend::Postgres"),
"must check the real backend at runtime:\n{sql}"
);
assert!(
sql.contains(".name(Alias::new(\"status\"))")
&& sql.contains(".rename_value(Alias::new(\"Published\"), Alias::new(\"Release\"))"),
"Postgres branch must use the Type::alter().rename_value() builder, with the \
type name lowercased to match the unquoted CREATE TYPE's case-folded identity:\n{sql}"
);
assert!(
sql.contains(".table(Alias::new(\"post\"))")
&& sql.contains(".value(Alias::new(\"status\"), \"Release\")")
&& sql.contains(".eq(\"Published\")"),
"non-Postgres branch must use the Query::update() builder:\n{sql}"
);
}
#[test]
fn enum_create_type_is_postgres_only() {
let schema = parse_model(ENUM_V2);
let sql = generate_create_file(&schema);
assert!(
sql.contains("get_database_backend() == sea_orm::DbBackend::Postgres"),
"must check the real backend at runtime:\n{sql}"
);
assert!(
sql.contains("Type::create()") && sql.contains(".as_enum(Alias::new(\"status\"))"),
"the enum type must always be created (runtime-guarded):\n{sql}"
);
assert!(
sql.contains("Type::drop()"),
"down() must always emit the matching DROP TYPE (runtime-guarded):\n{sql}"
);
}
const EXTEND_SRC: &str = r#"
use runique::prelude::*;
extend! {
table: "eihwaz_users",
fields: {
is_verified: bool [nullable, default: false],
job_title: text [nullable],
}
}
"#;
#[test]
fn extend_captures_literal_default() {
let schemas = parse_extend_blocks_from_source(EXTEND_SRC).unwrap();
let schema = schemas.first().expect("one extend block");
assert_eq!(
col(schema, "is_verified").default_value.as_deref(),
Some("false"),
"extend!{{}} must capture literal defaults"
);
}
const EXTEND_ENUM_SRC: &str = r#"
use runique::prelude::*;
extend! {
table: "eihwaz_users",
enums: {
Role: [Admin="admin", Staff="staff", Guest="guest"],
},
fields: {
role: choice [enum(Role), required],
}
}
"#;
#[test]
fn extend_resolves_enum_column() {
let schemas = parse_extend_blocks_from_source(EXTEND_ENUM_SRC).unwrap();
let schema = schemas.first().expect("one extend block");
let role = col(schema, "role");
assert_eq!(role.enum_name.as_deref(), Some("Role"));
assert_eq!(
role.enum_string_values,
vec!["admin", "staff", "guest"],
"extend!{{}} must resolve enum variants for a choice column"
);
assert_eq!(
role.col_type, "String",
"Auto-backed enum is a VARCHAR/enum column"
);
assert!(!role.nullable, "required choice must be NOT NULL");
}
#[test]
fn extend_enum_column_emits_create_type_on_postgres() {
let schema = &parse_extend_blocks_from_source(EXTEND_ENUM_SRC).unwrap()[0];
let change = Changes {
table_name: schema.table_name.clone(),
added_columns: schema.columns.clone(),
dropped_columns: vec![],
modified_columns: vec![],
added_fks: vec![],
dropped_fks: vec![],
added_indexes: vec![],
dropped_indexes: vec![],
is_new_table: false,
renamed_columns: vec![],
enum_renames: vec![],
enum_value_adds: vec![],
enum_value_drops: vec![],
};
let sql = generate_alter_file(&change);
assert!(
sql.contains("get_database_backend() == sea_orm::DbBackend::Postgres"),
"must check the real backend at runtime:\n{sql}"
);
assert!(
sql.contains("Type::create()"),
"extend enum must create its type, runtime-guarded to Postgres:\n{sql}"
);
}
const COL_BEFORE: &str = r#"
use runique::prelude::*;
model! {
Person,
table: "person",
pk: id => Pk,
{
job_title: text [nullable],
age: int [nullable],
}
}
"#;
const COL_RENAMED: &str = r#"
use runique::prelude::*;
model! {
Person,
table: "person",
pk: id => Pk,
{
title: text [nullable, renamed_from: "job_title"],
age: int [nullable],
}
}
"#;
const COL_NO_HINT: &str = r#"
use runique::prelude::*;
model! {
Person,
table: "person",
pk: id => Pk,
{
title: text [nullable],
age: int [nullable],
}
}
"#;
#[test]
fn column_rename_is_detected_via_hint() {
let changes = diff_schemas(&parse_model(COL_BEFORE), &parse_model(COL_RENAMED));
assert_eq!(
changes.renamed_columns,
vec![("job_title".to_string(), "title".to_string())]
);
assert!(
changes.added_columns.is_empty(),
"rename must not add: {:?}",
changes.added_columns
);
assert!(
changes.dropped_columns.is_empty(),
"rename must not drop: {:?}",
changes.dropped_columns
);
}
#[test]
fn column_rename_without_hint_is_drop_add() {
let changes = diff_schemas(&parse_model(COL_BEFORE), &parse_model(COL_NO_HINT));
assert!(changes.renamed_columns.is_empty());
assert!(changes.added_columns.iter().any(|c| c.name == "title"));
assert!(
changes
.dropped_columns
.iter()
.any(|c| c.name == "job_title")
);
}
#[test]
fn column_rename_sql_is_portable_across_engines() {
let changes = diff_schemas(&parse_model(COL_BEFORE), &parse_model(COL_RENAMED));
let sql = generate_alter_file(&changes);
assert!(
sql.contains(r#".rename_column(Alias::new("job_title"), Alias::new("title"))"#),
"RENAME COLUMN missing:\n{sql}"
);
assert!(!sql.contains("drop_column"), "rename must not DROP:\n{sql}");
assert!(!sql.contains("add_column"), "rename must not ADD:\n{sql}");
}
#[test]
fn column_rename_down_reverses() {
let changes = diff_schemas(&parse_model(COL_BEFORE), &parse_model(COL_RENAMED));
let sql = generate_alter_file(&changes);
let down = sql.split("async fn down").nth(1).unwrap_or("");
assert!(
down.contains(r#".rename_column(Alias::new("title"), Alias::new("job_title"))"#),
"down must reverse the rename:\n{sql}"
);
}
#[test]
fn stale_renamed_from_falls_back_to_add() {
let changes = diff_schemas(&parse_model(COL_NO_HINT), &parse_model(COL_RENAMED));
assert!(
changes.renamed_columns.is_empty(),
"stale hint must not rename: {:?}",
changes.renamed_columns
);
}
const EXTEND_COL_BEFORE: &str = r#"
use runique::prelude::*;
extend! {
table: "eihwaz_users",
fields: {
job_title: text [nullable],
}
}
"#;
const EXTEND_COL_RENAMED: &str = r#"
use runique::prelude::*;
extend! {
table: "eihwaz_users",
fields: {
title: text [nullable, renamed_from: "job_title"],
}
}
"#;
#[test]
fn extend_column_rename_is_detected() {
let prev = parse_extend_blocks_from_source(EXTEND_COL_BEFORE)
.unwrap()
.remove(0);
let curr = parse_extend_blocks_from_source(EXTEND_COL_RENAMED)
.unwrap()
.remove(0);
let changes = diff_schemas(&prev, &curr);
assert_eq!(
changes.renamed_columns,
vec![("job_title".to_string(), "title".to_string())]
);
assert!(changes.added_columns.is_empty());
assert!(changes.dropped_columns.is_empty());
let sql = generate_alter_file(&changes);
assert!(
sql.contains(r#".rename_column(Alias::new("job_title"), Alias::new("title"))"#),
"extend rename must emit RENAME COLUMN:\n{sql}"
);
}
fn model_pk(pk: &str) -> String {
format!(
"use runique::prelude::*;\nmodel! {{\n Thing,\n table: \"thing\",\n pk: id => {pk},\n {{\n label: text,\n }}\n}}\n"
)
}
#[cfg(not(any(feature = "big-pk", feature = "pk-uuid")))]
#[test]
fn pk_generic_alias_is_autoincrement_integer_by_default() {
let sql = generate_create_file(&parse_model(&model_pk("Pk")));
assert!(
sql.contains(
r#".col(ColumnDef::new(Alias::new("id")).integer().not_null().auto_increment().primary_key())"#
),
"{sql}"
);
}
#[cfg(all(feature = "big-pk", not(feature = "pk-uuid")))]
#[test]
fn pk_generic_alias_is_autoincrement_big_integer_under_big_pk() {
let sql = generate_create_file(&parse_model(&model_pk("Pk")));
assert!(
sql.contains(
r#".col(ColumnDef::new(Alias::new("id")).big_integer().not_null().auto_increment().primary_key())"#
),
"{sql}"
);
}
#[cfg(feature = "pk-uuid")]
#[test]
fn pk_generic_alias_is_uuid_no_autoincrement_under_pk_uuid() {
let sql = generate_create_file(&parse_model(&model_pk("Pk")));
assert!(
sql.contains(r#".col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())"#),
"{sql}"
);
assert!(
!sql.contains("auto_increment"),
"generic Pk alias under pk-uuid must not auto_increment:\n{sql}"
);
}
#[test]
fn pk_i64_is_big_integer_autoincrement() {
let sql = generate_create_file(&parse_model(&model_pk("i64")));
assert!(
sql.contains(".big_integer().not_null().auto_increment().primary_key()"),
"{sql}"
);
}
#[test]
fn pk_uuid_has_no_autoincrement() {
let sql = generate_create_file(&parse_model(&model_pk("uuid")));
assert!(sql.contains(".uuid().not_null().primary_key()"), "{sql}");
assert!(
!sql.contains("auto_increment"),
"uuid PK must not auto_increment:\n{sql}"
);
}
const TS_MODEL: &str = r#"
use runique::prelude::*;
model! {
Event,
table: "event",
pk: id => Pk,
{
created_at: datetime [auto_now],
updated_at: datetime [auto_now_update],
}
}
"#;
#[test]
fn auto_now_columns_default_to_current_timestamp() {
let schema = parse_model(TS_MODEL);
assert!(col(&schema, "created_at").has_default_now);
assert!(col(&schema, "updated_at").has_default_now);
let sql = generate_create_file(&schema);
assert!(sql.contains(".default(Expr::current_timestamp())"), "{sql}");
}
#[test]
fn a_column_name_alone_decides_nothing() {
let schema = parse_model(
r#"model! { E, table: "e", pk: id => i32, {
created_at: datetime [nullable],
cache_key: text,
} }"#,
);
assert!(!col(&schema, "created_at").has_default_now);
assert!(!col(&schema, "cache_key").ignored);
let sql = generate_create_file(&schema);
assert!(!sql.contains("current_timestamp"), "{sql}");
}
#[test]
fn updated_at_is_left_to_the_entity() {
let sql = generate_create_file(&parse_model(TS_MODEL));
for raw in [
"TRIGGER",
"plpgsql",
"ON UPDATE CURRENT_TIMESTAMP",
"execute_unprepared",
] {
assert!(!sql.contains(raw), "{raw}:\n{sql}");
}
}
fn fk_schema(table: &str, fk: ParsedFk) -> ParsedSchema {
ParsedSchema {
table_name: table.into(),
primary_key: None,
columns: vec![],
foreign_keys: vec![fk],
indexes: vec![],
}
}
#[test]
fn fk_cascade_action_is_rendered() {
let sql = generate_create_file(&fk_schema(
"comment",
ParsedFk {
from_column: "post_id".into(),
to_table: "post".into(),
to_column: "id".into(),
on_delete: "Cascade".into(),
on_update: "NoAction".into(),
},
));
assert!(
sql.contains(".on_delete(ForeignKeyAction::Cascade)"),
"{sql}"
);
assert!(
sql.contains(r#".from(Alias::new("comment"), Alias::new("post_id"))"#),
"{sql}"
);
assert!(
sql.contains(r#".to(Alias::new("post"), Alias::new("id"))"#),
"{sql}"
);
}
#[test]
fn fk_set_null_and_restrict_actions_render() {
let sql = generate_create_file(&fk_schema(
"child",
ParsedFk {
from_column: "a_id".into(),
to_table: "a".into(),
to_column: "id".into(),
on_delete: "SetNull".into(),
on_update: "Restrict".into(),
},
));
assert!(
sql.contains(".on_delete(ForeignKeyAction::SetNull)"),
"{sql}"
);
assert!(
sql.contains(".on_update(ForeignKeyAction::Restrict)"),
"{sql}"
);
}
const ENUM_I32: &str = r#"
use runique::prelude::*;
model! {
Task,
table: "task",
pk: id => Pk,
enums: {
Priority: i32 [Low=1, Mid=2, High=3],
},
{
priority: choice [enum(Priority), required],
}
}
"#;
#[test]
fn i32_backed_enum_is_integer_column_without_create_type() {
let schema = parse_model(ENUM_I32);
let prio = col(&schema, "priority");
assert_eq!(prio.col_type, "Integer");
assert!(
prio.enum_string_values.is_empty(),
"i32-backed enum carries no string variants"
);
let pg = generate_create_file(&schema);
assert!(
!pg.contains("CREATE TYPE"),
"i32 enum must not CREATE TYPE:\n{pg}"
);
assert!(
pg.contains(r#".col(ColumnDef::new(Alias::new("priority")).integer()"#),
"i32 enum is an integer column:\n{pg}"
);
}
fn empty_changes() -> Changes {
Changes {
table_name: "t".into(),
added_columns: vec![],
dropped_columns: vec![],
modified_columns: vec![],
renamed_columns: vec![],
added_fks: vec![],
dropped_fks: vec![],
added_indexes: vec![],
dropped_indexes: vec![],
is_new_table: false,
enum_renames: vec![],
enum_value_adds: vec![],
enum_value_drops: vec![],
}
}
#[test]
fn destructive_type_change_is_flagged() {
let old = ParsedColumn {
name: "age".into(),
col_type: "Integer".into(),
..Default::default()
};
let new = ParsedColumn {
name: "age".into(),
col_type: "String".into(),
..Default::default()
};
let changes = vec![Changes {
modified_columns: vec![(old, new)],
..empty_changes()
}];
let msgs = collect_destructive_messages(&changes);
assert!(
msgs.iter().any(|m| m.contains("type Integer -> String")),
"{msgs:?}"
);
}
#[test]
fn destructive_nullable_to_not_null_is_flagged() {
let old = ParsedColumn {
name: "bio".into(),
col_type: "String".into(),
nullable: true,
..Default::default()
};
let new = ParsedColumn {
name: "bio".into(),
col_type: "String".into(),
nullable: false,
..Default::default()
};
let changes = vec![Changes {
modified_columns: vec![(old, new)],
..empty_changes()
}];
let msgs = collect_destructive_messages(&changes);
assert!(
msgs.iter().any(|m| m.contains("nullable -> not_null")),
"{msgs:?}"
);
}
#[test]
fn destructive_drop_fk_is_flagged() {
let changes = vec![Changes {
dropped_fks: vec![ParsedFk {
from_column: "post_id".into(),
to_table: "post".into(),
to_column: "id".into(),
on_delete: "NoAction".into(),
on_update: "NoAction".into(),
}],
..empty_changes()
}];
let msgs = collect_destructive_messages(&changes);
assert!(
msgs.iter().any(|m| m.contains("DROP FOREIGN KEY")),
"{msgs:?}"
);
}
#[test]
fn non_destructive_change_yields_no_messages() {
let changes = vec![Changes {
added_columns: vec![ParsedColumn {
name: "nickname".into(),
col_type: "String".into(),
nullable: true,
..Default::default()
}],
..empty_changes()
}];
assert!(collect_destructive_messages(&changes).is_empty());
}
#[test]
fn combined_alter_runs_rename_before_drop_and_add() {
let changes = Changes {
renamed_columns: vec![("old_name".into(), "new_name".into())],
added_columns: vec![ParsedColumn {
name: "fresh".into(),
col_type: "Integer".into(),
nullable: true,
..Default::default()
}],
dropped_columns: vec![ParsedColumn {
name: "gone".into(),
..Default::default()
}],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let up = sql.split("async fn down").next().unwrap();
let rename_pos = up.find("rename_column").expect("rename present");
let drop_pos = up.find("drop_column").expect("drop present");
let add_pos = up.find("add_column").expect("add present");
assert!(
rename_pos < drop_pos && rename_pos < add_pos,
"RENAME must precede DROP/ADD in up:\n{sql}"
);
}
fn up_down(sql: &str) -> (&str, &str) {
let mut parts = sql.split("async fn down");
let up = parts.next().unwrap_or("");
let down = parts.next().unwrap_or("");
(up, down)
}
#[test]
fn alter_add_column_up_adds_down_drops() {
let changes = Changes {
added_columns: vec![ParsedColumn {
name: "nickname".into(),
col_type: "String".into(),
nullable: true,
..Default::default()
}],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let (up, down) = up_down(&sql);
assert!(
up.contains(r#".add_column(ColumnDef::new(Alias::new("nickname")).string().null())"#),
"up:\n{up}"
);
assert!(
down.contains(r#".drop_column(Alias::new("nickname"))"#),
"down:\n{down}"
);
}
#[test]
fn alter_drop_column_up_drops_down_recreates() {
let changes = Changes {
dropped_columns: vec![ParsedColumn {
name: "legacy".into(),
col_type: "Integer".into(),
..Default::default()
}],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let (up, down) = up_down(&sql);
assert!(
up.contains(r#".drop_column(Alias::new("legacy"))"#),
"up:\n{up}"
);
assert!(
down.contains(r#".add_column(ColumnDef::new(Alias::new("legacy")).integer()"#),
"down must recreate the dropped column:\n{down}"
);
}
#[test]
fn alter_type_change_emits_warning_not_modify() {
let old = ParsedColumn {
name: "age".into(),
col_type: "Integer".into(),
..Default::default()
};
let new = ParsedColumn {
name: "age".into(),
col_type: "BigInteger".into(),
..Default::default()
};
let changes = Changes {
modified_columns: vec![(old, new)],
..empty_changes()
};
let sql = generate_alter_file(&changes);
assert!(
sql.contains("WARNING: type change on column 'age': Integer -> BigInteger"),
"type change must emit a manual WARNING:\n{sql}"
);
assert!(
!sql.contains(".modify_column("),
"type change must NOT auto-modify:\n{sql}"
);
}
#[test]
fn alter_type_change_only_body_uses_underscored_manager() {
let old = ParsedColumn {
name: "age".into(),
col_type: "Integer".into(),
..Default::default()
};
let new = ParsedColumn {
name: "age".into(),
col_type: "BigInteger".into(),
..Default::default()
};
let changes = Changes {
modified_columns: vec![(old, new)],
..empty_changes()
};
let sql = generate_alter_file(&changes);
assert!(
sql.contains("async fn up(&self, _manager: &SchemaManager)"),
"comment-only up body must use _manager:\n{sql}"
);
assert!(
sql.contains("async fn down(&self, _manager: &SchemaManager)"),
"empty down body must use _manager:\n{sql}"
);
}
#[test]
fn alter_nullable_to_not_null_modifies_and_reverses() {
let old = ParsedColumn {
name: "bio".into(),
col_type: "String".into(),
nullable: true,
..Default::default()
};
let new = ParsedColumn {
name: "bio".into(),
col_type: "String".into(),
nullable: false,
..Default::default()
};
let changes = Changes {
modified_columns: vec![(old, new)],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let (up, down) = up_down(&sql);
assert!(
up.contains(r#".modify_column(ColumnDef::new(Alias::new("bio")).string().not_null())"#),
"up:\n{up}"
);
assert!(
down.contains(r#".modify_column(ColumnDef::new(Alias::new("bio")).string().null())"#),
"down must restore nullable:\n{down}"
);
}
fn fk(from: &str, to_table: &str) -> ParsedFk {
ParsedFk {
from_column: from.into(),
to_table: to_table.into(),
to_column: "id".into(),
on_delete: "NoAction".into(),
on_update: "NoAction".into(),
}
}
#[test]
fn alter_add_fk_up_creates_down_drops() {
let changes = Changes {
added_fks: vec![fk("post_id", "post")],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let (up, down) = up_down(&sql);
assert!(up.contains(".create_foreign_key("), "up:\n{up}");
assert!(up.contains(r#".name("t_post_id_post_fkey")"#), "up:\n{up}");
assert!(down.contains(".drop_foreign_key("), "down:\n{down}");
}
#[test]
fn alter_drop_fk_up_drops_down_creates() {
let changes = Changes {
dropped_fks: vec![fk("post_id", "post")],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let (up, down) = up_down(&sql);
assert!(up.contains(".drop_foreign_key("), "up:\n{up}");
assert!(down.contains(".create_foreign_key("), "down:\n{down}");
}
#[test]
fn alter_add_unique_index_up_creates_down_drops() {
let changes = Changes {
added_indexes: vec![ParsedIndex {
name: "idx_t_email".into(),
columns: vec!["email".into()],
unique: true,
}],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let (up, down) = up_down(&sql);
assert!(up.contains(".create_index("), "up:\n{up}");
assert!(up.contains(r#".name("idx_t_email")"#), "up:\n{up}");
assert!(up.contains(".unique()"), "unique index:\n{up}");
assert!(
down.contains(r#".drop_index(Index::drop().name("idx_t_email")"#),
"down:\n{down}"
);
}
#[test]
fn alter_drop_index_up_drops_down_recreates() {
let changes = Changes {
dropped_indexes: vec![ParsedIndex {
name: "idx_t_slug".into(),
columns: vec!["slug".into()],
unique: false,
}],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let (up, down) = up_down(&sql);
assert!(
up.contains(r#".drop_index(Index::drop().name("idx_t_slug")"#),
"up:\n{up}"
);
assert!(down.contains(".create_index("), "down:\n{down}");
assert!(
!down.contains(".unique()"),
"non-unique index must not be unique on recreate:\n{down}"
);
}
const STR_DEFAULT: &str = r#"
use runique::prelude::*;
model! {
Cfg,
table: "cfg",
pk: id => Pk,
{
role: text [nullable, default: "guest"],
}
}
"#;
#[test]
fn string_literal_default_is_emitted() {
let sql = generate_create_file(&parse_model(STR_DEFAULT));
assert!(sql.contains(r#".default("guest")"#), "{sql}");
}
const UNIQUE_COL: &str = r#"
use runique::prelude::*;
model! {
Acc,
table: "acc",
pk: id => Pk,
{
email: text [required, unique],
}
}
"#;
#[test]
fn unique_required_column_renders_unique_key_not_null() {
let sql = generate_create_file(&parse_model(UNIQUE_COL));
assert!(
sql.contains(
r#".col(ColumnDef::new(Alias::new("email")).string().not_null().unique_key())"#
),
"{sql}"
);
}
const TYPES_MODEL: &str = r#"
use runique::prelude::*;
model! {
Mix,
table: "mix",
pk: id => Pk,
{
price: decimal [nullable],
active: bool [nullable],
ref_id: uuid [nullable],
payload: json [nullable],
big: bigint [nullable],
ratio: float [nullable],
}
}
"#;
#[test]
fn semantic_types_map_to_seaorm_methods() {
let sql = generate_create_file(&parse_model(TYPES_MODEL));
for needle in [
".decimal()",
".boolean()",
".uuid()",
".json()",
".big_integer()",
".double()",
] {
assert!(sql.contains(needle), "missing `{needle}`:\n{sql}");
}
}
#[test]
fn empty_changes_is_empty() {
assert!(empty_changes().is_empty());
}
#[test]
fn new_table_is_not_empty() {
let c = Changes {
is_new_table: true,
..empty_changes()
};
assert!(!c.is_empty(), "a new table is a change");
}
#[test]
fn a_lone_rename_is_not_empty() {
let c = Changes {
renamed_columns: vec![("a".into(), "b".into())],
..empty_changes()
};
assert!(!c.is_empty(), "a rename is a change");
}
#[test]
fn multiple_column_renames_all_emitted() {
let changes = Changes {
renamed_columns: vec![
("first".into(), "given_name".into()),
("last".into(), "family_name".into()),
],
..empty_changes()
};
let sql = generate_alter_file(&changes);
assert!(
sql.contains(r#".rename_column(Alias::new("first"), Alias::new("given_name"))"#),
"{sql}"
);
assert!(
sql.contains(r#".rename_column(Alias::new("last"), Alias::new("family_name"))"#),
"{sql}"
);
}
#[test]
fn fk_round_trip_is_stable() {
let schema = ParsedSchema {
table_name: "comment".into(),
primary_key: Some(ParsedColumn {
name: "id".into(),
col_type: "Integer".into(),
..Default::default()
}),
columns: vec![ParsedColumn {
name: "post_id".into(),
col_type: "Integer".into(),
..Default::default()
}],
foreign_keys: vec![ParsedFk {
from_column: "post_id".into(),
to_table: "post".into(),
to_column: "id".into(),
on_delete: "Cascade".into(),
on_update: "NoAction".into(),
}],
indexes: vec![],
};
let snapshot = generate_snapshot_file(&schema);
let reparsed = parse_seaorm_source(&snapshot).expect("snapshot should reparse");
let changes = diff_schemas(&reparsed, &schema);
assert!(
changes.added_fks.is_empty() && changes.dropped_fks.is_empty(),
"FK round-trip not stable: {changes:?}"
);
}
#[test]
fn fk_is_inlined_in_create_table_on_every_engine() {
let schema = ParsedSchema {
table_name: "comment".into(),
primary_key: Some(ParsedColumn {
name: "id".into(),
col_type: "Integer".into(),
..Default::default()
}),
columns: vec![ParsedColumn {
name: "post_id".into(),
col_type: "Integer".into(),
..Default::default()
}],
foreign_keys: vec![ParsedFk {
from_column: "post_id".into(),
to_table: "post".into(),
to_column: "id".into(),
on_delete: "Cascade".into(),
on_update: "NoAction".into(),
}],
indexes: vec![],
};
let sqlite = generate_create_file(&schema);
assert!(
sqlite.contains(".foreign_key("),
"SQLite must inline FK in CREATE:\n{sqlite}"
);
assert!(
sqlite.contains(r#".from(Alias::new("comment"), Alias::new("post_id"))"#),
"inline FK from clause:\n{sqlite}"
);
assert!(
sqlite.contains(".on_delete(ForeignKeyAction::Cascade)"),
"inline FK action:\n{sqlite}"
);
let pg = generate_create_file(&schema);
assert!(
pg.contains(".foreign_key("),
"PG must also inline FK in CREATE:\n{pg}"
);
let snap = generate_snapshot_file(&schema);
assert!(
!snap.contains(".foreign_key("),
"snapshot must not inline FK:\n{snap}"
);
}
#[test]
fn identical_schemas_produce_no_changes() {
let changes = diff_schemas(&parse_model(TYPES_MODEL), &parse_model(TYPES_MODEL));
assert!(
changes.is_empty(),
"identical schemas must diff to empty: {changes:?}"
);
}
const IGNORED_COL: &str = r#"
use runique::prelude::*;
model! {
Sec,
table: "sec",
pk: id => Pk,
{
visible: text [nullable],
note: text [nullable, readonly],
}
}
"#;
#[test]
fn readonly_column_is_excluded_from_migration() {
let schema = parse_model(IGNORED_COL);
assert!(
col(&schema, "note").ignored,
"readonly column must be flagged ignored"
);
let sql = generate_create_file(&schema);
assert!(
sql.contains(r#"Alias::new("visible")"#),
"visible column expected:\n{sql}"
);
assert!(
!sql.contains(r#"Alias::new("note")"#),
"ignored column must not appear in the migration:\n{sql}"
);
}
const DT_TYPES: &str = r#"
use runique::prelude::*;
model! {
Sched,
table: "sched",
pk: id => Pk,
{
d: date [nullable],
t: time [nullable],
tz: timestamp_tz [nullable],
}
}
"#;
#[test]
fn date_time_types_map_to_methods() {
let sql = generate_create_file(&parse_model(DT_TYPES));
assert!(sql.contains(r#"Alias::new("d")).date()"#), "date:\n{sql}");
assert!(sql.contains(r#"Alias::new("t")).time()"#), "time:\n{sql}");
assert!(
sql.contains(r#"Alias::new("tz")).timestamp_with_time_zone()"#),
"timestamptz:\n{sql}"
);
}
#[test]
fn alter_add_unique_via_modify_column() {
let old = ParsedColumn {
name: "email".into(),
col_type: "String".into(),
nullable: false,
unique: false,
..Default::default()
};
let new = ParsedColumn {
name: "email".into(),
col_type: "String".into(),
nullable: false,
unique: true,
..Default::default()
};
let changes = Changes {
modified_columns: vec![(old, new)],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let (up, down) = up_down(&sql);
assert!(
up.contains(r#".modify_column(ColumnDef::new(Alias::new("email")).string().not_null().unique_key())"#),
"up:\n{up}"
);
assert!(
down.contains(r#".modify_column(ColumnDef::new(Alias::new("email")).string().not_null())"#),
"down must drop the unique:\n{down}"
);
}
#[test]
fn alter_multiple_added_columns_all_present() {
let changes = Changes {
added_columns: vec![
ParsedColumn {
name: "a".into(),
col_type: "Integer".into(),
nullable: true,
..Default::default()
},
ParsedColumn {
name: "b".into(),
col_type: "String".into(),
nullable: true,
..Default::default()
},
],
..empty_changes()
};
let sql = generate_alter_file(&changes);
assert!(
sql.contains(r#".add_column(ColumnDef::new(Alias::new("a")).integer().null())"#),
"{sql}"
);
assert!(
sql.contains(r#".add_column(ColumnDef::new(Alias::new("b")).string().null())"#),
"{sql}"
);
}
#[test]
fn enum_value_drop_warns_up_and_readds_down_on_postgres() {
let changes = Changes {
enum_value_drops: vec![("status".into(), "Status".into(), "Legacy".into())],
..empty_changes()
};
let sql = generate_alter_file(&changes);
let (up, down) = up_down(&sql);
assert!(
up.contains("WARNING: value 'Legacy' removed"),
"up warning:\n{up}"
);
assert!(
down.contains("get_database_backend() == sea_orm::DbBackend::Postgres"),
"down must check the real backend at runtime:\n{down}"
);
assert!(
down.contains(".name(Alias::new(\"status\"))")
&& down.contains(".add_value(Alias::new(\"Legacy\"))")
&& down.contains(".if_not_exists()"),
"down must re-add the value on Postgres via the Type::alter() builder, with the \
type name lowercased to match the unquoted CREATE TYPE's case-folded identity:\n{down}"
);
}
const ENUM_DEFAULT_MODEL: &str = r#"
use runique::prelude::*;
model! {
Article,
table: "article",
pk: id => Pk,
enums: {
Status: [Draft="Draft", Published="Published", Archived="Archived"],
},
{
status: choice [enum(Status), default: "Draft", required],
}
}
"#;
#[test]
fn enum_default_reaches_parsed_column() {
let schema = parse_model(ENUM_DEFAULT_MODEL);
assert_eq!(
col(&schema, "status").default_value.as_deref(),
Some("\"Draft\""),
"model! must capture `default` alongside `enum(...)`"
);
}
#[test]
fn enum_column_default_is_emitted_in_create_on_all_engines() {
let schema = parse_model(ENUM_DEFAULT_MODEL);
let sql = generate_create_file(&schema);
assert!(
sql.contains("ColumnType::Enum"),
"enum coldef missing:\n{sql}"
);
assert!(
sql.contains(r#".not_null().default("Draft")"#),
"enum default dropped:\n{sql}"
);
}
const EXTEND_ENUM_DEFAULT_SRC: &str = r#"
use runique::prelude::*;
extend! {
table: "blog",
enums: {
Status: [Draft="Draft", Published="Published", Archived="Archived"],
},
fields: {
status: choice [enum(Status), default: "Draft", required],
}
}
"#;
#[test]
fn extend_enum_column_default_is_emitted_on_add_all_engines() {
let schema = &parse_extend_blocks_from_source(EXTEND_ENUM_DEFAULT_SRC).unwrap()[0];
let change = Changes {
table_name: schema.table_name.clone(),
added_columns: schema.columns.clone(),
dropped_columns: vec![],
modified_columns: vec![],
added_fks: vec![],
dropped_fks: vec![],
added_indexes: vec![],
dropped_indexes: vec![],
is_new_table: false,
renamed_columns: vec![],
enum_renames: vec![],
enum_value_adds: vec![],
enum_value_drops: vec![],
};
let sql = generate_alter_file(&change);
assert!(
sql.contains(r#".not_null().default("Draft")"#),
"extend enum default missing on ADD COLUMN:\n{sql}"
);
assert!(
sql.contains("get_database_backend() == sea_orm::DbBackend::Postgres"),
"CREATE TYPE must be runtime-guarded to Postgres:\n{sql}"
);
assert!(
sql.contains("Type::create()"),
"must still create the enum type:\n{sql}"
);
}
fn new_table(name: &str, fks: Vec<ParsedFk>) -> Changes {
Changes {
table_name: name.into(),
is_new_table: true,
added_fks: fks,
..empty_changes()
}
}
#[test]
fn topological_sort_creates_referenced_table_first() {
let sorted = topological_sort_changes(vec![
new_table("comment", vec![fk("post_id", "post")]),
new_table("post", vec![]),
]);
let order: Vec<&str> = sorted.iter().map(|c| c.table_name.as_str()).collect();
let post = order.iter().position(|t| *t == "post").unwrap();
let comment = order.iter().position(|t| *t == "comment").unwrap();
assert!(
post < comment,
"referenced table must come first: {order:?}"
);
}
#[test]
fn topological_sort_orders_a_fk_chain() {
let sorted = topological_sort_changes(vec![
new_table("a", vec![fk("b_id", "b")]),
new_table("b", vec![fk("c_id", "c")]),
new_table("c", vec![]),
]);
let order: Vec<&str> = sorted.iter().map(|c| c.table_name.as_str()).collect();
let pos = |t: &str| order.iter().position(|x| *x == t).unwrap();
assert!(
pos("c") < pos("b") && pos("b") < pos("a"),
"chain order wrong: {order:?}"
);
}
#[test]
fn topological_sort_ignores_fk_to_existing_table() {
let sorted = topological_sort_changes(vec![new_table("comment", vec![fk("user_id", "users")])]);
assert_eq!(sorted.len(), 1);
assert_eq!(sorted[0].table_name, "comment");
}
fn titled(len: u32) -> ParsedSchema {
parse_model(&format!(
r#"model! {{ Post, table: "post", pk: id => i32, {{ title: text [required, max_length: {len}] }} }}"#
))
}
#[test]
fn declared_length_reaches_the_create_file() {
assert_eq!(col(&titled(80), "title").max_length, Some(80));
let sql = generate_create_file(&titled(80));
assert!(sql.contains(".string_len(80)"), "{sql}");
}
#[test]
fn email_and_binary_columns_get_their_length() {
let schema = parse_model(
r#"model! { T, table: "t", pk: id => i32, { mail: email [required], raw: binary [required], bytes: var_binary [required, max_length: 16] } }"#,
);
let sql = generate_create_file(&schema);
assert!(sql.contains(".string_len(254)"), "{sql}");
assert!(sql.contains(".binary_len(255)"), "{sql}");
assert!(sql.contains(".var_binary(16)"), "{sql}");
}
#[test]
fn snapshot_round_trips_the_length() {
let schema = titled(80);
let reparsed = parse_seaorm_source(&generate_snapshot_file(&schema)).unwrap();
assert_eq!(col(&reparsed, "title").max_length, Some(80));
assert!(diff_schemas(&reparsed, &schema).is_empty());
}
#[test]
fn growing_a_length_is_a_plain_alter() {
let changes = diff_schemas(&titled(80), &titled(120));
assert_eq!(changes.modified_columns.len(), 1);
let alter = generate_alter_file(&changes);
assert!(alter.contains(".string_len(120)"), "{alter}");
assert!(collect_destructive_messages(&[changes]).is_empty());
}
#[test]
fn shrinking_a_length_needs_force() {
let changes = diff_schemas(&titled(120), &titled(80));
let messages = collect_destructive_messages(&[changes]);
assert!(
messages.iter().any(|m| m.contains("length 120 -> 80")),
"{messages:?}"
);
}
#[test]
fn a_snapshot_from_before_lengths_takes_the_model_lengths() {
let current = titled(80);
let old = generate_snapshot_file(&parse_model(
r#"model! { Post, table: "post", pk: id => i32, { title: text [required] } }"#,
));
let old = old
.strip_prefix(crate::migration::utils::generators::SNAPSHOT_LENGTHS_MARKER)
.unwrap()
.to_string();
let dir = std::env::temp_dir().join(format!("runique_len_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("post.rs");
std::fs::write(&path, old).unwrap();
let previous =
crate::cli::makemigration::previous_snapshot(path.to_str().unwrap(), ¤t).unwrap();
std::fs::remove_dir_all(&dir).ok();
assert!(diff_schemas(&previous, ¤t).is_empty());
}