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