Skip to main content

queryweave_sql/backend/sea_query/
check.rs

1//! The check that each domain field's value fits its column's type in a
2//! `SeaORM` entity (or any table described by sea-query's `ColumnType`).
3//!
4//! sea-query binds whatever value it is given, and the database decides:
5//! Postgres rejects a value of another type when the query runs, MySQL and
6//! SQLite convert it, so a mismatch may silently match the wrong rows. The
7//! domain derives publish each field's value type
8//! (`PersistedFields::FIELD_TYPES`), and `SeaORM` each column's
9//! (`ColumnTrait::def`), so [`check_column_types`] compares the two, once,
10//! from a test or at startup.
11
12use crate::Columns;
13use queryweave::{PersistedFields, ValueKind, ValueType};
14use sea_query::ColumnType;
15
16/// A domain field whose value does not fit its column; see
17/// [`check_column_types`].
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ColumnTypeMismatch {
20    /// The entity's type name.
21    pub entity: &'static str,
22    /// The domain field.
23    pub field: &'static str,
24    /// The column storing it.
25    pub column: &'static str,
26    /// What does not fit.
27    pub reason: MismatchReason,
28}
29
30/// Why a domain field's value does not fit its column.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum MismatchReason {
33    /// The value is of a type the column does not take.
34    Kind {
35        /// The value's type.
36        value: ValueKind,
37        /// What the column takes (empty: no domain value).
38        accepts: &'static [ValueKind],
39        /// The column's type, as sea-query names it.
40        column_type: String,
41    },
42    /// The field may be `None`, and the column is `NOT NULL`.
43    Nullable,
44    /// The column is not among the columns given.
45    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/// The value types a column of `column_type` takes; `None` for a custom type,
107/// which is not checked.
108///
109/// Those sea-query binds as a type the database stores in it without loss of
110/// meaning: any integer in any integer column (out-of-range values are the
111/// database's to reject, as with sqlx). A UTC date-time only in a column with
112/// a time zone: Postgres would convert a column without one to compare or
113/// store it by the session's time zone, so results would depend on the
114/// server's settings.
115#[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
140/// Check that the value of every field of the model `M` fits its column on
141/// the entity `C`.
142///
143/// `columns` describes the entity's columns: `(column name, column type,
144/// nullable)` for each, e.g. from `SeaORM`'s `Column::iter()` and
145/// `ColumnTrait::def()`.
146///
147/// A field that may be `None` (a `MergePatch` model's optional field) needs a
148/// nullable column. A model without value types (a hand-written
149/// `PersistedFields`) passes unchecked, as does a column of a custom type.
150///
151/// # Examples
152///
153/// ```rust,ignore
154/// use sea_orm::{ColumnTrait, IdenStatic, Iterable};
155///
156/// let columns = order::Column::iter().map(|column| {
157///     let def = column.def();
158///     (column.as_str(), def.get_column_type().clone(), def.is_null())
159/// });
160/// queryweave_sql::sea_query::check_column_types::<order::Model, OrdersFilterOptionsModel>(
161///     columns,
162/// )?;
163/// ```
164///
165/// # Errors
166///
167/// The first field whose value does not fit its column, or whose column is
168/// not among `columns`.
169pub 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        // Every field has a column (the SQL helpers check it at compile time).
183        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
204/// Why a value of `value`'s type does not fit a column, if it does not.
205fn 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    // A hand-written `PersistedFields` has no value types: nothing checked.
337    #[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}