use quote::quote;
use prax_schema::ast::{FieldType, Model, ScalarType};
use crate::plugins::{Plugin, PluginContext, PluginOutput};
pub struct ValidatorPlugin;
impl Plugin for ValidatorPlugin {
fn name(&self) -> &'static str {
"validator"
}
fn env_var(&self) -> &'static str {
"PRAX_PLUGIN_VALIDATOR"
}
fn description(&self) -> &'static str {
"Generates runtime validation methods for model constraints"
}
fn on_start(&self, _ctx: &PluginContext) -> PluginOutput {
PluginOutput::with_tokens(quote! {
pub mod _validation {
#[derive(Debug, Clone)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
impl ValidationError {
pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
Self {
field: field.into(),
message: message.into(),
}
}
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.field, self.message)
}
}
impl std::error::Error for ValidationError {}
pub type ValidationResult = Result<(), Vec<ValidationError>>;
pub trait Validate {
fn validate(&self) -> ValidationResult;
fn is_valid(&self) -> bool {
self.validate().is_ok()
}
}
}
})
}
fn on_model(&self, _ctx: &PluginContext, model: &Model) -> PluginOutput {
let model_name = model.name();
let validations: Vec<_> = model
.fields
.values()
.filter_map(|field| {
let field_name = field.name();
let field_name_str = field_name.to_string();
let mut checks = Vec::new();
if field.has_attribute("unique") {
}
if matches!(field.field_type, FieldType::Scalar(ScalarType::String)) {
for attr in &field.attributes {
if attr.name() == "db" {
}
}
}
if !field.modifier.is_optional()
&& let FieldType::Scalar(ScalarType::String) = &field.field_type
{
checks.push(quote! {
if self.#field_name.is_empty() {
errors.push(super::super::_validation::ValidationError::new(
#field_name_str,
"cannot be empty"
));
}
});
}
if field.modifier.is_optional()
&& let FieldType::Scalar(ScalarType::String) = &field.field_type
{
checks.push(quote! {
if let Some(ref val) = self.#field_name {
if val.is_empty() {
errors.push(super::super::_validation::ValidationError::new(
#field_name_str,
"if provided, cannot be empty"
));
}
}
});
}
let is_email = field_name.to_lowercase().contains("email")
|| field.attributes.iter().any(|a| a.name() == "email");
if is_email && matches!(field.field_type, FieldType::Scalar(ScalarType::String)) {
let email_check = if field.modifier.is_optional() {
quote! {
if let Some(ref email) = self.#field_name {
if !email.contains('@') || !email.contains('.') {
errors.push(super::super::_validation::ValidationError::new(
#field_name_str,
"must be a valid email address"
));
}
}
}
} else {
quote! {
if !self.#field_name.contains('@') || !self.#field_name.contains('.') {
errors.push(super::super::_validation::ValidationError::new(
#field_name_str,
"must be a valid email address"
));
}
}
};
checks.push(email_check);
}
if checks.is_empty() {
None
} else {
Some(quote! { #(#checks)* })
}
})
.collect();
let has_validations = !validations.is_empty();
PluginOutput::with_tokens(quote! {
pub mod _validator {
use super::*;
pub const HAS_VALIDATIONS: bool = #has_validations;
impl super::super::_validation::Validate for super::#model_name {
fn validate(&self) -> super::super::_validation::ValidationResult {
let mut errors = Vec::new();
#(#validations)*
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use prax_schema::Schema;
use prax_schema::ast::{
Attribute, AttributeArg, AttributeValue, Field, Ident, Span, TypeModifier,
};
fn make_span() -> Span {
Span::new(0, 0)
}
fn make_ident(name: &str) -> Ident {
Ident::new(name, make_span())
}
fn make_attribute(name: &str) -> Attribute {
Attribute::simple(make_ident(name), make_span())
}
fn make_db_attribute(value: &str) -> Attribute {
Attribute::new(
make_ident("db"),
vec![AttributeArg::positional(
AttributeValue::String(value.into()),
make_span(),
)],
make_span(),
)
}
#[test]
fn test_validator_plugin_start() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let plugin = ValidatorPlugin;
let output = plugin.on_start(&ctx);
let code = output.tokens.to_string();
assert!(code.contains("_validation"));
assert!(code.contains("ValidationError"));
assert!(code.contains("Validate"));
}
#[test]
fn test_validator_plugin_model() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("email"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Required,
vec![],
make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
let code = output.tokens.to_string();
assert!(code.contains("_validator"));
assert!(code.contains("validate"));
}
#[test]
fn test_validator_plugin_email_field() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("email"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Required,
vec![],
make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
let code = output.tokens.to_string();
assert!(code.contains("valid email"));
}
#[test]
fn test_validator_plugin_name() {
let plugin = ValidatorPlugin;
assert_eq!(plugin.name(), "validator");
}
#[test]
fn test_validator_plugin_env_var() {
let plugin = ValidatorPlugin;
assert_eq!(plugin.env_var(), "PRAX_PLUGIN_VALIDATOR");
}
#[test]
fn test_validator_plugin_description() {
let plugin = ValidatorPlugin;
assert!(plugin.description().contains("validation"));
}
#[test]
fn test_validator_plugin_start_contains_validation_trait() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let plugin = ValidatorPlugin;
let output = plugin.on_start(&ctx);
let code = output.tokens.to_string();
assert!(code.contains("trait Validate"));
assert!(code.contains("fn validate"));
assert!(code.contains("fn is_valid"));
}
#[test]
fn test_validator_plugin_start_contains_validation_error() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let plugin = ValidatorPlugin;
let output = plugin.on_start(&ctx);
let code = output.tokens.to_string();
assert!(code.contains("struct ValidationError"));
assert!(code.contains("pub field"));
assert!(code.contains("pub message"));
assert!(code.contains("fn new"));
}
#[test]
fn test_validator_plugin_start_contains_validation_result() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let plugin = ValidatorPlugin;
let output = plugin.on_start(&ctx);
let code = output.tokens.to_string();
assert!(code.contains("ValidationResult"));
}
#[test]
fn test_validator_plugin_optional_email_field() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("alternativeEmail"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Optional,
vec![],
make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
let code = output.tokens.to_string();
assert!(code.contains("if let Some"));
assert!(code.contains("valid email"));
}
#[test]
fn test_validator_plugin_required_string_field() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("name"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Required,
vec![],
make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
let code = output.tokens.to_string();
assert!(code.contains("is_empty"));
assert!(code.contains("cannot be empty"));
}
#[test]
fn test_validator_plugin_optional_string_field() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("bio"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Optional,
vec![],
make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
let code = output.tokens.to_string();
assert!(code.contains("if provided, cannot be empty"));
}
#[test]
fn test_validator_plugin_non_string_fields_no_empty_check() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("age"),
FieldType::Scalar(ScalarType::Int),
TypeModifier::Required,
vec![],
make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
let code = output.tokens.to_string();
assert!(code.contains("HAS_VALIDATIONS : bool = false"));
}
#[test]
fn test_validator_plugin_model_with_unique_attribute() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("username"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Required,
vec![make_attribute("unique")],
make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
assert!(!output.tokens.is_empty());
}
#[test]
fn test_validator_plugin_model_with_db_attribute() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("description"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Required,
vec![make_db_attribute("VarChar(255)")],
make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
assert!(!output.tokens.is_empty());
}
#[test]
fn test_validator_plugin_model_generates_has_validations_const() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let model = Model::new(make_ident("EmptyModel"), make_span());
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
let code = output.tokens.to_string();
assert!(code.contains("HAS_VALIDATIONS"));
}
#[test]
fn test_validator_plugin_email_field_with_attribute() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("contactAddress"), FieldType::Scalar(ScalarType::String),
TypeModifier::Required,
vec![make_attribute("email")], make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
let code = output.tokens.to_string();
assert!(code.contains("valid email"));
}
#[test]
fn test_validator_plugin_model_with_multiple_fields() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let mut model = Model::new(make_ident("User"), make_span());
model.add_field(Field::new(
make_ident("email"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Required,
vec![],
make_span(),
));
model.add_field(Field::new(
make_ident("name"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Required,
vec![],
make_span(),
));
model.add_field(Field::new(
make_ident("bio"),
FieldType::Scalar(ScalarType::String),
TypeModifier::Optional,
vec![],
make_span(),
));
let plugin = ValidatorPlugin;
let output = plugin.on_model(&ctx, &model);
let code = output.tokens.to_string();
assert!(code.contains("HAS_VALIDATIONS : bool = true"));
}
#[test]
fn test_validator_plugin_validation_error_implements_display() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let plugin = ValidatorPlugin;
let output = plugin.on_start(&ctx);
let code = output.tokens.to_string();
assert!(code.contains("impl std :: fmt :: Display"));
}
#[test]
fn test_validator_plugin_validation_error_implements_error() {
let schema = Schema::new();
let config = crate::plugins::PluginConfig::new();
let ctx = PluginContext::new(&schema, &config);
let plugin = ValidatorPlugin;
let output = plugin.on_start(&ctx);
let code = output.tokens.to_string();
assert!(code.contains("impl std :: error :: Error"));
}
}