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