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 | ColumnType::DateTime | ColumnType::TimestampNanos => value.is_string(),
263        ColumnType::Embedding => value
264            .as_array()
265            .is_some_and(|a| a.iter().all(|v| v.is_number())),
266        ColumnType::Sparse => value.as_array().is_some_and(|a| {
267            a.iter().all(|pair| {
268                pair.as_array()
269                    .is_some_and(|p| p.len() == 2 && p[0].is_u64() && p[1].is_number())
270            })
271        }),
272    };
273
274    if !ok {
275        return Err(ValidationError::new(
276            &table.name,
277            &col.name,
278            format!("must be {:?}", col.storage_type),
279        ));
280    }
281
282    Ok(())
283}
284
285fn number_to_f64(n: &Number) -> f64 {
286    n.as_f64().unwrap_or(f64::NAN)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::schema::{CheckConstraint, ColumnType};
293    use serde_json::json;
294
295    fn users_table() -> Table {
296        Table {
297            id: 1,
298            name: "users".into(),
299            columns: vec![
300                Column::new(1, "id", ColumnType::Int64),
301                Column::new(2, "email", ColumnType::Text),
302                Column {
303                    nullable: true,
304                    ..Column::new(3, "age", ColumnType::Int64)
305                },
306                Column {
307                    min_length: Some(2),
308                    max_length: Some(10),
309                    ..Column::new(4, "handle", ColumnType::Text)
310                },
311                Column {
312                    enum_values: Some(vec!["user".into(), "admin".into()]),
313                    ..Column::new(5, "role", ColumnType::Text)
314                },
315                Column {
316                    regex: Some(r"^\d{3}-\d{4}$".into()),
317                    ..Column::new(6, "zip", ColumnType::Text)
318                },
319            ],
320            primary_key: vec!["id".into()],
321            indexes: vec![],
322            foreign_keys: vec![],
323            unique_constraints: vec![],
324            check_constraints: vec![],
325        }
326    }
327
328    fn row(value: serde_json::Value) -> Map<String, Value> {
329        value.as_object().unwrap().clone()
330    }
331
332    #[test]
333    fn valid_row_passes() {
334        let table = users_table();
335        let r = row(json!({
336            "id": 1,
337            "email": "a@b.com",
338            "age": 30,
339            "handle": "ab",
340            "role": "user",
341            "zip": "123-4567"
342        }));
343        validate_row(&table, &r).unwrap();
344    }
345
346    #[test]
347    fn rejects_null_in_non_nullable_column() {
348        let table = users_table();
349        let r = row(json!({ "id": null, "email": "a@b.com", "role": "user", "zip": "123-4567" }));
350        let err = validate_row(&table, &r).unwrap_err();
351        assert_eq!(err.column, "id");
352        assert!(err.message.contains("cannot be null"));
353    }
354
355    #[test]
356    fn rejects_missing_non_nullable_column() {
357        let table = users_table();
358        let r = row(json!({ "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    }
362
363    #[test]
364    fn rejects_type_mismatch() {
365        let table = users_table();
366        let r = row(
367            json!({ "id": "not-a-number", "email": "a@b.com", "handle": "ab", "role": "user", "zip": "123-4567" }),
368        );
369        let err = validate_row(&table, &r).unwrap_err();
370        assert_eq!(err.column, "id");
371    }
372
373    #[test]
374    fn rejects_enum_violation() {
375        let table = users_table();
376        let r = row(
377            json!({ "id": 1, "email": "a@b.com", "handle": "ab", "role": "superuser", "zip": "123-4567" }),
378        );
379        let err = validate_row(&table, &r).unwrap_err();
380        assert_eq!(err.column, "role");
381    }
382
383    #[test]
384    fn rejects_min_max() {
385        let table = users_table();
386        let mut col = Column::new(3, "score", ColumnType::Int64);
387        col.min = Some(0.0);
388        col.max = Some(100.0);
389        let table = Table {
390            columns: vec![col],
391            ..table
392        };
393        let r = row(
394            json!({ "id": 1, "email": "a@b.com", "handle": "ab", "role": "user", "zip": "123-4567", "score": 101 }),
395        );
396        let err = validate_row(&table, &r).unwrap_err();
397        assert_eq!(err.column, "score");
398    }
399
400    #[test]
401    fn rejects_length() {
402        let table = users_table();
403        let r = row(
404            json!({ "id": 1, "email": "a@b.com", "handle": "x", "role": "user", "zip": "123-4567" }),
405        );
406        let err = validate_row(&table, &r).unwrap_err();
407        assert_eq!(err.column, "handle");
408    }
409
410    #[test]
411    fn rejects_regex() {
412        let table = users_table();
413        let r = row(
414            json!({ "id": 1, "email": "a@b.com", "handle": "ab", "role": "user", "zip": "bad" }),
415        );
416        let err = validate_row(&table, &r).unwrap_err();
417        assert_eq!(err.column, "zip");
418    }
419
420    #[test]
421    fn rejects_empty_table_check_expr() {
422        let table = Table {
423            check_constraints: vec![CheckConstraint {
424                name: "empty".into(),
425                expr: "   ".into(),
426            }],
427            ..users_table()
428        };
429        let r = row(
430            json!({ "id": 1, "email": "a@b.com", "handle": "ab", "role": "user", "zip": "123-4567" }),
431        );
432        let err = validate_row(&table, &r).unwrap_err();
433        assert!(err.message.contains("empty"));
434    }
435}