Skip to main content

tablo_core/schema/
validation.rs

1//! The typed values a text field parses, and the email rule.
2
3use email_address::{EmailAddress, Options};
4
5/// Parses a typed column's text submission and names the type in the error it produces.
6pub trait TypedValue: std::fmt::Display + std::str::FromStr {
7    /// What this type is called in a validation error.
8    const NOUN: &'static str;
9
10    /// The `type` attribute of the text control that edits it.
11    const INPUT_TYPE: &'static str = "text";
12
13    /// Reads a trimmed, non-empty submission, or `None` when the type refuses it.
14    fn parse_input(value: &str) -> Option<Self> {
15        value.parse().ok()
16    }
17}
18
19/// Binds the integer types a typed field accepts.
20macro_rules! typed_whole_number {
21    ($($ty:ty),* $(,)?) => {
22        $(
23            impl TypedValue for $ty {
24                const NOUN: &'static str = "whole number";
25            }
26        )*
27    };
28}
29
30typed_whole_number!(
31    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize
32);
33
34impl TypedValue for bool {
35    const NOUN: &'static str = "yes/no value";
36}
37
38/// Accepts finite values only.
39impl TypedValue for f32 {
40    const NOUN: &'static str = "number";
41
42    fn parse_input(value: &str) -> Option<Self> {
43        value
44            .parse::<f32>()
45            .ok()
46            .filter(|parsed| parsed.is_finite())
47    }
48}
49
50impl TypedValue for f64 {
51    const NOUN: &'static str = "number";
52
53    fn parse_input(value: &str) -> Option<Self> {
54        value
55            .parse::<f64>()
56            .ok()
57            .filter(|parsed| parsed.is_finite())
58    }
59}
60
61impl TypedValue for uuid::Uuid {
62    const NOUN: &'static str = "identifier";
63}
64
65impl TypedValue for crate::TenantId {
66    const NOUN: &'static str = "identifier";
67}
68
69/// Binds a timestamp to a `datetime-local` control read back as UTC.
70impl TypedValue for jiff::Timestamp {
71    const NOUN: &'static str = "timestamp";
72    const INPUT_TYPE: &'static str = "datetime-local";
73
74    /// Parses RFC 3339 plus the `datetime-local` shapes a browser sends, read as UTC.
75    fn parse_input(value: &str) -> Option<Self> {
76        if let Ok(parsed) = value.parse::<jiff::Timestamp>() {
77            return Some(parsed);
78        }
79        normalize_datetime_local(value)?.parse().ok()
80    }
81}
82
83/// Converts a `datetime-local` value to the RFC 3339 string a timestamp parses, or `None` when the
84/// shape is not one the control sends.
85fn normalize_datetime_local(value: &str) -> Option<String> {
86    let t = value.find('T')?;
87    let after_t = &value[t + 1..];
88    if after_t.contains('+') || after_t.contains('-') {
89        return None;
90    }
91    if value.ends_with(['Z', 'z']) {
92        return None;
93    }
94    if value.len() == 16 {
95        Some(format!("{value}:00Z"))
96    } else {
97        Some(format!("{value}Z"))
98    }
99}
100
101/// Formats a stored timestamp as the `datetime-local` value its control renders, rendering anything
102/// else empty.
103pub(crate) fn format_timestamp_input(storage: &str) -> String {
104    let trimmed = storage.trim();
105    if trimmed.is_empty() {
106        return String::new();
107    }
108    match trimmed.parse::<jiff::Timestamp>() {
109        Ok(parsed) => parsed.strftime("%Y-%m-%dT%H:%M").to_string(),
110        Err(_) => String::new(),
111    }
112}
113
114/// Caps the longest address the rule accepts.
115const EMAIL_MAX_LENGTH: usize = 254;
116
117/// Reports whether `value` is an address the email rule accepts, requiring a TLD and refusing
118/// display text.
119pub(crate) fn is_email(value: &str) -> bool {
120    value.len() <= EMAIL_MAX_LENGTH
121        && EmailAddress::parse_with_options(
122            value,
123            Options::default()
124                .with_required_tld()
125                .without_display_text(),
126        )
127        .is_ok()
128}
129
130#[cfg(test)]
131mod tests;