Skip to main content

appcore_filemaker/
data.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: data.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded data contracts and behavior for this crate.
12
13use std::collections::BTreeMap;
14
15use rust_decimal::Decimal;
16use serde::{Deserialize, Serialize};
17
18use crate::{ErrorCode, Expression, ExpressionBudget, FileMakerError, Result};
19
20/// Exact monetary value.
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct CurrencyValue {
24    /// ISO-4217-style uppercase code.
25    pub code: String,
26    /// Exact decimal amount.
27    #[serde(with = "rust_decimal::serde::str")]
28    pub amount: Decimal,
29}
30
31/// Typed data accepted by bindings and datasets.
32#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(tag = "type", content = "value", rename_all = "snake_case")]
34pub enum DataValue {
35    /// UTF-8 string.
36    String(String),
37    /// Signed integer.
38    Integer(i64),
39    /// Exact decimal.
40    Decimal(#[serde(with = "rust_decimal::serde::str")] Decimal),
41    /// Boolean.
42    Boolean(bool),
43    /// ISO `YYYY-MM-DD` date retained losslessly.
44    Date(String),
45    /// RFC-3339-like date-time retained losslessly.
46    DateTime(String),
47    /// Signed duration in milliseconds.
48    Duration(i64),
49    /// Exact monetary value.
50    Currency(CurrencyValue),
51    /// Ordered array.
52    Array(Vec<Self>),
53    /// Deterministically ordered object.
54    Object(BTreeMap<String, Self>),
55    /// Explicit null.
56    Null,
57}
58
59impl DataValue {
60    /// Resolves a dot-separated object path without side effects.
61    #[must_use]
62    pub fn get_path(&self, path: &str) -> Option<&Self> {
63        if path.is_empty() {
64            return Some(self);
65        }
66        let mut value = self;
67        for part in path.split('.') {
68            value = match value {
69                Self::Object(object) => object.get(part)?,
70                Self::Array(array) => array.get(part.parse::<usize>().ok()?)?,
71                _ => return None,
72            };
73        }
74        Some(value)
75    }
76
77    /// Returns a bounded display representation used by text bindings.
78    #[must_use]
79    pub fn display(&self) -> String {
80        match self {
81            Self::String(value) | Self::Date(value) | Self::DateTime(value) => value.clone(),
82            Self::Integer(value) | Self::Duration(value) => value.to_string(),
83            Self::Decimal(value) => value.normalize().to_string(),
84            Self::Boolean(value) => value.to_string(),
85            Self::Currency(value) => format!("{} {}", value.amount.normalize(), value.code),
86            Self::Array(_) => "[array]".to_owned(),
87            Self::Object(_) => "[object]".to_owned(),
88            Self::Null => String::new(),
89        }
90    }
91
92    /// Returns truthiness for conditional rules.
93    #[must_use]
94    pub fn is_truthy(&self) -> bool {
95        match self {
96            Self::Boolean(value) => *value,
97            Self::Null => false,
98            Self::String(value) => !value.is_empty(),
99            Self::Integer(value) | Self::Duration(value) => *value != 0,
100            Self::Decimal(value) => !value.is_zero(),
101            Self::Currency(value) => !value.amount.is_zero(),
102            Self::Array(value) => !value.is_empty(),
103            Self::Object(value) => !value.is_empty(),
104            Self::Date(_) | Self::DateTime(_) => true,
105        }
106    }
107
108    /// Validates bounded structural invariants.
109    pub fn validate(
110        &self,
111        max_depth: usize,
112        max_items: usize,
113        max_text_bytes: usize,
114    ) -> Result<()> {
115        let mut stack = vec![(self, 0_usize)];
116        let mut count = 0_usize;
117        while let Some((value, depth)) = stack.pop() {
118            if depth > max_depth {
119                return Err(data_error("data nesting exceeds configured depth"));
120            }
121            count = count.saturating_add(1);
122            if count > max_items {
123                return Err(data_error("data item count exceeds configured limit"));
124            }
125            match value {
126                Self::String(value) => {
127                    if value.len() > max_text_bytes {
128                        return Err(data_error("data string exceeds configured byte limit"));
129                    }
130                }
131                Self::Date(value) => {
132                    if value.len() > max_text_bytes || !valid_date(value) {
133                        return Err(data_error("date must use a valid YYYY-MM-DD value"));
134                    }
135                }
136                Self::DateTime(value) => {
137                    if value.len() > max_text_bytes || !valid_date_time(value) {
138                        return Err(data_error("date-time must use a valid RFC-3339-like value"));
139                    }
140                }
141                Self::Currency(value) if !valid_currency_code(&value.code) => {
142                    return Err(data_error(
143                        "currency code must be three uppercase ASCII letters",
144                    ));
145                }
146                Self::Array(values) => {
147                    stack.extend(values.iter().rev().map(|item| (item, depth + 1)));
148                }
149                Self::Object(values) => {
150                    if values.keys().any(|key| key.is_empty() || key.len() > 128) {
151                        return Err(data_error("object key is empty or too long"));
152                    }
153                    stack.extend(values.values().rev().map(|item| (item, depth + 1)));
154                }
155                _ => {}
156            }
157        }
158        Ok(())
159    }
160}
161
162/// Declared data type.
163#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum DataType {
166    /// String.
167    String,
168    /// Integer.
169    Integer,
170    /// Decimal.
171    Decimal,
172    /// Boolean.
173    Boolean,
174    /// Date.
175    Date,
176    /// Date-time.
177    DateTime,
178    /// Duration.
179    Duration,
180    /// Currency.
181    Currency,
182    /// Array.
183    Array,
184    /// Object.
185    Object,
186    /// Explicit null.
187    Null,
188}
189
190/// One schema field.
191#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
192pub struct DataField {
193    /// Declared type.
194    pub data_type: DataType,
195    /// Whether null is accepted.
196    pub nullable: bool,
197    /// Optional deterministic computed expression.
198    pub computed: Option<String>,
199}
200
201/// Deterministically ordered schema.
202pub type DataSchema = BTreeMap<String, DataField>;
203
204/// Resolves computed schema fields through a bounded deterministic dependency graph.
205///
206/// Caller values for computed fields are replaced by their declared expression,
207/// making the schema the single source of truth for derived values.
208pub fn resolve_computed_fields(
209    schema: &DataSchema,
210    data: &DataValue,
211    max_expression_steps: usize,
212) -> Result<DataValue> {
213    let DataValue::Object(input) = data else {
214        return Err(data_error("computed schema root requires an object"));
215    };
216    let mut values = input.clone();
217    let mut resolved = std::collections::BTreeSet::new();
218    let mut visiting = std::collections::BTreeSet::new();
219    for name in schema.keys() {
220        resolve_computed_field(
221            name,
222            schema,
223            &mut values,
224            &mut resolved,
225            &mut visiting,
226            max_expression_steps,
227        )?;
228    }
229    let result = DataValue::Object(values);
230    validate_schema(schema, &result)?;
231    Ok(result)
232}
233
234fn resolve_computed_field(
235    name: &str,
236    schema: &DataSchema,
237    values: &mut BTreeMap<String, DataValue>,
238    resolved: &mut std::collections::BTreeSet<String>,
239    visiting: &mut std::collections::BTreeSet<String>,
240    max_expression_steps: usize,
241) -> Result<()> {
242    if resolved.contains(name) {
243        return Ok(());
244    }
245    let Some(field) = schema.get(name) else {
246        return Ok(());
247    };
248    let Some(source) = &field.computed else {
249        resolved.insert(name.to_owned());
250        return Ok(());
251    };
252    if !visiting.insert(name.to_owned()) {
253        return Err(FileMakerError::new(
254            ErrorCode::DataCycle,
255            format!("computed data cycle includes `{name}`"),
256        ));
257    }
258    let expression = Expression::parse(source.clone())?;
259    for dependency in expression.dependencies() {
260        if schema
261            .get(&dependency)
262            .is_some_and(|field| field.computed.is_some())
263        {
264            resolve_computed_field(
265                &dependency,
266                schema,
267                values,
268                resolved,
269                visiting,
270                max_expression_steps,
271            )?;
272        }
273    }
274    let root = DataValue::Object(values.clone());
275    let value = expression.evaluate(&root, &mut ExpressionBudget::new(max_expression_steps)?)?;
276    values.insert(name.to_owned(), value);
277    visiting.remove(name);
278    resolved.insert(name.to_owned());
279    Ok(())
280}
281
282/// Validates one object against a schema.
283pub fn validate_schema(schema: &DataSchema, data: &DataValue) -> Result<()> {
284    let DataValue::Object(object) = data else {
285        return Err(data_error("schema root requires an object"));
286    };
287    for (name, field) in schema {
288        let Some(value) = object.get(name) else {
289            if field.nullable || field.computed.is_some() {
290                continue;
291            }
292            return Err(data_error(format!(
293                "required data field `{name}` is missing"
294            )));
295        };
296        if matches!(value, DataValue::Null) && field.nullable {
297            continue;
298        }
299        if !matches_type(value, field.data_type) {
300            return Err(data_error(format!(
301                "data field `{name}` has the wrong type"
302            )));
303        }
304    }
305    Ok(())
306}
307
308fn matches_type(value: &DataValue, expected: DataType) -> bool {
309    matches!(
310        (value, expected),
311        (DataValue::String(_), DataType::String)
312            | (DataValue::Integer(_), DataType::Integer)
313            | (DataValue::Decimal(_), DataType::Decimal)
314            | (DataValue::Boolean(_), DataType::Boolean)
315            | (DataValue::Date(_), DataType::Date)
316            | (DataValue::DateTime(_), DataType::DateTime)
317            | (DataValue::Duration(_), DataType::Duration)
318            | (DataValue::Currency(_), DataType::Currency)
319            | (DataValue::Array(_), DataType::Array)
320            | (DataValue::Object(_), DataType::Object)
321            | (DataValue::Null, DataType::Null)
322    )
323}
324
325fn valid_currency_code(code: &str) -> bool {
326    code.len() == 3 && code.bytes().all(|byte| byte.is_ascii_uppercase())
327}
328
329fn valid_date(value: &str) -> bool {
330    if !value.is_ascii() || value.len() != 10 || &value[4..5] != "-" || &value[7..8] != "-" {
331        return false;
332    }
333    let Some(year) = decimal(&value[0..4]) else {
334        return false;
335    };
336    let Some(month) = decimal(&value[5..7]) else {
337        return false;
338    };
339    let Some(day) = decimal(&value[8..10]) else {
340        return false;
341    };
342    let maximum = match month {
343        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
344        4 | 6 | 9 | 11 => 30,
345        2 if leap_year(year) => 29,
346        2 => 28,
347        _ => return false,
348    };
349    (1..=maximum).contains(&day)
350}
351
352fn valid_date_time(value: &str) -> bool {
353    if !value.is_ascii() {
354        return false;
355    }
356    let Some((date, time_and_zone)) = value.split_once('T') else {
357        return false;
358    };
359    if !valid_date(date) {
360        return false;
361    }
362    let (time, zone) = if let Some(time) = time_and_zone.strip_suffix('Z') {
363        (time, None)
364    } else {
365        let Some(index) = time_and_zone
366            .char_indices()
367            .rev()
368            .find_map(|(index, character)| matches!(character, '+' | '-').then_some(index))
369        else {
370            return false;
371        };
372        (&time_and_zone[..index], Some(&time_and_zone[index + 1..]))
373    };
374    let mut parts = time.split(':');
375    let (Some(hour), Some(minute), Some(second), None) =
376        (parts.next(), parts.next(), parts.next(), parts.next())
377    else {
378        return false;
379    };
380    let second = second.split_once('.').map_or(second, |(whole, fraction)| {
381        if fraction.is_empty() || !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
382            "invalid"
383        } else {
384            whole
385        }
386    });
387    let valid_time = decimal(hour).is_some_and(|value| value <= 23)
388        && decimal(minute).is_some_and(|value| value <= 59)
389        && decimal(second).is_some_and(|value| value <= 60);
390    valid_time && zone.is_none_or(valid_zone)
391}
392
393fn valid_zone(value: &str) -> bool {
394    value.is_ascii()
395        && value.len() == 5
396        && &value[2..3] == ":"
397        && decimal(&value[..2]).is_some_and(|hour| hour <= 23)
398        && decimal(&value[3..]).is_some_and(|minute| minute <= 59)
399}
400
401fn decimal(value: &str) -> Option<u32> {
402    (!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
403        .then(|| value.parse().ok())
404        .flatten()
405}
406
407const fn leap_year(year: u32) -> bool {
408    year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
409}
410
411fn data_error(message: impl Into<String>) -> FileMakerError {
412    FileMakerError::new(ErrorCode::DataType, message)
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn resolves_computed_fields_in_dependency_order() {
421        let schema = BTreeMap::from([
422            (
423                "label".to_owned(),
424                DataField {
425                    data_type: DataType::String,
426                    nullable: false,
427                    computed: Some("data.name + data.suffix".to_owned()),
428                },
429            ),
430            (
431                "suffix".to_owned(),
432                DataField {
433                    data_type: DataType::String,
434                    nullable: false,
435                    computed: Some("\"!\"".to_owned()),
436                },
437            ),
438            (
439                "name".to_owned(),
440                DataField {
441                    data_type: DataType::String,
442                    nullable: false,
443                    computed: None,
444                },
445            ),
446        ]);
447        let input = DataValue::Object(BTreeMap::from([(
448            "name".to_owned(),
449            DataValue::String("Ada".to_owned()),
450        )]));
451        let output = resolve_computed_fields(&schema, &input, 32).unwrap();
452        assert_eq!(
453            output.get_path("label"),
454            Some(&DataValue::String("Ada!".to_owned()))
455        );
456    }
457
458    #[test]
459    fn rejects_computed_field_cycles() {
460        let schema = BTreeMap::from([
461            (
462                "a".to_owned(),
463                DataField {
464                    data_type: DataType::String,
465                    nullable: false,
466                    computed: Some("b".to_owned()),
467                },
468            ),
469            (
470                "b".to_owned(),
471                DataField {
472                    data_type: DataType::String,
473                    nullable: false,
474                    computed: Some("a".to_owned()),
475                },
476            ),
477        ]);
478        let error =
479            resolve_computed_fields(&schema, &DataValue::Object(BTreeMap::new()), 8).unwrap_err();
480        assert_eq!(error.code(), ErrorCode::DataCycle);
481    }
482
483    #[test]
484    fn validates_date_and_date_time_values_without_locale_rules() {
485        DataValue::Array(vec![
486            DataValue::Date("2024-02-29".to_owned()),
487            DataValue::DateTime("2026-08-30T12:34:56.123+02:00".to_owned()),
488            DataValue::DateTime("2026-08-30T10:34:56Z".to_owned()),
489        ])
490        .validate(4, 8, 64)
491        .unwrap();
492        assert!(DataValue::Date("2023-02-29".to_owned())
493            .validate(1, 2, 64)
494            .is_err());
495        assert!(DataValue::DateTime("2026-08-30 10:34:56".to_owned())
496            .validate(1, 2, 64)
497            .is_err());
498    }
499}