use super::super::helpers::{
apply_column_type_with_table, build_create_enum_type_sql, convert_default_for_backend,
is_enum_type, needs_quoting, parse_pg_type_cast, recreate_indexes_after_rebuild,
to_sea_fk_action,
};
fn reference_action_sql(action: &ReferenceAction) -> &'static str {
match action {
ReferenceAction::Cascade => "CASCADE",
ReferenceAction::Restrict => "RESTRICT",
ReferenceAction::SetNull => "SET NULL",
ReferenceAction::SetDefault => "SET DEFAULT",
ReferenceAction::NoAction => "NO ACTION",
_ => unreachable!("ReferenceAction is #[non_exhaustive]; all variants are matched above"),
}
}
fn get_enum_name(column_type: &ColumnType) -> Option<&str> {
if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = column_type {
Some(name.as_str())
} else {
None
}
}
use super::*;
use proptest::prelude::*;
use sea_query::{Alias, ColumnDef as SeaColumnDef, ForeignKeyAction};
use vespertide_core::{ComplexColumnType, EnumValues, NumValue};
#[rstest]
#[case(ColumnType::Simple(SimpleColumnType::Integer))]
#[case(ColumnType::Simple(SimpleColumnType::BigInt))]
#[case(ColumnType::Simple(SimpleColumnType::Text))]
#[case(ColumnType::Simple(SimpleColumnType::Boolean))]
#[case(ColumnType::Simple(SimpleColumnType::Timestamp))]
#[case(ColumnType::Simple(SimpleColumnType::Uuid))]
#[case(ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }))]
#[case(ColumnType::Complex(ComplexColumnType::Numeric { precision: 10, scale: 2 }))]
fn test_column_type_conversion(
#[case] ty: ColumnType,
#[values(
DatabaseBackend::Postgres,
DatabaseBackend::MySql,
DatabaseBackend::Sqlite
)]
backend: DatabaseBackend,
) {
let mut col = SeaColumnDef::new(Alias::new("test"));
apply_column_type_with_table(&mut col, &ty, "test_table", backend);
}
#[rstest]
#[case(SimpleColumnType::SmallInt)]
#[case(SimpleColumnType::Integer)]
#[case(SimpleColumnType::BigInt)]
#[case(SimpleColumnType::Real)]
#[case(SimpleColumnType::DoublePrecision)]
#[case(SimpleColumnType::Text)]
#[case(SimpleColumnType::Boolean)]
#[case(SimpleColumnType::Date)]
#[case(SimpleColumnType::Time)]
#[case(SimpleColumnType::Timestamp)]
#[case(SimpleColumnType::Timestamptz)]
#[case(SimpleColumnType::Interval)]
#[case(SimpleColumnType::Bytea)]
#[case(SimpleColumnType::Uuid)]
#[case(SimpleColumnType::Json)]
#[case(SimpleColumnType::Inet)]
#[case(SimpleColumnType::Cidr)]
#[case(SimpleColumnType::Macaddr)]
#[case(SimpleColumnType::Xml)]
fn test_all_simple_types_cover_branches(
#[case] ty: SimpleColumnType,
#[values(
DatabaseBackend::Postgres,
DatabaseBackend::MySql,
DatabaseBackend::Sqlite
)]
backend: DatabaseBackend,
) {
let mut col = SeaColumnDef::new(Alias::new("t"));
apply_column_type_with_table(&mut col, &ColumnType::Simple(ty), "test_table", backend);
}
#[rstest]
#[case(ComplexColumnType::Varchar { length: 42 })]
#[case(ComplexColumnType::Numeric { precision: 8, scale: 3 })]
#[case(ComplexColumnType::Numeric { precision: 40, scale: 35 })]
#[case(ComplexColumnType::Char { length: 3 })]
#[case(ComplexColumnType::Custom { custom_type: "GEOGRAPHY".into() })]
#[case(ComplexColumnType::Enum { name: "status".into(), values: EnumValues::String(vec!["active".into(), "inactive".into()]) })]
#[case(ComplexColumnType::Enum { name: "level".into(), values: EnumValues::Integer(vec![NumValue { name: "low".into(), value: 0 }]) })]
fn test_all_complex_types_cover_branches(
#[case] ty: ComplexColumnType,
#[values(
DatabaseBackend::Postgres,
DatabaseBackend::MySql,
DatabaseBackend::Sqlite
)]
backend: DatabaseBackend,
) {
let mut col = SeaColumnDef::new(Alias::new("t"));
apply_column_type_with_table(&mut col, &ColumnType::Complex(ty), "test_table", backend);
}
#[rstest]
#[case::cascade(ReferenceAction::Cascade, ForeignKeyAction::Cascade)]
#[case::restrict(ReferenceAction::Restrict, ForeignKeyAction::Restrict)]
#[case::set_null(ReferenceAction::SetNull, ForeignKeyAction::SetNull)]
#[case::set_default(ReferenceAction::SetDefault, ForeignKeyAction::SetDefault)]
#[case::no_action(ReferenceAction::NoAction, ForeignKeyAction::NoAction)]
fn test_reference_action_conversion(
#[case] action: ReferenceAction,
#[case] expected: ForeignKeyAction,
) {
let result = to_sea_fk_action(&action);
assert!(
matches!(result, _expected),
"Expected {expected:?}, got {result:?}"
);
}
#[rstest]
#[case(ReferenceAction::Cascade, "CASCADE")]
#[case(ReferenceAction::Restrict, "RESTRICT")]
#[case(ReferenceAction::SetNull, "SET NULL")]
#[case(ReferenceAction::SetDefault, "SET DEFAULT")]
#[case(ReferenceAction::NoAction, "NO ACTION")]
fn test_reference_action_sql_all_variants(#[case] action: ReferenceAction, #[case] expected: &str) {
assert_eq!(reference_action_sql(&action), expected);
}
#[rstest]
#[case::gen_random_uuid_postgres(
"gen_random_uuid()",
DatabaseBackend::Postgres,
"gen_random_uuid()"
)]
#[case::gen_random_uuid_mysql("gen_random_uuid()", DatabaseBackend::MySql, "(UUID())")]
#[case::gen_random_uuid_sqlite(
"gen_random_uuid()",
DatabaseBackend::Sqlite,
"lower(hex(randomblob(16)))"
)]
#[case::current_timestamp_postgres(
"current_timestamp()",
DatabaseBackend::Postgres,
"CURRENT_TIMESTAMP"
)]
#[case::current_timestamp_mysql("current_timestamp()", DatabaseBackend::MySql, "CURRENT_TIMESTAMP")]
#[case::current_timestamp_sqlite(
"current_timestamp()",
DatabaseBackend::Sqlite,
"CURRENT_TIMESTAMP"
)]
#[case::sqlite_uuid_spelling_postgres(
"lower(hex(randomblob(16)))",
DatabaseBackend::Postgres,
"gen_random_uuid()"
)]
#[case::sqlite_uuid_spelling_sqlite(
"lower(hex(randomblob(16)))",
DatabaseBackend::Sqlite,
"lower(hex(randomblob(16)))"
)]
#[case::getdate_postgres("getdate()", DatabaseBackend::Postgres, "CURRENT_TIMESTAMP")]
#[case::getdate_mysql("GetDate()", DatabaseBackend::MySql, "CURRENT_TIMESTAMP")]
#[case::now_postgres("now()", DatabaseBackend::Postgres, "CURRENT_TIMESTAMP")]
#[case::now_mysql("now()", DatabaseBackend::MySql, "CURRENT_TIMESTAMP")]
#[case::now_sqlite("now()", DatabaseBackend::Sqlite, "CURRENT_TIMESTAMP")]
#[case::now_upper_postgres("NOW()", DatabaseBackend::Postgres, "CURRENT_TIMESTAMP")]
#[case::now_upper_mysql("NOW()", DatabaseBackend::MySql, "CURRENT_TIMESTAMP")]
#[case::now_upper_sqlite("NOW()", DatabaseBackend::Sqlite, "CURRENT_TIMESTAMP")]
#[case::current_timestamp_upper_postgres(
"CURRENT_TIMESTAMP",
DatabaseBackend::Postgres,
"CURRENT_TIMESTAMP"
)]
#[case::current_timestamp_upper_mysql(
"CURRENT_TIMESTAMP",
DatabaseBackend::MySql,
"CURRENT_TIMESTAMP"
)]
#[case::current_timestamp_upper_sqlite(
"CURRENT_TIMESTAMP",
DatabaseBackend::Sqlite,
"CURRENT_TIMESTAMP"
)]
fn test_convert_default_for_backend(
#[case] default: &str,
#[case] backend: DatabaseBackend,
#[case] expected: &str,
) {
let result = convert_default_for_backend(default, backend);
assert_eq!(result, expected);
}
#[rstest]
#[case::json_cast_postgres("'[]'::json", DatabaseBackend::Postgres, "'[]'::json")]
#[case::json_cast_mysql("'[]'::json", DatabaseBackend::MySql, "CAST('[]' AS JSON)")]
#[case::json_cast_sqlite("'[]'::json", DatabaseBackend::Sqlite, "'[]'")]
#[case::jsonb_cast_postgres("'{}'::jsonb", DatabaseBackend::Postgres, "'{}'::jsonb")]
#[case::jsonb_cast_mysql("'{}'::jsonb", DatabaseBackend::MySql, "CAST('{}' AS JSON)")]
#[case::jsonb_cast_sqlite("'{}'::jsonb", DatabaseBackend::Sqlite, "'{}'")]
#[case::text_cast_postgres("'hello'::text", DatabaseBackend::Postgres, "'hello'::text")]
#[case::text_cast_mysql("'hello'::text", DatabaseBackend::MySql, "CAST('hello' AS CHAR)")]
#[case::text_cast_sqlite("'hello'::text", DatabaseBackend::Sqlite, "'hello'")]
#[case::int_cast_postgres("0::integer", DatabaseBackend::Postgres, "0::integer")]
#[case::int_cast_mysql("0::integer", DatabaseBackend::MySql, "CAST(0 AS SIGNED)")]
#[case::int_cast_sqlite("0::integer", DatabaseBackend::Sqlite, "0")]
#[case::bool_cast_postgres("0::boolean", DatabaseBackend::Postgres, "0::boolean")]
#[case::bool_cast_mysql("0::boolean", DatabaseBackend::MySql, "CAST(0 AS UNSIGNED)")]
#[case::bool_cast_sqlite("0::boolean", DatabaseBackend::Sqlite, "0")]
#[case::json_obj_cast_postgres(
"'{\"key\":\"value\"}'::json",
DatabaseBackend::Postgres,
"'{\"key\":\"value\"}'::json"
)]
#[case::json_obj_cast_mysql(
"'{\"key\":\"value\"}'::json",
DatabaseBackend::MySql,
"CAST('{\"key\":\"value\"}' AS JSON)"
)]
#[case::json_obj_cast_sqlite(
"'{\"key\":\"value\"}'::json",
DatabaseBackend::Sqlite,
"'{\"key\":\"value\"}'"
)]
#[case::timestamp_cast_postgres(
"'2024-01-01'::timestamp",
DatabaseBackend::Postgres,
"'2024-01-01'::timestamp"
)]
#[case::timestamp_cast_mysql(
"'2024-01-01'::timestamp",
DatabaseBackend::MySql,
"CAST('2024-01-01' AS DATETIME)"
)]
#[case::timestamp_cast_sqlite("'2024-01-01'::timestamp", DatabaseBackend::Sqlite, "'2024-01-01'")]
fn test_convert_default_for_backend_type_cast(
#[case] default: &str,
#[case] backend: DatabaseBackend,
#[case] expected: &str,
) {
let result = convert_default_for_backend(default, backend);
assert_eq!(result, expected);
}
#[test]
fn test_parse_pg_type_cast_no_cast() {
assert!(parse_pg_type_cast("'hello'").is_none());
assert!(parse_pg_type_cast("42").is_none());
assert!(parse_pg_type_cast("NOW()").is_none());
assert!(parse_pg_type_cast("CURRENT_TIMESTAMP").is_none());
assert!(parse_pg_type_cast("::json").is_none());
assert!(parse_pg_type_cast("0::").is_none());
}
#[test]
fn test_parse_pg_type_cast_valid() {
let (value, cast_type) = parse_pg_type_cast("'[]'::json").unwrap();
assert_eq!(value, "'[]'");
assert_eq!(cast_type, "json");
let (value, cast_type) = parse_pg_type_cast("0::boolean").unwrap();
assert_eq!(value, "0");
assert_eq!(cast_type, "boolean");
}
#[test]
fn test_parse_pg_type_cast_escaped_quotes() {
let (value, cast_type) = parse_pg_type_cast("'it''s'::text").unwrap();
assert_eq!(value, "'it''s'");
assert_eq!(cast_type, "text");
}
#[test]
fn test_parse_pg_type_cast_unicode_quoted_value() {
let (value, cast_type) = parse_pg_type_cast("'한êµì–´ 日本語 café 📊'::text").unwrap();
assert_eq!(value, "'한êµì–´ 日本語 café 📊'");
assert_eq!(cast_type, "text");
}
#[test]
fn test_parse_pg_type_cast_unicode_unquoted_value() {
let (value, cast_type) = parse_pg_type_cast("📊_stats::json").unwrap();
assert_eq!(value, "📊_stats");
assert_eq!(cast_type, "json");
}
proptest! {
#[test]
fn parse_pg_type_cast_does_not_panic_on_unicode(s in ".{0,100}") {
let _ = parse_pg_type_cast(&s);
}
#[test]
fn parse_pg_type_cast_round_trips_quoted_unicode_without_quotes(
s in proptest::collection::vec(any::<char>().prop_filter("exclude SQL quote", |c| *c != '\''), 0..50)
.prop_map(|v| v.into_iter().collect::<String>())
) {
let expr = format!("'{s}'::text");
let (value, cast_type) = parse_pg_type_cast(&expr).unwrap();
prop_assert_eq!(value, format!("'{s}'"));
prop_assert_eq!(cast_type, "text");
}
}
#[test]
fn test_parse_pg_type_cast_unterminated_quote() {
assert!(parse_pg_type_cast("'unclosed").is_none());
assert!(parse_pg_type_cast("'no close quote::json").is_none());
}
#[rstest]
#[case::cast_operator_inside_literal(
"CASE WHEN plan_tag = 'legacy::v1' THEN 1 ELSE 2 END::integer",
"CASE WHEN plan_tag = 'legacy::v1' THEN 1 ELSE 2 END",
"integer"
)]
#[case::cast_chain("'x'::text::json", "'x'::text", "json")]
#[case::cast_nested_in_parens(
"(CASE WHEN plan_key::text = 'API' THEN 'A' ELSE 'B' END)::billing_metric",
"(CASE WHEN plan_key::text = 'API' THEN 'A' ELSE 'B' END)",
"billing_metric"
)]
#[case::escaped_quote_before_cast("'it''s::not'::text", "'it''s::not'", "text")]
fn test_parse_pg_type_cast_splits_at_last_top_level_operator(
#[case] expr: &str,
#[case] expected_value: &str,
#[case] expected_type: &str,
) {
let (value, cast_type) = parse_pg_type_cast(expr).expect("expression carries a trailing cast");
assert_eq!(value, expected_value);
assert_eq!(cast_type, expected_type);
}
#[rstest]
#[case::only_inside_literal("'legacy::v1'")]
#[case::only_inside_parens("(a::text)")]
#[case::unbalanced_open_paren("(a::text")]
fn test_parse_pg_type_cast_ignores_non_top_level_operators(#[case] expr: &str) {
assert!(
parse_pg_type_cast(expr).is_none(),
"no top-level cast in: {expr}"
);
}
#[rstest]
#[case::postgres(DatabaseBackend::Postgres, "'x'::text::json")]
#[case::mysql(DatabaseBackend::MySql, "CAST(CAST('x' AS CHAR) AS JSON)")]
#[case::sqlite(DatabaseBackend::Sqlite, "'x'")]
fn test_convert_default_for_backend_cast_chain(
#[case] backend: DatabaseBackend,
#[case] expected: &str,
) {
assert_eq!(
convert_default_for_backend("'x'::text::json", backend),
expected
);
}
#[rstest]
#[case::numeric("'0.5'::numeric", DatabaseBackend::MySql, "CAST('0.5' AS DECIMAL)")]
#[case::decimal("'1.23'::decimal", DatabaseBackend::MySql, "CAST('1.23' AS DECIMAL)")]
#[case::bytea("'\\xDE'::bytea", DatabaseBackend::MySql, "CAST('\\xDE' AS BINARY)")]
#[case::unknown("'x'::citext", DatabaseBackend::MySql, "CAST('x' AS CHAR)")]
fn test_convert_default_for_backend_type_cast_extra(
#[case] default: &str,
#[case] backend: DatabaseBackend,
#[case] expected: &str,
) {
let result = convert_default_for_backend(default, backend);
assert_eq!(result, expected);
}
#[test]
fn test_is_enum_type_true() {
use vespertide_core::EnumValues;
let enum_type = ColumnType::Complex(ComplexColumnType::Enum {
name: "status".into(),
values: EnumValues::String(vec!["active".into(), "inactive".into()]),
});
assert!(is_enum_type(&enum_type));
}
#[test]
fn test_is_enum_type_false() {
let text_type = ColumnType::Simple(SimpleColumnType::Text);
assert!(!is_enum_type(&text_type));
}
#[test]
fn test_get_enum_name_some() {
use vespertide_core::EnumValues;
let enum_type = ColumnType::Complex(ComplexColumnType::Enum {
name: "user_status".into(),
values: EnumValues::String(vec!["active".into(), "inactive".into()]),
});
assert_eq!(get_enum_name(&enum_type), Some("user_status"));
}
#[test]
fn test_get_enum_name_none() {
let text_type = ColumnType::Simple(SimpleColumnType::Text);
assert_eq!(get_enum_name(&text_type), None);
}
#[test]
fn test_apply_column_type_integer_enum() {
use vespertide_core::{EnumValues, NumValue};
let integer_enum = ColumnType::Complex(ComplexColumnType::Enum {
name: "color".into(),
values: EnumValues::Integer(vec![
NumValue {
name: "Black".into(),
value: 0,
},
NumValue {
name: "White".into(),
value: 1,
},
]),
});
let mut col = SeaColumnDef::new(Alias::new("color"));
apply_column_type_with_table(
&mut col,
&integer_enum,
"test_table",
DatabaseBackend::Postgres,
);
}
#[test]
fn test_build_create_enum_type_sql_integer_enum_returns_none() {
use vespertide_core::{EnumValues, NumValue};
let integer_enum = ColumnType::Complex(ComplexColumnType::Enum {
name: "priority".into(),
values: EnumValues::Integer(vec![
NumValue {
name: "Low".into(),
value: 0,
},
NumValue {
name: "High".into(),
value: 10,
},
]),
});
assert!(build_create_enum_type_sql("test_table", &integer_enum).is_none());
}
#[rstest]
#[case::empty("", true)]
#[case::whitespace_only(" ", true)]
#[case::now_func("now()", false)]
#[case::coalesce_func("COALESCE(old_value, 'default')", false)]
#[case::uuid_func("gen_random_uuid()", false)]
#[case::null_upper("NULL", false)]
#[case::null_lower("null", false)]
#[case::null_mixed("Null", false)]
#[case::current_timestamp_upper("CURRENT_TIMESTAMP", false)]
#[case::current_timestamp_lower("current_timestamp", false)]
#[case::current_date_upper("CURRENT_DATE", false)]
#[case::current_date_lower("current_date", false)]
#[case::current_time_upper("CURRENT_TIME", false)]
#[case::current_time_lower("current_time", false)]
#[case::single_quoted("'active'", false)]
#[case::double_quoted("\"active\"", false)]
#[case::plain_active("active", true)]
#[case::plain_pending("pending", true)]
#[case::plain_underscore("some_value", true)]
fn test_needs_quoting(#[case] input: &str, #[case] expected: bool) {
assert_eq!(needs_quoting(input), expected);
}
#[test]
fn test_recreate_indexes_after_rebuild_skips_pending() {
use vespertide_core::TableConstraint;
let idx1 = TableConstraint::Index {
name: Some("idx_a".into()),
columns: vec!["a".into()],
};
let idx2 = TableConstraint::Index {
name: Some("idx_b".into()),
columns: vec!["b".into()],
};
let uq1 = TableConstraint::Unique {
name: Some("uq_c".into()),
columns: vec!["c".into()],
strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
keep: vespertide_core::KeepPolicy::First,
},
};
let constraints = vec![idx1.clone(), idx2.clone(), uq1.clone()];
let pending = vec![idx1.clone(), uq1.clone()];
let queries = recreate_indexes_after_rebuild("t", &constraints, &pending);
assert_eq!(queries.len(), 1);
let sql = queries[0].build(DatabaseBackend::Sqlite);
assert!(sql.contains("idx_b"));
}
#[test]
fn test_recreate_indexes_after_rebuild_no_pending() {
use vespertide_core::TableConstraint;
let idx = TableConstraint::Index {
name: Some("idx_a".into()),
columns: vec!["a".into()],
};
let uq = TableConstraint::Unique {
name: Some("uq_b".into()),
columns: vec!["b".into()],
strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
keep: vespertide_core::KeepPolicy::First,
},
};
let queries = recreate_indexes_after_rebuild("t", &[idx, uq], &[]);
assert_eq!(queries.len(), 2);
}
#[test]
fn test_recreate_indexes_after_rebuild_skips_non_index_constraints() {
use vespertide_core::TableConstraint;
let pk = TableConstraint::PrimaryKey {
columns: vec!["id".into()],
auto_increment: false,
strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
};
let fk = TableConstraint::ForeignKey {
name: None,
columns: vec!["uid".into()],
ref_table: "u".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
};
let chk = TableConstraint::Check {
name: "chk".into(),
expr: "id > 0".into(),
strategy: vespertide_core::CheckViolationStrategy::default(),
};
let queries = recreate_indexes_after_rebuild("t", &[pk, fk, chk], &[]);
assert_eq!(queries.len(), 0);
}
#[test]
fn pg_quotes_simple_ident() {
assert_eq!(quote_ident("users", DatabaseBackend::Postgres), "\"users\"");
}
#[test]
fn pg_escapes_embedded_quote() {
assert_eq!(
quote_ident("foo\"bar", DatabaseBackend::Postgres),
"\"foo\"\"bar\""
);
}
#[test]
fn mysql_uses_backticks() {
assert_eq!(quote_ident("users", DatabaseBackend::MySql), "`users`");
}
#[test]
fn mysql_escapes_embedded_backtick() {
assert_eq!(quote_ident("foo`bar", DatabaseBackend::MySql), "`foo``bar`");
}
#[test]
fn sqlite_uses_double_quotes() {
assert_eq!(quote_ident("users", DatabaseBackend::Sqlite), "\"users\"");
}
#[test]
fn reserved_word_safe() {
assert_eq!(quote_ident("order", DatabaseBackend::Postgres), "\"order\"");
assert_eq!(quote_ident("select", DatabaseBackend::MySql), "`select`");
}