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