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