1use std::cmp::Ordering;
25
26use serde_json::Value;
27
28use crate::error::{FraiseQLError, Result};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum ComparisonOperator {
34 LessThan,
36 LessEqual,
38 GreaterThan,
40 GreaterEqual,
42 Equal,
44 NotEqual,
46}
47
48impl ComparisonOperator {
49 #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Option<Self> {
52 match s {
53 "<" | "lt" => Some(Self::LessThan),
54 "<=" | "lte" => Some(Self::LessEqual),
55 ">" | "gt" => Some(Self::GreaterThan),
56 ">=" | "gte" => Some(Self::GreaterEqual),
57 "==" | "eq" => Some(Self::Equal),
58 "!=" | "neq" => Some(Self::NotEqual),
59 _ => None,
60 }
61 }
62
63 pub const fn symbol(&self) -> &'static str {
65 match self {
66 Self::LessThan => "<",
67 Self::LessEqual => "<=",
68 Self::GreaterThan => ">",
69 Self::GreaterEqual => ">=",
70 Self::Equal => "==",
71 Self::NotEqual => "!=",
72 }
73 }
74
75 pub const fn name(&self) -> &'static str {
77 match self {
78 Self::LessThan => "less than",
79 Self::LessEqual => "less than or equal to",
80 Self::GreaterThan => "greater than",
81 Self::GreaterEqual => "greater than or equal to",
82 Self::Equal => "equal to",
83 Self::NotEqual => "not equal to",
84 }
85 }
86}
87
88pub fn validate_cross_field_comparison(
107 input: &Value,
108 left_field: &str,
109 operator: ComparisonOperator,
110 right_field: &str,
111 context_path: Option<&str>,
112) -> Result<()> {
113 let field_path = context_path.unwrap_or("input");
114
115 if let Value::Object(obj) = input {
116 let left_val = obj.get(left_field).ok_or_else(|| FraiseQLError::Validation {
117 message: format!("Field '{}' not found in input", left_field),
118 path: Some(field_path.to_string()),
119 })?;
120
121 let right_val = obj.get(right_field).ok_or_else(|| FraiseQLError::Validation {
122 message: format!("Field '{}' not found in input", right_field),
123 path: Some(field_path.to_string()),
124 })?;
125
126 if matches!(left_val, Value::Null) || matches!(right_val, Value::Null) {
128 return Ok(());
129 }
130
131 compare_values(left_val, right_val, left_field, operator, right_field, field_path)
132 } else {
133 Err(FraiseQLError::Validation {
134 message: "Input is not an object".to_string(),
135 path: Some(field_path.to_string()),
136 })
137 }
138}
139
140fn compare_values(
142 left: &Value,
143 right: &Value,
144 left_field: &str,
145 operator: ComparisonOperator,
146 right_field: &str,
147 context_path: &str,
148) -> Result<()> {
149 let ordering = match (left, right) {
150 (Value::Number(l), Value::Number(r)) => {
152 let l_val = l.as_f64().unwrap_or(0.0);
153 let r_val = r.as_f64().unwrap_or(0.0);
154 if l_val < r_val {
155 Ordering::Less
156 } else if l_val > r_val {
157 Ordering::Greater
158 } else {
159 Ordering::Equal
160 }
161 },
162 (Value::String(l), Value::String(r)) => l.cmp(r),
164 _ => {
166 return Err(FraiseQLError::Validation {
167 message: format!(
168 "Cannot compare '{}' ({}) with '{}' ({})",
169 left_field,
170 value_type_name(left),
171 right_field,
172 value_type_name(right)
173 ),
174 path: Some(context_path.to_string()),
175 });
176 },
177 };
178
179 let result = match operator {
180 ComparisonOperator::LessThan => matches!(ordering, Ordering::Less),
181 ComparisonOperator::LessEqual => !matches!(ordering, Ordering::Greater),
182 ComparisonOperator::GreaterThan => matches!(ordering, Ordering::Greater),
183 ComparisonOperator::GreaterEqual => !matches!(ordering, Ordering::Less),
184 ComparisonOperator::Equal => matches!(ordering, Ordering::Equal),
185 ComparisonOperator::NotEqual => !matches!(ordering, Ordering::Equal),
186 };
187
188 if !result {
189 return Err(FraiseQLError::Validation {
190 message: format!(
191 "'{}' ({}) must be {} '{}' ({})",
192 left_field,
193 value_to_string(left),
194 operator.name(),
195 right_field,
196 value_to_string(right)
197 ),
198 path: Some(context_path.to_string()),
199 });
200 }
201
202 Ok(())
203}
204
205const fn value_type_name(val: &Value) -> &'static str {
207 match val {
208 Value::Null => "null",
209 Value::Bool(_) => "boolean",
210 Value::Number(_) => "number",
211 Value::String(_) => "string",
212 Value::Array(_) => "array",
213 Value::Object(_) => "object",
214 }
215}
216
217fn value_to_string(val: &Value) -> String {
219 match val {
220 Value::String(s) => format!("\"{}\"", s),
221 Value::Number(n) => n.to_string(),
222 Value::Bool(b) => b.to_string(),
223 Value::Null => "null".to_string(),
224 _ => val.to_string(),
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use serde_json::json;
231
232 use super::*;
233
234 #[test]
235 fn test_operator_parsing() {
236 assert_eq!(ComparisonOperator::from_str("<"), Some(ComparisonOperator::LessThan));
237 assert_eq!(ComparisonOperator::from_str("lt"), Some(ComparisonOperator::LessThan));
238 assert_eq!(ComparisonOperator::from_str("<="), Some(ComparisonOperator::LessEqual));
239 assert_eq!(ComparisonOperator::from_str("lte"), Some(ComparisonOperator::LessEqual));
240 assert_eq!(ComparisonOperator::from_str(">"), Some(ComparisonOperator::GreaterThan));
241 assert_eq!(ComparisonOperator::from_str("gt"), Some(ComparisonOperator::GreaterThan));
242 assert_eq!(ComparisonOperator::from_str(">="), Some(ComparisonOperator::GreaterEqual));
243 assert_eq!(ComparisonOperator::from_str("gte"), Some(ComparisonOperator::GreaterEqual));
244 assert_eq!(ComparisonOperator::from_str("=="), Some(ComparisonOperator::Equal));
245 assert_eq!(ComparisonOperator::from_str("eq"), Some(ComparisonOperator::Equal));
246 assert_eq!(ComparisonOperator::from_str("!="), Some(ComparisonOperator::NotEqual));
247 assert_eq!(ComparisonOperator::from_str("neq"), Some(ComparisonOperator::NotEqual));
248 assert_eq!(ComparisonOperator::from_str("invalid"), None);
249 }
250
251 #[test]
252 fn test_numeric_less_than() {
253 let input = json!({
254 "start": 10,
255 "end": 20
256 });
257 let result = validate_cross_field_comparison(
258 &input,
259 "start",
260 ComparisonOperator::LessThan,
261 "end",
262 None,
263 );
264 result.unwrap_or_else(|e| panic!("expected 10 < 20 to pass: {e}"));
265 }
266
267 #[test]
268 fn test_numeric_less_than_fails() {
269 let input = json!({
270 "start": 30,
271 "end": 20
272 });
273 let result = validate_cross_field_comparison(
274 &input,
275 "start",
276 ComparisonOperator::LessThan,
277 "end",
278 None,
279 );
280 assert!(
281 matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be") && message.contains("less than")),
282 "expected Validation error for 30 < 20, got: {result:?}"
283 );
284 }
285
286 #[test]
287 fn test_numeric_equal() {
288 let input = json!({
289 "a": 42,
290 "b": 42
291 });
292 let result =
293 validate_cross_field_comparison(&input, "a", ComparisonOperator::Equal, "b", None);
294 result.unwrap_or_else(|e| panic!("expected 42 == 42 to pass: {e}"));
295 }
296
297 #[test]
298 fn test_numeric_not_equal() {
299 let input = json!({
300 "a": 10,
301 "b": 20
302 });
303 let result =
304 validate_cross_field_comparison(&input, "a", ComparisonOperator::NotEqual, "b", None);
305 result.unwrap_or_else(|e| panic!("expected 10 != 20 to pass: {e}"));
306 }
307
308 #[test]
309 fn test_numeric_greater_than_or_equal() {
310 let input = json!({
311 "min": 10,
312 "max": 10
313 });
314 let result = validate_cross_field_comparison(
315 &input,
316 "max",
317 ComparisonOperator::GreaterEqual,
318 "min",
319 None,
320 );
321 result.unwrap_or_else(|e| panic!("expected 10 >= 10 to pass: {e}"));
322 }
323
324 #[test]
325 fn test_string_comparison() {
326 let input = json!({
327 "start_name": "alice",
328 "end_name": "zoe"
329 });
330 let result = validate_cross_field_comparison(
331 &input,
332 "start_name",
333 ComparisonOperator::LessThan,
334 "end_name",
335 None,
336 );
337 result.unwrap_or_else(|e| panic!("expected 'alice' < 'zoe' to pass: {e}"));
338 }
339
340 #[test]
341 fn test_string_comparison_fails() {
342 let input = json!({
343 "start_name": "zoe",
344 "end_name": "alice"
345 });
346 let result = validate_cross_field_comparison(
347 &input,
348 "start_name",
349 ComparisonOperator::LessThan,
350 "end_name",
351 None,
352 );
353 assert!(
354 matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be") && message.contains("less than")),
355 "expected Validation error for 'zoe' < 'alice', got: {result:?}"
356 );
357 }
358
359 #[test]
360 fn test_date_string_comparison() {
361 let input = json!({
362 "start_date": "2024-01-01",
363 "end_date": "2024-12-31"
364 });
365 let result = validate_cross_field_comparison(
366 &input,
367 "start_date",
368 ComparisonOperator::LessThan,
369 "end_date",
370 None,
371 );
372 result.unwrap_or_else(|e| panic!("expected date string comparison to pass: {e}"));
373 }
374
375 #[test]
376 fn test_float_comparison() {
377 let input = json!({
378 "price": 19.99,
379 "budget": 25.50
380 });
381 let result = validate_cross_field_comparison(
382 &input,
383 "price",
384 ComparisonOperator::LessThan,
385 "budget",
386 None,
387 );
388 result.unwrap_or_else(|e| panic!("expected 19.99 < 25.50 to pass: {e}"));
389 }
390
391 #[test]
392 fn test_missing_left_field() {
393 let input = json!({
394 "end": 20
395 });
396 let result = validate_cross_field_comparison(
397 &input,
398 "start",
399 ComparisonOperator::LessThan,
400 "end",
401 None,
402 );
403 assert!(
404 matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not found")),
405 "expected Validation error for missing left field, got: {result:?}"
406 );
407 }
408
409 #[test]
410 fn test_missing_right_field() {
411 let input = json!({
412 "start": 10
413 });
414 let result = validate_cross_field_comparison(
415 &input,
416 "start",
417 ComparisonOperator::LessThan,
418 "end",
419 None,
420 );
421 assert!(
422 matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not found")),
423 "expected Validation error for missing right field, got: {result:?}"
424 );
425 }
426
427 #[test]
428 fn test_null_fields_skipped() {
429 let input = json!({
430 "start": null,
431 "end": 20
432 });
433 let result = validate_cross_field_comparison(
434 &input,
435 "start",
436 ComparisonOperator::LessThan,
437 "end",
438 None,
439 );
440 result.unwrap_or_else(|e| panic!("expected null field to be skipped: {e}"));
441 }
442
443 #[test]
444 fn test_both_null_fields_skipped() {
445 let input = json!({
446 "start": null,
447 "end": null
448 });
449 let result = validate_cross_field_comparison(
450 &input,
451 "start",
452 ComparisonOperator::LessThan,
453 "end",
454 None,
455 );
456 result.unwrap_or_else(|e| panic!("expected both null fields to be skipped: {e}"));
457 }
458
459 #[test]
460 fn test_type_mismatch_error() {
461 let input = json!({
462 "start": 10,
463 "end": "twenty"
464 });
465 let result = validate_cross_field_comparison(
466 &input,
467 "start",
468 ComparisonOperator::LessThan,
469 "end",
470 None,
471 );
472 assert!(
473 matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Cannot compare")),
474 "expected Validation error for type mismatch, got: {result:?}"
475 );
476 }
477
478 #[test]
479 fn test_error_includes_context_path() {
480 let input = json!({
481 "start": 30,
482 "end": 20
483 });
484 let result = validate_cross_field_comparison(
485 &input,
486 "start",
487 ComparisonOperator::LessThan,
488 "end",
489 Some("dateRange"),
490 );
491 assert!(
492 matches!(result, Err(FraiseQLError::Validation { ref path, .. }) if *path == Some("dateRange".to_string())),
493 "expected Validation error with path 'dateRange', got: {result:?}"
494 );
495 }
496
497 #[test]
498 fn test_error_message_includes_values() {
499 let input = json!({
500 "price": 100,
501 "max_price": 50
502 });
503 let result = validate_cross_field_comparison(
504 &input,
505 "price",
506 ComparisonOperator::LessThan,
507 "max_price",
508 None,
509 );
510 assert!(
511 matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("price") && message.contains("max_price") && message.contains("100") && message.contains("50")),
512 "expected Validation error with field names and values, got: {result:?}"
513 );
514 }
515
516 #[test]
517 fn test_all_operators() {
518 let test_cases = vec![
519 (10, 20, ComparisonOperator::LessThan, true),
520 (10, 10, ComparisonOperator::LessEqual, true),
521 (20, 10, ComparisonOperator::GreaterThan, true),
522 (10, 10, ComparisonOperator::GreaterEqual, true),
523 (42, 42, ComparisonOperator::Equal, true),
524 (10, 20, ComparisonOperator::NotEqual, true),
525 (20, 10, ComparisonOperator::LessThan, false),
526 (10, 20, ComparisonOperator::GreaterThan, false),
527 ];
528
529 for (left, right, op, should_pass) in test_cases {
530 let input = json!({ "a": left, "b": right });
531 let result = validate_cross_field_comparison(&input, "a", op, "b", None);
532 assert_eq!(
533 result.is_ok(),
534 should_pass,
535 "Failed for {} {} {}",
536 left,
537 op.symbol(),
538 right
539 );
540 }
541 }
542
543 #[test]
544 fn test_non_object_input() {
545 let input = json!([1, 2, 3]);
546 let result =
547 validate_cross_field_comparison(&input, "a", ComparisonOperator::LessThan, "b", None);
548 assert!(
549 matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not an object")),
550 "expected Validation error for non-object input, got: {result:?}"
551 );
552 }
553
554 #[test]
555 fn test_empty_object() {
556 let input = json!({});
557 let result = validate_cross_field_comparison(
558 &input,
559 "start",
560 ComparisonOperator::LessThan,
561 "end",
562 None,
563 );
564 assert!(
565 matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not found")),
566 "expected Validation error for empty object, got: {result:?}"
567 );
568 }
569
570 #[test]
571 fn test_zero_comparison() {
572 let input = json!({
573 "a": 0,
574 "b": 0
575 });
576 let result =
577 validate_cross_field_comparison(&input, "a", ComparisonOperator::Equal, "b", None);
578 result.unwrap_or_else(|e| panic!("expected 0 == 0 to pass: {e}"));
579 }
580
581 #[test]
582 fn test_negative_number_comparison() {
583 let input = json!({
584 "a": -10,
585 "b": 5
586 });
587 let result =
588 validate_cross_field_comparison(&input, "a", ComparisonOperator::LessThan, "b", None);
589 result.unwrap_or_else(|e| panic!("expected -10 < 5 to pass: {e}"));
590 }
591
592 #[test]
593 fn test_empty_string_comparison() {
594 let input = json!({
595 "a": "",
596 "b": "text"
597 });
598 let result =
599 validate_cross_field_comparison(&input, "a", ComparisonOperator::LessThan, "b", None);
600 result.unwrap_or_else(|e| panic!("expected '' < 'text' to pass: {e}"));
601 }
602}