use crate::Columns;
use queryweave::{PersistedFields, ValueKind, ValueType};
use sea_query::ColumnType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnTypeMismatch {
pub entity: &'static str,
pub field: &'static str,
pub column: &'static str,
pub reason: MismatchReason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MismatchReason {
Kind {
value: ValueKind,
accepts: &'static [ValueKind],
column_type: String,
},
Nullable,
MissingColumn,
}
impl std::fmt::Display for ColumnTypeMismatch {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
entity,
field,
column,
reason,
} = self;
match reason {
MismatchReason::Kind {
value,
accepts,
column_type,
} => {
write!(
formatter,
"the value of domain field `{field}` (`{}`) does not fit the column `{column}` of `{entity}`, which ",
value.rust_type()
)?;
if accepts.is_empty() {
write!(formatter, "takes no domain value (`{column_type}`)")?;
} else {
let accepts = accepts
.iter()
.map(|kind| format!("`{}`", kind.rust_type()))
.collect::<Vec<_>>()
.join(" or ");
write!(formatter, "takes {accepts}")?;
}
write!(
formatter,
": make the field's type and the column's type in the entity agree"
)
},
MismatchReason::Nullable => write!(
formatter,
"domain field `{field}` may be `None`, but the column `{column}` of `{entity}` is `NOT NULL`: make the column nullable in the entity, or the field not optional"
),
MismatchReason::MissingColumn => write!(
formatter,
"the column `{column}` of `{entity}` (storing domain field `{field}`) is not among the columns given"
),
}
}
}
impl std::error::Error for ColumnTypeMismatch {}
const INTEGERS: &[ValueKind] = &[ValueKind::I16, ValueKind::I32, ValueKind::I64];
const NUMBERS: &[ValueKind] = &[
ValueKind::I16,
ValueKind::I32,
ValueKind::I64,
ValueKind::F32,
ValueKind::F64,
];
const FLOATS: &[ValueKind] = &[ValueKind::F32, ValueKind::F64];
#[must_use]
pub const fn accepted_kinds(column_type: &ColumnType) -> Option<&'static [ValueKind]> {
Some(match column_type {
ColumnType::Boolean => &[ValueKind::Bool],
ColumnType::TinyInteger
| ColumnType::SmallInteger
| ColumnType::Integer
| ColumnType::BigInteger
| ColumnType::TinyUnsigned
| ColumnType::SmallUnsigned
| ColumnType::Unsigned
| ColumnType::BigUnsigned
| ColumnType::Year => INTEGERS,
ColumnType::Float | ColumnType::Double => FLOATS,
ColumnType::Decimal(_) | ColumnType::Money(_) => NUMBERS,
ColumnType::Char(_) | ColumnType::String(_) | ColumnType::Text => &[ValueKind::Text],
ColumnType::TimestampWithTimeZone => &[ValueKind::DateTimeUtc],
ColumnType::DateTime | ColumnType::Timestamp => &[ValueKind::NaiveDateTime],
ColumnType::Date => &[ValueKind::NaiveDate],
ColumnType::Time => &[ValueKind::NaiveTime],
ColumnType::Custom(_) => return None,
_ => &[],
})
}
pub fn check_column_types<'a, C, M>(
columns: impl IntoIterator<Item = (&'a str, ColumnType, bool)>,
) -> Result<(), ColumnTypeMismatch>
where
C: Columns + ?Sized,
M: PersistedFields,
{
if M::FIELD_TYPES.len() != M::FIELD_NAMES.len() {
return Ok(());
}
let columns: Vec<_> = columns.into_iter().collect();
for (&field, value) in M::FIELD_NAMES.iter().zip(M::FIELD_TYPES) {
let Some(column) = C::column(field) else {
continue;
};
let mismatch = |reason| ColumnTypeMismatch {
entity: C::NAME,
field,
column,
reason,
};
let Some((_, column_type, nullable)) = columns.iter().find(|(name, _, _)| *name == column)
else {
return Err(mismatch(MismatchReason::MissingColumn));
};
if let Some(reason) = value_mismatch(*value, column_type, *nullable) {
return Err(mismatch(reason));
}
}
Ok(())
}
fn value_mismatch(
value: ValueType,
column_type: &ColumnType,
nullable: bool,
) -> Option<MismatchReason> {
let accepts = accepted_kinds(column_type)?;
if !accepts.contains(&value.kind) {
return Some(MismatchReason::Kind {
value: value.kind,
accepts,
column_type: format!("{column_type:?}"),
});
}
if value.nullable && !nullable {
return Some(MismatchReason::Nullable);
}
None
}
#[cfg(test)]
mod tests {
use super::{ColumnTypeMismatch, MismatchReason, accepted_kinds, check_column_types};
use crate::Columns;
use pretty_assertions::assert_eq;
use queryweave::{PersistedFields, ValueKind, ValueType};
use rstest::rstest;
use sea_query::{ColumnType, StringLen};
struct Order;
impl Columns for Order {
const NAME: &'static str = "Model";
const COLUMNS: &'static [(&'static str, &'static str)] =
&[("quantity", "quantity"), ("delivery_date", "delivered_at")];
}
struct Changes;
impl PersistedFields for Changes {
const FIELD_NAMES: &'static [&'static str] = &["quantity", "delivery_date"];
const FIELD_TYPES: &'static [ValueType] = &[
ValueType {
kind: ValueKind::I16,
nullable: false,
},
ValueType {
kind: ValueKind::DateTimeUtc,
nullable: true,
},
];
}
struct HandWritten;
impl PersistedFields for HandWritten {
const FIELD_NAMES: &'static [&'static str] = &["quantity"];
}
fn check<M: PersistedFields>(
quantity: ColumnType,
delivered_at_nullable: bool,
) -> Result<(), ColumnTypeMismatch> {
check_column_types::<Order, M>([
("id", ColumnType::Uuid, false),
("quantity", quantity, false),
(
"delivered_at",
ColumnType::TimestampWithTimeZone,
delivered_at_nullable,
),
])
}
#[test]
fn values_of_a_type_the_columns_take_fit() {
assert_eq!(check::<Changes>(ColumnType::Integer, true), Ok(()));
assert_eq!(check::<Changes>(ColumnType::TinyUnsigned, true), Ok(()));
}
#[test]
fn a_value_of_another_type_names_the_field_column_and_types() {
let mismatch = check::<Changes>(ColumnType::String(StringLen::None), true).unwrap_err();
assert_eq!(
mismatch.reason,
MismatchReason::Kind {
value: ValueKind::I16,
accepts: &[ValueKind::Text],
column_type: String::from("String(None)"),
}
);
assert_eq!(
mismatch.to_string(),
"the value of domain field `quantity` (`i16`) does not fit the column `quantity` of `Model`, which takes `String`: make the field's type and the column's type in the entity agree"
);
}
#[test]
fn a_column_taking_no_domain_value_says_its_type() {
let mismatch = check::<Changes>(ColumnType::Json, true).unwrap_err();
assert_eq!(
mismatch.to_string(),
"the value of domain field `quantity` (`i16`) does not fit the column `quantity` of `Model`, which takes no domain value (`Json`): make the field's type and the column's type in the entity agree"
);
}
#[test]
fn a_nullable_value_needs_a_nullable_column() {
let mismatch = check::<Changes>(ColumnType::Integer, false).unwrap_err();
assert_eq!(mismatch.reason, MismatchReason::Nullable);
assert_eq!(
mismatch.to_string(),
"domain field `delivery_date` may be `None`, but the column `delivered_at` of `Model` is `NOT NULL`: make the column nullable in the entity, or the field not optional"
);
}
#[test]
fn a_column_not_given_is_reported() {
let mismatch =
check_column_types::<Order, Changes>([("quantity", ColumnType::Integer, false)])
.unwrap_err();
assert_eq!(
mismatch.to_string(),
"the column `delivered_at` of `Model` (storing domain field `delivery_date`) is not among the columns given"
);
}
#[test]
fn a_model_without_value_types_is_not_checked() {
assert_eq!(check::<HandWritten>(ColumnType::Json, false), Ok(()));
}
#[rstest]
#[case(ColumnType::Boolean, ValueKind::Bool, true)]
#[case(ColumnType::BigInteger, ValueKind::I16, true)]
#[case(ColumnType::Integer, ValueKind::F64, false)]
#[case(ColumnType::Double, ValueKind::F32, true)]
#[case(ColumnType::Decimal(None), ValueKind::I64, true)]
#[case(ColumnType::Text, ValueKind::Text, true)]
#[case(ColumnType::Char(None), ValueKind::I32, false)]
#[case(ColumnType::TimestampWithTimeZone, ValueKind::DateTimeUtc, true)]
#[case(ColumnType::TimestampWithTimeZone, ValueKind::NaiveDateTime, false)]
#[case(ColumnType::DateTime, ValueKind::NaiveDateTime, true)]
#[case(ColumnType::DateTime, ValueKind::DateTimeUtc, false)]
#[case(ColumnType::Timestamp, ValueKind::DateTimeUtc, false)]
#[case(ColumnType::Date, ValueKind::NaiveDate, true)]
#[case(ColumnType::Time, ValueKind::NaiveTime, true)]
#[case(ColumnType::Uuid, ValueKind::Text, false)]
fn each_column_type_takes_its_values(
#[case] column_type: ColumnType,
#[case] kind: ValueKind,
#[case] takes: bool,
) {
assert_eq!(
accepted_kinds(&column_type).is_some_and(|accepts| accepts.contains(&kind)),
takes
);
}
#[test]
fn a_custom_type_is_not_checked() {
assert_eq!(
accepted_kinds(&ColumnType::Custom(sea_query::Alias::new("citext").into())),
None
);
}
}