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