1use crate::schema::{Column, ColumnType, Table};
4use serde_json::{Map, Number, Value};
5
6#[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
29pub fn validate_row(table: &Table, row: &Map<String, Value>) -> Result<(), ValidationError> {
42 validate_row_inner(table, row, true)
43}
44
45pub 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 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 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}