Skip to main content

mongreldb_kit_core/
validation.rs

1//! Row validation against a [`Table`] schema.
2
3use crate::schema::{Column, ColumnType, Table};
4use serde_json::{Map, Number, Value};
5
6/// A validation failure returned by [`validate_row`].
7#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
8#[error("validation error in table \"{table}\", column \"{column}\": {message}")]
9pub struct ValidationError {
10    pub table: String,
11    pub column: String,
12    pub message: String,
13}
14
15impl ValidationError {
16    pub fn new(
17        table: impl Into<String>,
18        column: impl Into<String>,
19        message: impl Into<String>,
20    ) -> Self {
21        Self {
22            table: table.into(),
23            column: column.into(),
24            message: message.into(),
25        }
26    }
27}
28
29/// Validate a JSON row against a table definition.
30///
31/// Checks performed:
32/// * not-null constraints
33/// * type compatibility with the column's [`ColumnType`]
34/// * `enum_values` membership
35/// * numeric `min` / `max`
36/// * string/bytes `min_length` / `max_length`
37/// * `regex` pattern match
38/// * JSON parseability for `Json` columns
39/// * table-level `check_constraints` names (validation of the expression itself
40///   is left to the runtime that registered the named check)
41pub fn validate_row(table: &Table, row: &Map<String, Value>) -> Result<(), ValidationError> {
42    for col in &table.columns {
43        let value = row.get(&col.name);
44        validate_column(table, col, value, row)?;
45    }
46
47    for check in &table.check_constraints {
48        if check.expr.trim().is_empty() {
49            return Err(ValidationError::new(
50                &table.name,
51                "",
52                format!(
53                    "check constraint \"{}\" has an empty expression",
54                    check.name
55                ),
56            ));
57        }
58        match crate::check::eval_check(&check.expr, row) {
59            Ok(true) => {}
60            Ok(false) => {
61                return Err(ValidationError::new(
62                    &table.name,
63                    "",
64                    format!("check constraint \"{}\" failed", check.name),
65                ));
66            }
67            Err(e) => {
68                return Err(ValidationError::new(
69                    &table.name,
70                    "",
71                    format!("check constraint \"{}\" is invalid: {}", check.name, e.0),
72                ));
73            }
74        }
75    }
76
77    Ok(())
78}
79
80fn validate_column(
81    table: &Table,
82    col: &Column,
83    value: Option<&Value>,
84    row: &Map<String, Value>,
85) -> Result<(), ValidationError> {
86    let value = match value {
87        Some(Value::Null) | None => {
88            if !col.nullable {
89                return Err(ValidationError::new(
90                    &table.name,
91                    &col.name,
92                    "cannot be null",
93                ));
94            }
95            return Ok(());
96        }
97        Some(v) => v,
98    };
99
100    type_check(table, col, value)?;
101
102    if let Some(enum_values) = &col.enum_values {
103        if let Value::String(s) = value {
104            if !enum_values.contains(s) {
105                return Err(ValidationError::new(
106                    &table.name,
107                    &col.name,
108                    format!("value \"{s}\" must be one of {}", enum_values.join(", ")),
109                ));
110            }
111        }
112    }
113
114    match value {
115        Value::Number(n) => {
116            let f = number_to_f64(n);
117            if let Some(min) = col.min {
118                if f < min {
119                    return Err(ValidationError::new(
120                        &table.name,
121                        &col.name,
122                        format!("must be at least {min}"),
123                    ));
124                }
125            }
126            if let Some(max) = col.max {
127                if f > max {
128                    return Err(ValidationError::new(
129                        &table.name,
130                        &col.name,
131                        format!("must be at most {max}"),
132                    ));
133                }
134            }
135        }
136        Value::String(s) => {
137            if let Some(min_len) = col.min_length {
138                if s.chars().count() < min_len {
139                    return Err(ValidationError::new(
140                        &table.name,
141                        &col.name,
142                        format!("must have length at least {min_len}"),
143                    ));
144                }
145            }
146            if let Some(max_len) = col.max_length {
147                if s.chars().count() > max_len {
148                    return Err(ValidationError::new(
149                        &table.name,
150                        &col.name,
151                        format!("must have length at most {max_len}"),
152                    ));
153                }
154            }
155            if let Some(pattern) = &col.regex {
156                let re = regex::Regex::new(pattern).map_err(|e| {
157                    ValidationError::new(
158                        &table.name,
159                        &col.name,
160                        format!("invalid regex pattern: {e}"),
161                    )
162                })?;
163                if !re.is_match(s) {
164                    return Err(ValidationError::new(
165                        &table.name,
166                        &col.name,
167                        "does not match required pattern",
168                    ));
169                }
170            }
171        }
172        Value::Array(arr) => {
173            if let Some(max_len) = col.max_length {
174                if arr.len() > max_len {
175                    return Err(ValidationError::new(
176                        &table.name,
177                        &col.name,
178                        format!("must have length at most {max_len}"),
179                    ));
180                }
181            }
182            if let Some(min_len) = col.min_length {
183                if arr.len() < min_len {
184                    return Err(ValidationError::new(
185                        &table.name,
186                        &col.name,
187                        format!("must have length at least {min_len}"),
188                    ));
189                }
190            }
191        }
192        Value::Object(obj) => {
193            if let Some(max_len) = col.max_length {
194                if obj.len() > max_len {
195                    return Err(ValidationError::new(
196                        &table.name,
197                        &col.name,
198                        format!("must have length at most {max_len}"),
199                    ));
200                }
201            }
202            if let Some(min_len) = col.min_length {
203                if obj.len() < min_len {
204                    return Err(ValidationError::new(
205                        &table.name,
206                        &col.name,
207                        format!("must have length at least {min_len}"),
208                    ));
209                }
210            }
211        }
212        _ => {}
213    }
214
215    if let Some(expr) = &col.check_expr {
216        if expr.trim().is_empty() {
217            return Err(ValidationError::new(
218                &table.name,
219                &col.name,
220                "column check expression is empty",
221            ));
222        }
223        // Column checks are evaluated against the full row so they may
224        // reference the column by name (and any sibling column).
225        match crate::check::eval_check(expr, row) {
226            Ok(true) => {}
227            Ok(false) => {
228                return Err(ValidationError::new(
229                    &table.name,
230                    &col.name,
231                    "column check constraint failed",
232                ));
233            }
234            Err(e) => {
235                return Err(ValidationError::new(
236                    &table.name,
237                    &col.name,
238                    format!("column check constraint is invalid: {}", e.0),
239                ));
240            }
241        }
242    }
243
244    Ok(())
245}
246
247fn type_check(table: &Table, col: &Column, value: &Value) -> Result<(), ValidationError> {
248    let ok = match col.storage_type {
249        ColumnType::Bool => value.is_boolean(),
250        ColumnType::Int8 | ColumnType::Int16 | ColumnType::Int32 | ColumnType::Int64 => {
251            value.as_i64().is_some()
252        }
253        ColumnType::Float32 | ColumnType::Float64 => value.is_number(),
254        ColumnType::Text => value.is_string(),
255        ColumnType::Bytes => value.is_string() || value.is_array(),
256        ColumnType::Json => {
257            // Any serde_json::Value is JSON-compatible, but circular references
258            // are impossible here and serialization always succeeds for owned
259            // values. We accept objects/arrays/scalars.
260            true
261        }
262        ColumnType::Date
263        | ColumnType::DateTime
264        | ColumnType::TimestampNanos
265        | ColumnType::Date64
266        | ColumnType::Time64
267        | ColumnType::Interval
268        | ColumnType::Decimal128
269        | ColumnType::Uuid
270        | ColumnType::JsonNative
271        | ColumnType::Array => value.is_string() || value.is_number() || value.is_array(),
272        ColumnType::Embedding => value
273            .as_array()
274            .is_some_and(|a| a.iter().all(|v| v.is_number())),
275        ColumnType::Sparse => value.as_array().is_some_and(|a| {
276            a.iter().all(|pair| {
277                pair.as_array()
278                    .is_some_and(|p| p.len() == 2 && p[0].is_u64() && p[1].is_number())
279            })
280        }),
281    };
282
283    if !ok {
284        return Err(ValidationError::new(
285            &table.name,
286            &col.name,
287            format!("must be {:?}", col.storage_type),
288        ));
289    }
290
291    Ok(())
292}
293
294fn number_to_f64(n: &Number) -> f64 {
295    n.as_f64().unwrap_or(f64::NAN)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::schema::{CheckConstraint, ColumnType};
302    use serde_json::json;
303
304    fn users_table() -> Table {
305        Table {
306            id: 1,
307            name: "users".into(),
308            columns: vec![
309                Column::new(1, "id", ColumnType::Int64),
310                Column::new(2, "email", ColumnType::Text),
311                Column {
312                    nullable: true,
313                    ..Column::new(3, "age", ColumnType::Int64)
314                },
315                Column {
316                    min_length: Some(2),
317                    max_length: Some(10),
318                    ..Column::new(4, "handle", ColumnType::Text)
319                },
320                Column {
321                    enum_values: Some(vec!["user".into(), "admin".into()]),
322                    ..Column::new(5, "role", ColumnType::Text)
323                },
324                Column {
325                    regex: Some(r"^\d{3}-\d{4}$".into()),
326                    ..Column::new(6, "zip", ColumnType::Text)
327                },
328            ],
329            primary_key: vec!["id".into()],
330            indexes: vec![],
331            foreign_keys: vec![],
332            unique_constraints: vec![],
333            check_constraints: vec![],
334        }
335    }
336
337    fn row(value: serde_json::Value) -> Map<String, Value> {
338        value.as_object().unwrap().clone()
339    }
340
341    #[test]
342    fn valid_row_passes() {
343        let table = users_table();
344        let r = row(json!({
345            "id": 1,
346            "email": "a@b.com",
347            "age": 30,
348            "handle": "ab",
349            "role": "user",
350            "zip": "123-4567"
351        }));
352        validate_row(&table, &r).unwrap();
353    }
354
355    #[test]
356    fn rejects_null_in_non_nullable_column() {
357        let table = users_table();
358        let r = row(json!({ "id": null, "email": "a@b.com", "role": "user", "zip": "123-4567" }));
359        let err = validate_row(&table, &r).unwrap_err();
360        assert_eq!(err.column, "id");
361        assert!(err.message.contains("cannot be null"));
362    }
363
364    #[test]
365    fn rejects_missing_non_nullable_column() {
366        let table = users_table();
367        let r = row(json!({ "email": "a@b.com", "role": "user", "zip": "123-4567" }));
368        let err = validate_row(&table, &r).unwrap_err();
369        assert_eq!(err.column, "id");
370    }
371
372    #[test]
373    fn rejects_type_mismatch() {
374        let table = users_table();
375        let r = row(
376            json!({ "id": "not-a-number", "email": "a@b.com", "handle": "ab", "role": "user", "zip": "123-4567" }),
377        );
378        let err = validate_row(&table, &r).unwrap_err();
379        assert_eq!(err.column, "id");
380    }
381
382    #[test]
383    fn rejects_enum_violation() {
384        let table = users_table();
385        let r = row(
386            json!({ "id": 1, "email": "a@b.com", "handle": "ab", "role": "superuser", "zip": "123-4567" }),
387        );
388        let err = validate_row(&table, &r).unwrap_err();
389        assert_eq!(err.column, "role");
390    }
391
392    #[test]
393    fn rejects_min_max() {
394        let table = users_table();
395        let mut col = Column::new(3, "score", ColumnType::Int64);
396        col.min = Some(0.0);
397        col.max = Some(100.0);
398        let table = Table {
399            columns: vec![col],
400            ..table
401        };
402        let r = row(
403            json!({ "id": 1, "email": "a@b.com", "handle": "ab", "role": "user", "zip": "123-4567", "score": 101 }),
404        );
405        let err = validate_row(&table, &r).unwrap_err();
406        assert_eq!(err.column, "score");
407    }
408
409    #[test]
410    fn rejects_length() {
411        let table = users_table();
412        let r = row(
413            json!({ "id": 1, "email": "a@b.com", "handle": "x", "role": "user", "zip": "123-4567" }),
414        );
415        let err = validate_row(&table, &r).unwrap_err();
416        assert_eq!(err.column, "handle");
417    }
418
419    #[test]
420    fn rejects_regex() {
421        let table = users_table();
422        let r = row(
423            json!({ "id": 1, "email": "a@b.com", "handle": "ab", "role": "user", "zip": "bad" }),
424        );
425        let err = validate_row(&table, &r).unwrap_err();
426        assert_eq!(err.column, "zip");
427    }
428
429    #[test]
430    fn rejects_empty_table_check_expr() {
431        let table = Table {
432            check_constraints: vec![CheckConstraint {
433                name: "empty".into(),
434                expr: "   ".into(),
435            }],
436            ..users_table()
437        };
438        let r = row(
439            json!({ "id": 1, "email": "a@b.com", "handle": "ab", "role": "user", "zip": "123-4567" }),
440        );
441        let err = validate_row(&table, &r).unwrap_err();
442        assert!(err.message.contains("empty"));
443    }
444}