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 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 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 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}