use crate::i18n::Text;
use crate::value::{FieldKind, FieldType, Value};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer, Serialize};
use std::fmt;
use std::ops::Deref;
pub trait Model: Clone + Default + Serialize + DeserializeOwned + Send + Sync + 'static {
const NAME: &'static str;
const SENSITIVE: bool = false;
fn fields() -> &'static [FieldMeta];
fn value_at(&self, index: usize) -> Option<Value>;
fn validate(&self) -> Result<(), ValidationErrors>;
}
pub trait Entity: Model {
const TABLE: &'static str;
}
pub(crate) fn check_embeds<M: Model>(model: &M, csp: &crate::csp::Csp, errors: &mut ValidationErrors) {
for (i, meta) in M::fields().iter().enumerate() {
if !meta.rules.contains(&Rule::Embed) || errors.errors.iter().any(|e| e.field == meta.name) {
continue;
}
if let Some(Value::Text(url)) = model.value_at(i) {
if !url.trim().is_empty() && !csp.allows_frame(&url) {
let host = url.trim_start_matches("https://").split(['/', '?', '#']).next().unwrap_or("").to_string();
errors.errors.push(FieldError {
field: meta.name,
rule: Rule::Embed,
message: Text::key("zeyn.validation.embed_host").arg("label", meta.label_text()).arg("host", host),
});
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "rule", content = "value", rename_all = "snake_case")]
pub enum Rule {
Required,
Unique,
Email,
MinLen(usize),
MaxLen(usize),
Min(i64),
Max(i64),
Scale(u8),
Precision(u8),
Mask(&'static str),
Embed,
Format,
Custom,
Line {
row: u32,
field: &'static str,
},
}
#[derive(Debug, Serialize)]
pub struct FieldMeta {
pub model: &'static str,
pub name: &'static str,
pub label: &'static str,
pub kind: FieldKind,
pub rules: &'static [Rule],
pub key: bool,
pub readonly: bool,
pub version: bool,
pub optional: bool,
pub mask: Option<crate::privacy::Masking>,
}
impl FieldMeta {
pub fn label_text(&self) -> Text {
Text::key_or(format!("{}.{}", self.model, self.name), self.label)
}
pub fn is_required(&self) -> bool {
self.rules.contains(&Rule::Required)
}
pub fn max_len(&self) -> Option<usize> {
self.rules.iter().find_map(|r| match r {
Rule::MaxLen(n) => Some(*n),
_ => None,
})
}
pub fn min_len(&self) -> Option<usize> {
self.rules.iter().find_map(|r| match r {
Rule::MinLen(n) => Some(*n),
_ => None,
})
}
pub fn min(&self) -> Option<i64> {
self.rules.iter().find_map(|r| match r {
Rule::Min(n) => Some(*n),
_ => None,
})
}
pub fn decimal_limits(&self) -> (u8, u8) {
let scale = self.rules.iter().find_map(|r| if let Rule::Scale(n) = r { Some(*n) } else { None });
let precision = self.rules.iter().find_map(|r| if let Rule::Precision(n) = r { Some(*n) } else { None });
let scale = scale.unwrap_or(crate::decimal::DEFAULT_SCALE).min(crate::decimal::MAX_SCALE);
let precision =
precision.unwrap_or(crate::decimal::DEFAULT_PRECISION).clamp(scale.max(1), crate::decimal::MAX_PRECISION);
(precision, scale)
}
pub fn mask_pattern(&self) -> Option<&'static str> {
self.rules.iter().find_map(|r| if let Rule::Mask(p) = r { Some(*p) } else { None })
}
pub fn max(&self) -> Option<i64> {
self.rules.iter().find_map(|r| match r {
Rule::Max(n) => Some(*n),
_ => None,
})
}
}
pub struct Field<M, T> {
meta: &'static FieldMeta,
get: fn(&M) -> &T,
}
impl<M, T> Field<M, T> {
#[doc(hidden)]
pub const fn new(meta: &'static FieldMeta, get: fn(&M) -> &T) -> Self {
Self { meta, get }
}
pub fn meta(&self) -> &'static FieldMeta {
self.meta
}
pub fn get<'a>(&self, model: &'a M) -> &'a T {
(self.get)(model)
}
}
impl<M, T> Clone for Field<M, T> {
fn clone(&self) -> Self {
*self
}
}
impl<M, T> Copy for Field<M, T> {}
impl<M, T> fmt::Debug for Field<M, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Field({})", self.meta.name)
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct FieldError {
pub field: &'static str,
pub rule: Rule,
pub message: Text,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct ValidationErrors {
pub errors: Vec<FieldError>,
}
impl ValidationErrors {
pub fn add<M, T>(&mut self, field: Field<M, T>, message: impl Into<Text>) {
self.errors.push(FieldError { field: field.meta().name, rule: Rule::Custom, message: message.into() });
}
pub fn add_general(&mut self, message: impl Into<Text>) {
self.errors.push(FieldError { field: "", rule: Rule::Custom, message: message.into() });
}
pub fn into_result(self) -> Result<(), ValidationErrors> {
if self.errors.is_empty() { Ok(()) } else { Err(self) }
}
pub fn for_field(&self, field: &str) -> impl Iterator<Item = &FieldError> {
self.errors.iter().filter(move |e| e.field == field)
}
}
impl fmt::Display for ValidationErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let messages: Vec<String> = self.errors.iter().map(|e| e.message.english()).collect();
f.write_str(&messages.join(" "))
}
}
impl std::error::Error for ValidationErrors {}
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct Valid<T>(T);
impl<T: Model> Valid<T> {
pub fn new(value: T) -> Result<Self, ValidationErrors> {
value.validate()?;
Ok(Self(value))
}
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> Deref for Valid<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<'de, T: Model> Deserialize<'de> for Valid<T> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = T::deserialize(deserializer)?;
Valid::new(value).map_err(serde::de::Error::custom)
}
}
fn looks_like_email(s: &str) -> bool {
let Some((local, domain)) = s.split_once('@') else { return false };
!local.is_empty()
&& !domain.contains('@')
&& !s.chars().any(char::is_whitespace)
&& domain.split('.').count() >= 2
&& domain.split('.').all(|part| !part.is_empty())
}
#[doc(hidden)]
pub mod __rules {
use super::*;
use crate::value::{SupportsMinMax, SupportsRequired, SupportsScale, SupportsText};
use std::cmp::Ordering;
pub fn required<T: SupportsRequired>() {}
pub fn text<T: SupportsText>() {}
pub fn min_max<T: SupportsMinMax>() {}
pub fn scale<T: SupportsScale>() {}
pub fn version<T: IsVersion>() {}
#[diagnostic::on_unimplemented(
message = "`#[version]` cannot be used on `{Self}`",
label = "a row version must be an i64",
note = "declare the field as `pub version: i64`"
)]
pub trait IsVersion {}
impl IsVersion for i64 {}
fn limit_text(meta: &FieldMeta, n: i64) -> String {
match (meta.kind, crate::time::Date::from_compact(n)) {
(crate::value::FieldKind::Date, Some(d)) => d.to_string(),
_ => n.to_string(),
}
}
pub fn validate<T: FieldType>(value: &T, meta: &FieldMeta, errors: &mut ValidationErrors) {
let empty = value.__is_empty();
let message = |key: &'static str| Text::key(key).arg("label", meta.label_text());
for rule in meta.rules {
let text = match *rule {
Rule::Required if empty => Some(message("zeyn.validation.required")),
Rule::MinLen(n) if !empty && value.__text_len().is_some_and(|len| len < n) => {
Some(message("zeyn.validation.min_len").arg("n", n.to_string()))
}
Rule::MaxLen(n) if value.__text_len().is_some_and(|len| len > n) => {
Some(message("zeyn.validation.max_len").arg("n", n.to_string()))
}
Rule::Email if !empty && value.__text().is_some_and(|s| !looks_like_email(s.trim())) => {
Some(message("zeyn.validation.email"))
}
Rule::Min(n) if value.__compare(n) == Some(Ordering::Less) => {
Some(message("zeyn.validation.min").arg("n", limit_text(meta, n)))
}
Rule::Max(n) if value.__compare(n) == Some(Ordering::Greater) => {
Some(message("zeyn.validation.max").arg("n", limit_text(meta, n)))
}
Rule::Mask(p) if !empty && value.__text().is_some_and(|s| !crate::ui::mask_fits(p, s)) => {
Some(message("zeyn.validation.mask").arg("pattern", crate::ui::mask_example(p)))
}
Rule::Embed
if !empty && value.__text().is_some_and(|s| crate::csp::embed_url(s).as_deref() != Some(s)) =>
{
Some(message("zeyn.validation.embed"))
}
_ => None,
};
if let Some(message) = text {
errors.errors.push(FieldError { field: meta.name, rule: *rule, message });
}
}
if let Some(d) = value.__decimal() {
let (precision, scale) = meta.decimal_limits();
let problem = if d.fraction_digits() > scale {
Some((Rule::Scale(scale), message("zeyn.validation.scale").arg("n", scale.to_string())))
} else if d.integer_digits() > precision - scale {
let whole = (precision - scale).to_string();
Some((Rule::Precision(precision), message("zeyn.validation.precision").arg("n", whole)))
} else {
None
};
if let Some((rule, message)) = problem {
errors.errors.push(FieldError { field: meta.name, rule, message });
}
}
value.__validate_items(meta, errors);
}
}