use crate::schema::{BaseType, Column, ValueRange};
use super::doc_name;
use super::emit::MAX_WIDTH;
use super::sql::str_literal;
pub fn push_measured_id(output: &mut String, name: &str) {
output.push_str(&format!(
"impl validator::ValidateLength<u64> for {name} {{\n fn length(&self) -> Option<u64> {{\n validator::ValidateLength::length(&self.0)\n }}\n}}\n\n"
));
}
pub fn push_ranged_id(output: &mut String, name: &str, inner: &str) {
output.push_str(&format!(
"impl validator::ValidateRange<{inner}> for {name} {{\n fn greater_than(&self, max: {inner}) -> Option<bool> {{\n validator::ValidateRange::greater_than(&self.0, max)\n }}\n\n fn less_than(&self, min: {inner}) -> Option<bool> {{\n validator::ValidateRange::less_than(&self.0, min)\n }}\n}}\n\n"
));
}
const ATTR_FN_LIKE_WIDTH: usize = 70;
pub fn push_field_rule(output: &mut String, column: &Column) {
if let Some(rule) = length_rule(column).or_else(|| range_rule(column)) {
push_rule(output, rule.kind.name(), &rule.args);
}
}
struct Rule {
kind: RuleKind,
args: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuleKind {
Length,
Range,
DecimalRange,
}
impl RuleKind {
fn name(self) -> &'static str {
match self {
RuleKind::Length => "length",
RuleKind::Range => "range",
RuleKind::DecimalRange => "custom",
}
}
}
const DECIMAL: &str = "rust_decimal::Decimal";
fn range_rule(column: &Column) -> Option<Rule> {
let range = column.value_range.as_ref()?;
let base = column.base_type();
if column.typed_id.is_some() && base == BaseType::Decimal {
return None;
}
let (float, decimal) = (base == BaseType::Float, base == BaseType::Decimal);
if !(float || decimal || base == BaseType::Integer) {
return None;
}
let mut args = Vec::new();
if !decimal {
let literal = |value: &str| {
if float && !value.contains('.') {
format!("{value}.0")
} else {
value.to_owned()
}
};
for (name, bound) in [("min", &range.min), ("max", &range.max)] {
if let Some(bound) = bound {
let exclusive = if bound.exclusive { "exclusive_" } else { "" };
args.push(format!("{exclusive}{name} = {}", literal(&bound.value)));
}
}
} else {
args.push(format!("function = \"{}\"", decimal_check_name(column)));
args.push("code = \"range\"".to_owned());
}
let message = format!("{} must be {}", column.sql_name, range_phrase(range));
args.push(format!("message = {}", str_literal(&message)));
let kind = if decimal {
RuleKind::DecimalRange
} else {
RuleKind::Range
};
Some(Rule { kind, args })
}
fn range_phrase(range: &ValueRange) -> String {
let lower = range.min.as_ref().map(|bound| {
let word = if bound.exclusive {
"greater than"
} else {
"at least"
};
format!("{word} {}", bound.value)
});
let upper = range.max.as_ref().map(|bound| {
let word = if bound.exclusive {
"less than"
} else {
"at most"
};
format!("{word} {}", bound.value)
});
match (&range.min, &range.max) {
(Some(min), Some(max)) if !min.exclusive && !max.exclusive => {
format!("between {} and {}", min.value, max.value)
}
_ => [lower, upper]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" and "),
}
}
fn decimal_check_name(column: &Column) -> String {
format!("{}_in_range", column.rust_name)
}
pub fn push_decimal_checks<'a>(output: &mut String, columns: impl IntoIterator<Item = &'a Column>) {
let mut done: Vec<&str> = Vec::new();
for column in columns {
if done.contains(&column.sql_name.as_str())
|| range_rule(column).is_none_or(|rule| rule.kind != RuleKind::DecimalRange)
{
continue;
}
done.push(&column.sql_name);
let Some(range) = &column.value_range else {
continue;
};
let name = decimal_check_name(column);
output.push_str(&format!(
"\n/// The range rule of the {} column.\n",
doc_name(&column.sql_name)
));
let one_line =
format!("fn {name}(value: &{DECIMAL}) -> Result<(), validator::ValidationError> {{");
if one_line.len() <= MAX_WIDTH {
output.push_str(&one_line);
output.push('\n');
} else {
output.push_str(&format!(
"fn {name}(\n value: &{DECIMAL},\n) -> Result<(), validator::ValidationError> {{\n"
));
}
let mut bounds = Vec::new();
let mut terms = Vec::new();
for (name, bound, outside, outside_exclusive) in [
("min", &range.min, "<", "<="),
("max", &range.max, ">", ">="),
] {
let Some(bound) = bound else { continue };
let (param, op) = if bound.exclusive {
(format!("exclusive_{name}"), outside_exclusive)
} else {
(name.to_owned(), outside)
};
output.push_str(&format!(
" let {param} = {};\n",
decimal_expr(&bound.value)
));
terms.push(format!("*value {op} {param}"));
bounds.push(param);
}
push_or_condition(output, &terms);
output.push_str(" let mut error = validator::ValidationError::new(\"range\");\n");
for param in &bounds {
output.push_str(&format!(
" error.add_param(\"{param}\".into(), &{param});\n"
));
}
output.push_str(" return Err(error);\n }\n Ok(())\n}\n");
}
}
fn push_or_condition(output: &mut String, terms: &[String]) {
let one_line = format!(" if {} {{", terms.join(" || "));
if one_line.len() <= MAX_WIDTH {
output.push_str(&one_line);
output.push('\n');
return;
}
output.push_str(&format!(" if {}\n", terms[0]));
for term in &terms[1..] {
output.push_str(&format!(" || {term}\n"));
}
output.push_str(" {\n");
}
fn decimal_expr(value: &str) -> String {
let scale = value
.split_once('.')
.map_or(0, |(_, fraction)| fraction.len());
let digits = value.replace('.', "");
let mantissa: i128 = digits.parse().expect("a range bound is a number literal");
format!("{DECIMAL}::from_i128_with_scale({mantissa}, {scale})")
}
fn push_rule(output: &mut String, kind: &str, args: &[String]) {
let joined = args.join(", ");
if joined.len() <= ATTR_FN_LIKE_WIDTH {
output.push_str(&format!(" #[validate({kind}({joined}))]\n"));
return;
}
output.push_str(&format!(" #[validate({kind}(\n"));
for arg in args {
output.push_str(&format!(" {arg},\n"));
}
output.truncate(output.len() - 2);
output.push_str("\n ))]\n");
}
fn length_rule(column: &Column) -> Option<Rule> {
if !holds_text(column) {
return None;
}
let checked = column.length_range;
let min = checked.and_then(|range| range.min);
let max = [column.declared_length, checked.and_then(|range| range.max)]
.into_iter()
.flatten()
.min();
let name = &column.sql_name;
let (mut args, message) = match (min, max) {
(None, None) => return None,
(Some(min), Some(max)) if min == max => (
vec![format!("equal = {min}")],
format!("{name} must be exactly {}", characters(min)),
),
(Some(min), Some(max)) => (
vec![format!("min = {min}"), format!("max = {max}")],
format!("{name} must be between {min} and {}", characters(max)),
),
(Some(min), None) => (
vec![format!("min = {min}")],
format!("{name} must be at least {}", characters(min)),
),
(None, Some(max)) => (
vec![format!("max = {max}")],
format!("{name} must be at most {}", characters(max)),
),
};
args.push(format!("message = {}", str_literal(&message)));
Some(Rule {
kind: RuleKind::Length,
args,
})
}
fn holds_text(column: &Column) -> bool {
column.base_type() == BaseType::Text
}
fn characters(count: u32) -> String {
if count == 1 {
"1 character".to_owned()
} else {
format!("{count} characters")
}
}