Skip to main content

tablo_core/schema/
validation.rs

1//! Applies presence, email, and typed-parse rules to a submitted string.
2
3use email_address::{EmailAddress, Options};
4
5use crate::form::{FieldError, FormScalar};
6
7/// Parses a typed column's text submission and names the type in the error it produces.
8pub trait TypedValue: std::fmt::Display + std::str::FromStr {
9    /// What this type is called in a validation error.
10    const NOUN: &'static str;
11
12    /// The `type` attribute of the text control that edits it.
13    const INPUT_TYPE: &'static str = "text";
14
15    /// Reads a trimmed, non-empty submission, or `None` when the type refuses it.
16    fn parse_input(value: &str) -> Option<Self> {
17        value.parse().ok()
18    }
19}
20
21/// Binds the integer types a typed field accepts.
22macro_rules! typed_whole_number {
23    ($($ty:ty),* $(,)?) => {
24        $(
25            impl TypedValue for $ty {
26                const NOUN: &'static str = "whole number";
27            }
28        )*
29    };
30}
31
32typed_whole_number!(
33    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize
34);
35
36impl TypedValue for bool {
37    const NOUN: &'static str = "yes/no value";
38}
39
40/// Accepts finite values only.
41impl TypedValue for f32 {
42    const NOUN: &'static str = "number";
43
44    fn parse_input(value: &str) -> Option<Self> {
45        value
46            .parse::<f32>()
47            .ok()
48            .filter(|parsed| parsed.is_finite())
49    }
50}
51
52impl TypedValue for f64 {
53    const NOUN: &'static str = "number";
54
55    fn parse_input(value: &str) -> Option<Self> {
56        value
57            .parse::<f64>()
58            .ok()
59            .filter(|parsed| parsed.is_finite())
60    }
61}
62
63impl TypedValue for uuid::Uuid {
64    const NOUN: &'static str = "identifier";
65}
66
67impl TypedValue for crate::TenantId {
68    const NOUN: &'static str = "identifier";
69}
70
71/// Binds a timestamp to a `datetime-local` control read back as UTC.
72impl TypedValue for jiff::Timestamp {
73    const NOUN: &'static str = "timestamp";
74    const INPUT_TYPE: &'static str = "datetime-local";
75
76    /// Parses RFC 3339 plus the `datetime-local` shapes a browser sends, read as UTC.
77    fn parse_input(value: &str) -> Option<Self> {
78        if let Ok(parsed) = value.parse::<jiff::Timestamp>() {
79            return Some(parsed);
80        }
81        normalize_datetime_local(value)?.parse().ok()
82    }
83}
84
85/// Converts a `datetime-local` value to the RFC 3339 string a timestamp parses, or `None` when the
86/// shape is not one the control sends.
87fn normalize_datetime_local(value: &str) -> Option<String> {
88    let t = value.find('T')?;
89    let after_t = &value[t + 1..];
90    if after_t.contains('+') || after_t.contains('-') {
91        return None;
92    }
93    if value.ends_with(['Z', 'z']) {
94        return None;
95    }
96    if value.len() == 16 {
97        Some(format!("{value}:00Z"))
98    } else {
99        Some(format!("{value}Z"))
100    }
101}
102
103/// Formats a stored timestamp as the `datetime-local` value its control renders, rendering anything
104/// else empty.
105pub(crate) fn format_timestamp_input(storage: &str) -> String {
106    let trimmed = storage.trim();
107    if trimmed.is_empty() {
108        return String::new();
109    }
110    match trimmed.parse::<jiff::Timestamp>() {
111        Ok(parsed) => parsed.strftime("%Y-%m-%dT%H:%M").to_string(),
112        Err(_) => String::new(),
113    }
114}
115
116/// Reads a submitted string back as the stored spelling or the error message.
117type ValueParser = fn(&str) -> Result<String, String>;
118
119/// Binds the parser for scalar type `T`, rejecting what `T` refuses and storing what `T`'s own form
120/// spelling produces.
121fn scalar_parser<T: FormScalar>(value: &str) -> Result<String, String> {
122    T::parse_form(value).map(|parsed| parsed.to_form())
123}
124
125/// Holds the rules a field declares on top of presence and the wording of every message they
126/// produce.
127#[derive(Clone, Copy, Default)]
128pub(crate) struct Rules {
129    email: bool,
130    parser: Option<ValueParser>,
131}
132
133impl Rules {
134    /// Holds a field with no declared rule: presence alone.
135    pub(crate) fn new() -> Self {
136        Self::default()
137    }
138
139    /// Adds the parse rule of the scalar type `T`.
140    pub(crate) fn scalar<T: FormScalar>(mut self) -> Self {
141        self.parser = Some(scalar_parser::<T>);
142        self
143    }
144
145    /// Turns on the email rule.
146    pub(crate) fn set_email(&mut self) {
147        self.email = true;
148    }
149
150    /// Reports whether the email rule is on.
151    pub(crate) fn is_email(&self) -> bool {
152        self.email
153    }
154
155    /// Validates `value` in rule order and skips the email and typed-parse rules on an empty
156    /// submit.
157    pub(crate) fn validate(
158        &self,
159        key: &str,
160        label: &str,
161        required: bool,
162        value: &str,
163    ) -> Vec<FieldError> {
164        let v = value.trim();
165        let mut errs = Vec::new();
166        if required && v.is_empty() {
167            errs.push(FieldError::unanswered(key, required_error(label)));
168        }
169        if self.email && !v.is_empty() && !is_email(v) {
170            errs.push(FieldError::invalid(
171                key,
172                format!("{label} must be a valid email"),
173            ));
174        }
175        if !v.is_empty()
176            && errs.is_empty()
177            && let Some(parser) = &self.parser
178            && let Err(message) = parser(v)
179        {
180            errs.push(FieldError::invalid(key, message));
181        }
182        errs
183    }
184
185    /// Returns the stored spelling of an already-validated submission and reports a failure rather
186    /// than guessing.
187    pub(crate) fn normalize(&self, value: &str) -> Result<String, String> {
188        let v = value.trim();
189        match &self.parser {
190            Some(parser) => parser(v),
191            None => Ok(v.to_string()),
192        }
193    }
194}
195
196/// Returns the message for an empty submit.
197pub(crate) fn required_error(label: &str) -> String {
198    format!("{label} is required")
199}
200
201/// Caps the longest address the rule accepts.
202const EMAIL_MAX_LENGTH: usize = 254;
203
204/// Reports whether `value` is an address the email rule accepts, requiring a TLD and refusing
205/// display text.
206fn is_email(value: &str) -> bool {
207    value.len() <= EMAIL_MAX_LENGTH
208        && EmailAddress::parse_with_options(
209            value,
210            Options::default()
211                .with_required_tld()
212                .without_display_text(),
213        )
214        .is_ok()
215}
216
217#[cfg(test)]
218mod tests;