use serde_json::Value;
use crate::{
error::{FraiseQLError, Result, ValidationFieldError},
schema::CompiledSchema,
validation::ValidationRule,
};
#[derive(Debug, Clone, Default)]
pub struct ValidationErrorCollection {
pub errors: Vec<ValidationFieldError>,
}
impl ValidationErrorCollection {
pub fn new() -> Self {
Self::default()
}
pub fn add_error(&mut self, error: ValidationFieldError) {
self.errors.push(error);
}
pub const fn is_empty(&self) -> bool {
self.errors.is_empty()
}
pub const fn len(&self) -> usize {
self.errors.len()
}
pub fn to_error(&self) -> FraiseQLError {
if self.errors.is_empty() {
FraiseQLError::validation("No validation errors")
} else if self.errors.len() == 1 {
let err = &self.errors[0];
FraiseQLError::Validation {
message: err.to_string(),
path: Some(err.field.clone()),
}
} else {
let messages: Vec<String> = self.errors.iter().map(|e| e.to_string()).collect();
FraiseQLError::Validation {
message: format!("Multiple validation errors: {}", messages.join("; ")),
path: None,
}
}
}
}
pub fn validate_custom_scalar_from_schema(
value: &Value,
scalar_type_name: &str,
schema: &CompiledSchema,
) -> Result<()> {
if schema.custom_scalars.exists(scalar_type_name) {
schema.custom_scalars.validate(scalar_type_name, value)
} else {
Ok(())
}
}
pub fn validate_input(value: &Value, field_path: &str, rules: &[ValidationRule]) -> Result<()> {
let mut errors = ValidationErrorCollection::new();
match value {
Value::String(s) => {
for rule in rules {
if let Err(FraiseQLError::Validation { message, .. }) =
validate_string_field(s, field_path, rule)
{
if let Some(field_err) = extract_field_error(&message) {
errors.add_error(field_err);
}
}
}
},
Value::Null => {
for rule in rules {
if rule.is_required() {
errors.add_error(ValidationFieldError::new(
field_path,
"required",
"Field is required",
));
}
}
},
_ => {
},
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.to_error())
}
}
fn validate_string_field(value: &str, field_path: &str, rule: &ValidationRule) -> Result<()> {
match rule {
ValidationRule::Required => {
if value.is_empty() {
return Err(FraiseQLError::Validation {
message: format!(
"Field validation failed: {}",
ValidationFieldError::new(field_path, "required", "Field is required")
),
path: Some(field_path.to_string()),
});
}
Ok(())
},
ValidationRule::Pattern { pattern, message } => {
let regex = regex::Regex::new(pattern)
.map_err(|e| FraiseQLError::validation(format!("Invalid regex pattern: {}", e)))?;
if regex.is_match(value) {
Ok(())
} else {
let msg = message.clone().unwrap_or_else(|| "Pattern mismatch".to_string());
Err(FraiseQLError::Validation {
message: format!(
"Field validation failed: {}",
ValidationFieldError::new(field_path, "pattern", msg)
),
path: Some(field_path.to_string()),
})
}
},
ValidationRule::Length { min, max } => {
let len = value.len();
let valid = if let Some(m) = min { len >= *m } else { true }
&& if let Some(m) = max { len <= *m } else { true };
if valid {
Ok(())
} else {
let msg = match (min, max) {
(Some(m), Some(x)) => format!("Length must be between {} and {}", m, x),
(Some(m), None) => format!("Length must be at least {}", m),
(None, Some(x)) => format!("Length must be at most {}", x),
(None, None) => "Length validation failed".to_string(),
};
Err(FraiseQLError::Validation {
message: format!(
"Field validation failed: {}",
ValidationFieldError::new(field_path, "length", msg)
),
path: Some(field_path.to_string()),
})
}
},
ValidationRule::Enum { values } => {
if values.contains(&value.to_string()) {
Ok(())
} else {
Err(FraiseQLError::Validation {
message: format!(
"Field validation failed: {}",
ValidationFieldError::new(
field_path,
"enum",
format!("Must be one of: {}", values.join(", "))
)
),
path: Some(field_path.to_string()),
})
}
},
_ => Ok(()), }
}
fn extract_field_error(message: &str) -> Option<ValidationFieldError> {
if message.contains("Field validation failed:") {
if let Some(field_start) = message.find("Field validation failed: ") {
let rest = &message[field_start + "Field validation failed: ".len()..];
if let Some(paren_start) = rest.find('(') {
if let Some(paren_end) = rest.find(')') {
let field = rest[..paren_start].trim().to_string();
let rule_type = rest[paren_start + 1..paren_end].to_string();
let msg_start = rest.find(": ").unwrap_or(0) + 2;
let message_text = rest[msg_start..].to_string();
return Some(ValidationFieldError::new(field, rule_type, message_text));
}
}
}
}
None
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn test_validation_error_collection() {
let mut errors = ValidationErrorCollection::new();
assert!(errors.is_empty());
errors.add_error(ValidationFieldError::new("email", "pattern", "Invalid email"));
assert!(!errors.is_empty());
assert_eq!(errors.len(), 1);
}
#[test]
fn test_validation_error_collection_to_error() {
let mut errors = ValidationErrorCollection::new();
errors.add_error(ValidationFieldError::new("email", "pattern", "Invalid email"));
let err = errors.to_error();
assert!(matches!(err, FraiseQLError::Validation { .. }));
}
#[test]
fn test_validate_required_field() {
let rule = ValidationRule::Required;
let result = validate_string_field("value", "field", &rule);
result.unwrap_or_else(|e| panic!("expected Ok for non-empty value: {e}"));
let result = validate_string_field("", "field", &rule);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for empty required field, got: {result:?}"
);
}
#[test]
fn test_validate_pattern() {
let rule = ValidationRule::Pattern {
pattern: "^[a-z]+$".to_string(),
message: None,
};
let result = validate_string_field("hello", "field", &rule);
result.unwrap_or_else(|e| panic!("expected Ok for matching pattern: {e}"));
let result = validate_string_field("Hello", "field", &rule);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for non-matching pattern, got: {result:?}"
);
}
#[test]
fn test_validate_length() {
let rule = ValidationRule::Length {
min: Some(3),
max: Some(10),
};
let result = validate_string_field("hello", "field", &rule);
result.unwrap_or_else(|e| panic!("expected Ok for in-range length: {e}"));
let result = validate_string_field("hi", "field", &rule);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for too-short string, got: {result:?}"
);
let result = validate_string_field("this is too long", "field", &rule);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for too-long string, got: {result:?}"
);
}
#[test]
fn test_validate_enum() {
let rule = ValidationRule::Enum {
values: vec!["active".to_string(), "inactive".to_string()],
};
let result = validate_string_field("active", "field", &rule);
result.unwrap_or_else(|e| panic!("expected Ok for valid enum value: {e}"));
let result = validate_string_field("unknown", "field", &rule);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for invalid enum value, got: {result:?}"
);
}
#[test]
fn test_validate_null_field() {
let rule = ValidationRule::Required;
let result = validate_input(&Value::Null, "field", &[rule]);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for null required field, got: {result:?}"
);
}
#[test]
fn test_validate_custom_scalar_library_code_valid() {
use crate::{
schema::CompiledSchema,
validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
};
let schema = {
let mut s = CompiledSchema::new();
let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
let mut def = CustomTypeDef::new("LibraryCode".to_string());
def.validation_rules = vec![ValidationRule::Pattern {
pattern: r"^LIB-[0-9]{4}$".to_string(),
message: Some("Library code must be LIB-#### format".to_string()),
}];
registry.register("LibraryCode".to_string(), def).unwrap();
s.custom_scalars = registry;
s
};
let value = serde_json::json!("LIB-1234");
let result = validate_custom_scalar_from_schema(&value, "LibraryCode", &schema);
result.unwrap_or_else(|e| panic!("expected Ok for valid LibraryCode: {e}"));
}
#[test]
fn test_validate_custom_scalar_library_code_invalid() {
use crate::{
schema::CompiledSchema,
validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
};
let schema = {
let mut s = CompiledSchema::new();
let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
let mut def = CustomTypeDef::new("LibraryCode".to_string());
def.validation_rules = vec![ValidationRule::Pattern {
pattern: r"^LIB-[0-9]{4}$".to_string(),
message: Some("Library code must be LIB-#### format".to_string()),
}];
registry.register("LibraryCode".to_string(), def).unwrap();
s.custom_scalars = registry;
s
};
let value = serde_json::json!("INVALID");
let result = validate_custom_scalar_from_schema(&value, "LibraryCode", &schema);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for invalid LibraryCode, got: {result:?}"
);
}
#[test]
fn test_validate_custom_scalar_student_id_with_length() {
use crate::{
schema::CompiledSchema,
validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
};
let schema = {
let mut s = CompiledSchema::new();
let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
let mut def = CustomTypeDef::new("StudentID".to_string());
def.validation_rules = vec![
ValidationRule::Pattern {
pattern: r"^STU-[0-9]{4}-[0-9]{3}$".to_string(),
message: None,
},
ValidationRule::Length {
min: Some(12),
max: Some(12),
},
];
registry.register("StudentID".to_string(), def).unwrap();
s.custom_scalars = registry;
s
};
let value = serde_json::json!("STU-2024-001");
let result = validate_custom_scalar_from_schema(&value, "StudentID", &schema);
result.unwrap_or_else(|e| panic!("expected Ok for valid StudentID: {e}"));
let value = serde_json::json!("STUDENT-2024");
let result = validate_custom_scalar_from_schema(&value, "StudentID", &schema);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for invalid StudentID, got: {result:?}"
);
}
#[test]
fn test_validate_unknown_scalar_type_passthrough() {
use crate::schema::CompiledSchema;
let schema = CompiledSchema::new();
let value = serde_json::json!("any value");
let result = validate_custom_scalar_from_schema(&value, "UnknownType", &schema);
result.unwrap_or_else(|e| panic!("expected Ok for unknown scalar passthrough: {e}"));
}
#[test]
fn test_validate_custom_scalar_patient_id_passthrough() {
use crate::schema::CompiledSchema;
let schema = CompiledSchema::new();
let value = serde_json::json!("PAT-123456");
let result = validate_custom_scalar_from_schema(&value, "PatientID", &schema);
result
.unwrap_or_else(|e| panic!("expected Ok for unregistered PatientID passthrough: {e}"));
}
#[test]
fn test_validate_custom_scalar_with_elo_expression() {
use crate::{
schema::CompiledSchema,
validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
};
let schema = {
let mut s = CompiledSchema::new();
let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
let mut def = CustomTypeDef::new("StudentID".to_string());
def.elo_expression = Some("matches(value, \"^STU-[0-9]{4}-[0-9]{3}$\")".to_string());
registry.register("StudentID".to_string(), def).unwrap();
s.custom_scalars = registry;
s
};
let value = serde_json::json!("STU-2024-001");
let result = validate_custom_scalar_from_schema(&value, "StudentID", &schema);
result.unwrap_or_else(|e| panic!("expected Ok for StudentID matching ELO expression: {e}"));
let value = serde_json::json!("INVALID");
let result = validate_custom_scalar_from_schema(&value, "StudentID", &schema);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for StudentID not matching ELO expression, got: {result:?}"
);
}
#[test]
fn test_validate_custom_scalar_combined_rules_and_elo() {
use crate::{
schema::CompiledSchema,
validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
};
let schema = {
let mut s = CompiledSchema::new();
let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
let mut def = CustomTypeDef::new("PatientID".to_string());
def.validation_rules = vec![ValidationRule::Length {
min: Some(10),
max: Some(10),
}];
def.elo_expression = Some("matches(value, \"^PAT-[0-9]{6}$\")".to_string());
registry.register("PatientID".to_string(), def).unwrap();
s.custom_scalars = registry;
s
};
let value = serde_json::json!("PAT-123456");
let result = validate_custom_scalar_from_schema(&value, "PatientID", &schema);
result.unwrap_or_else(|e| panic!("expected Ok for valid PatientID: {e}"));
let value = serde_json::json!("NOTVALID!");
let result = validate_custom_scalar_from_schema(&value, "PatientID", &schema);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for PatientID failing ELO expression, got: {result:?}"
);
let value = serde_json::json!("PAT-12345");
let result = validate_custom_scalar_from_schema(&value, "PatientID", &schema);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for PatientID failing length rule, got: {result:?}"
);
}
}