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