1use crate::Columns;
13use queryweave::{PersistedFields, ValueKind, ValueType};
14use sea_query::ColumnType;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ColumnTypeMismatch {
20 pub entity: &'static str,
22 pub field: &'static str,
24 pub column: &'static str,
26 pub reason: MismatchReason,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum MismatchReason {
33 Kind {
35 value: ValueKind,
37 accepts: &'static [ValueKind],
39 column_type: String,
41 },
42 Nullable,
44 MissingColumn,
46}
47
48impl std::fmt::Display for ColumnTypeMismatch {
49 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 let Self {
51 entity,
52 field,
53 column,
54 reason,
55 } = self;
56 match reason {
57 MismatchReason::Kind {
58 value,
59 accepts,
60 column_type,
61 } => {
62 write!(
63 formatter,
64 "the value of domain field `{field}` (`{}`) does not fit the column `{column}` of `{entity}`, which ",
65 value.rust_type()
66 )?;
67 if accepts.is_empty() {
68 write!(formatter, "takes no domain value (`{column_type}`)")?;
69 } else {
70 let accepts = accepts
71 .iter()
72 .map(|kind| format!("`{}`", kind.rust_type()))
73 .collect::<Vec<_>>()
74 .join(" or ");
75 write!(formatter, "takes {accepts}")?;
76 }
77 write!(
78 formatter,
79 ": make the field's type and the column's type in the entity agree"
80 )
81 },
82 MismatchReason::Nullable => write!(
83 formatter,
84 "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"
85 ),
86 MismatchReason::MissingColumn => write!(
87 formatter,
88 "the column `{column}` of `{entity}` (storing domain field `{field}`) is not among the columns given"
89 ),
90 }
91 }
92}
93
94impl std::error::Error for ColumnTypeMismatch {}
95
96const INTEGERS: &[ValueKind] = &[ValueKind::I16, ValueKind::I32, ValueKind::I64];
97const NUMBERS: &[ValueKind] = &[
98 ValueKind::I16,
99 ValueKind::I32,
100 ValueKind::I64,
101 ValueKind::F32,
102 ValueKind::F64,
103];
104const FLOATS: &[ValueKind] = &[ValueKind::F32, ValueKind::F64];
105
106#[must_use]
116pub const fn accepted_kinds(column_type: &ColumnType) -> Option<&'static [ValueKind]> {
117 Some(match column_type {
118 ColumnType::Boolean => &[ValueKind::Bool],
119 ColumnType::TinyInteger
120 | ColumnType::SmallInteger
121 | ColumnType::Integer
122 | ColumnType::BigInteger
123 | ColumnType::TinyUnsigned
124 | ColumnType::SmallUnsigned
125 | ColumnType::Unsigned
126 | ColumnType::BigUnsigned
127 | ColumnType::Year => INTEGERS,
128 ColumnType::Float | ColumnType::Double => FLOATS,
129 ColumnType::Decimal(_) | ColumnType::Money(_) => NUMBERS,
130 ColumnType::Char(_) | ColumnType::String(_) | ColumnType::Text => &[ValueKind::Text],
131 ColumnType::TimestampWithTimeZone => &[ValueKind::DateTimeUtc],
132 ColumnType::DateTime | ColumnType::Timestamp => &[ValueKind::NaiveDateTime],
133 ColumnType::Date => &[ValueKind::NaiveDate],
134 ColumnType::Time => &[ValueKind::NaiveTime],
135 ColumnType::Custom(_) => return None,
136 _ => &[],
137 })
138}
139
140pub fn check_column_types<'a, C, M>(
170 columns: impl IntoIterator<Item = (&'a str, ColumnType, bool)>,
171) -> Result<(), ColumnTypeMismatch>
172where
173 C: Columns + ?Sized,
174 M: PersistedFields,
175{
176 if M::FIELD_TYPES.len() != M::FIELD_NAMES.len() {
177 return Ok(());
178 }
179 let columns: Vec<_> = columns.into_iter().collect();
180
181 for (&field, value) in M::FIELD_NAMES.iter().zip(M::FIELD_TYPES) {
182 let Some(column) = C::column(field) else {
184 continue;
185 };
186 let mismatch = |reason| ColumnTypeMismatch {
187 entity: C::NAME,
188 field,
189 column,
190 reason,
191 };
192 let Some((_, column_type, nullable)) = columns.iter().find(|(name, _, _)| *name == column)
193 else {
194 return Err(mismatch(MismatchReason::MissingColumn));
195 };
196 if let Some(reason) = value_mismatch(*value, column_type, *nullable) {
197 return Err(mismatch(reason));
198 }
199 }
200
201 Ok(())
202}
203
204fn value_mismatch(
206 value: ValueType,
207 column_type: &ColumnType,
208 nullable: bool,
209) -> Option<MismatchReason> {
210 let accepts = accepted_kinds(column_type)?;
211 if !accepts.contains(&value.kind) {
212 return Some(MismatchReason::Kind {
213 value: value.kind,
214 accepts,
215 column_type: format!("{column_type:?}"),
216 });
217 }
218 if value.nullable && !nullable {
219 return Some(MismatchReason::Nullable);
220 }
221
222 None
223}
224
225#[cfg(test)]
226mod tests {
227 use super::{ColumnTypeMismatch, MismatchReason, accepted_kinds, check_column_types};
228 use crate::Columns;
229 use pretty_assertions::assert_eq;
230 use queryweave::{PersistedFields, ValueKind, ValueType};
231 use rstest::rstest;
232 use sea_query::{ColumnType, StringLen};
233
234 struct Order;
235
236 impl Columns for Order {
237 const NAME: &'static str = "Model";
238 const COLUMNS: &'static [(&'static str, &'static str)] =
239 &[("quantity", "quantity"), ("delivery_date", "delivered_at")];
240 }
241
242 struct Changes;
243
244 impl PersistedFields for Changes {
245 const FIELD_NAMES: &'static [&'static str] = &["quantity", "delivery_date"];
246 const FIELD_TYPES: &'static [ValueType] = &[
247 ValueType {
248 kind: ValueKind::I16,
249 nullable: false,
250 },
251 ValueType {
252 kind: ValueKind::DateTimeUtc,
253 nullable: true,
254 },
255 ];
256 }
257
258 struct HandWritten;
259
260 impl PersistedFields for HandWritten {
261 const FIELD_NAMES: &'static [&'static str] = &["quantity"];
262 }
263
264 fn check<M: PersistedFields>(
265 quantity: ColumnType,
266 delivered_at_nullable: bool,
267 ) -> Result<(), ColumnTypeMismatch> {
268 check_column_types::<Order, M>([
269 ("id", ColumnType::Uuid, false),
270 ("quantity", quantity, false),
271 (
272 "delivered_at",
273 ColumnType::TimestampWithTimeZone,
274 delivered_at_nullable,
275 ),
276 ])
277 }
278
279 #[test]
280 fn values_of_a_type_the_columns_take_fit() {
281 assert_eq!(check::<Changes>(ColumnType::Integer, true), Ok(()));
282 assert_eq!(check::<Changes>(ColumnType::TinyUnsigned, true), Ok(()));
283 }
284
285 #[test]
286 fn a_value_of_another_type_names_the_field_column_and_types() {
287 let mismatch = check::<Changes>(ColumnType::String(StringLen::None), true).unwrap_err();
288
289 assert_eq!(
290 mismatch.reason,
291 MismatchReason::Kind {
292 value: ValueKind::I16,
293 accepts: &[ValueKind::Text],
294 column_type: String::from("String(None)"),
295 }
296 );
297 assert_eq!(
298 mismatch.to_string(),
299 "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"
300 );
301 }
302
303 #[test]
304 fn a_column_taking_no_domain_value_says_its_type() {
305 let mismatch = check::<Changes>(ColumnType::Json, true).unwrap_err();
306
307 assert_eq!(
308 mismatch.to_string(),
309 "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"
310 );
311 }
312
313 #[test]
314 fn a_nullable_value_needs_a_nullable_column() {
315 let mismatch = check::<Changes>(ColumnType::Integer, false).unwrap_err();
316
317 assert_eq!(mismatch.reason, MismatchReason::Nullable);
318 assert_eq!(
319 mismatch.to_string(),
320 "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"
321 );
322 }
323
324 #[test]
325 fn a_column_not_given_is_reported() {
326 let mismatch =
327 check_column_types::<Order, Changes>([("quantity", ColumnType::Integer, false)])
328 .unwrap_err();
329
330 assert_eq!(
331 mismatch.to_string(),
332 "the column `delivered_at` of `Model` (storing domain field `delivery_date`) is not among the columns given"
333 );
334 }
335
336 #[test]
338 fn a_model_without_value_types_is_not_checked() {
339 assert_eq!(check::<HandWritten>(ColumnType::Json, false), Ok(()));
340 }
341
342 #[rstest]
343 #[case(ColumnType::Boolean, ValueKind::Bool, true)]
344 #[case(ColumnType::BigInteger, ValueKind::I16, true)]
345 #[case(ColumnType::Integer, ValueKind::F64, false)]
346 #[case(ColumnType::Double, ValueKind::F32, true)]
347 #[case(ColumnType::Decimal(None), ValueKind::I64, true)]
348 #[case(ColumnType::Text, ValueKind::Text, true)]
349 #[case(ColumnType::Char(None), ValueKind::I32, false)]
350 #[case(ColumnType::TimestampWithTimeZone, ValueKind::DateTimeUtc, true)]
351 #[case(ColumnType::TimestampWithTimeZone, ValueKind::NaiveDateTime, false)]
352 #[case(ColumnType::DateTime, ValueKind::NaiveDateTime, true)]
353 #[case(ColumnType::DateTime, ValueKind::DateTimeUtc, false)]
354 #[case(ColumnType::Timestamp, ValueKind::DateTimeUtc, false)]
355 #[case(ColumnType::Date, ValueKind::NaiveDate, true)]
356 #[case(ColumnType::Time, ValueKind::NaiveTime, true)]
357 #[case(ColumnType::Uuid, ValueKind::Text, false)]
358 fn each_column_type_takes_its_values(
359 #[case] column_type: ColumnType,
360 #[case] kind: ValueKind,
361 #[case] takes: bool,
362 ) {
363 assert_eq!(
364 accepted_kinds(&column_type).is_some_and(|accepts| accepts.contains(&kind)),
365 takes
366 );
367 }
368
369 #[test]
370 fn a_custom_type_is_not_checked() {
371 assert_eq!(
372 accepted_kinds(&ColumnType::Custom(sea_query::Alias::new("citext").into())),
373 None
374 );
375 }
376}