Skip to main content

type_bridge_contract/
decimal.rs

1//! Canonical TypeDB decimal parsing and semantic comparison.
2
3use std::cmp::Ordering;
4
5/// A validated decimal split into normalized, borrowed components.
6///
7/// The parser accepts TypeQL decimal text with or without the driver's `dec`
8/// suffix. Leading whole-part zeroes, trailing fractional zeroes, and negative
9/// zero are normalized so semantic comparison does not depend on spelling.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct CanonicalDecimal<'a> {
12    negative: bool,
13    whole: &'a str,
14    fraction: &'a str,
15}
16
17impl CanonicalDecimal<'_> {
18    /// Compare two validated decimals by numeric value.
19    pub fn compare(&self, other: &Self) -> Ordering {
20        if self.negative != other.negative {
21            return if self.negative {
22                Ordering::Less
23            } else {
24                Ordering::Greater
25            };
26        }
27        let width = self.fraction.len().max(other.fraction.len());
28        let magnitude = self
29            .whole
30            .len()
31            .cmp(&other.whole.len())
32            .then_with(|| self.whole.cmp(other.whole))
33            .then_with(|| {
34                self.fraction
35                    .bytes()
36                    .chain(std::iter::repeat_n(b'0', width - self.fraction.len()))
37                    .cmp(
38                        other
39                            .fraction
40                            .bytes()
41                            .chain(std::iter::repeat_n(b'0', width - other.fraction.len())),
42                    )
43            });
44        if self.negative {
45            magnitude.reverse()
46        } else {
47            magnitude
48        }
49    }
50
51    /// Render the normalized allocation-owned decimal spelling.
52    pub fn canonical_string(&self) -> String {
53        let mut value = String::with_capacity(self.whole.len() + self.fraction.len() + 2);
54        if self.negative {
55            value.push('-');
56        }
57        value.push_str(self.whole);
58        if !self.fraction.is_empty() {
59            value.push('.');
60            value.push_str(self.fraction);
61        }
62        value
63    }
64}
65
66/// Parse one TypeDB decimal using the canonical TypeQL/driver grammar.
67pub fn parse_decimal(value: &str) -> Option<CanonicalDecimal<'_>> {
68    let value = value.strip_suffix("dec").unwrap_or(value);
69    let (negative, unsigned) = if let Some(value) = value.strip_prefix('-') {
70        (true, value)
71    } else {
72        (false, value.strip_prefix('+').unwrap_or(value))
73    };
74    let (raw_whole, raw_fraction) = match unsigned.split_once('.') {
75        Some((whole, fraction)) => (whole, Some(fraction)),
76        None => (unsigned, None),
77    };
78    if raw_whole.is_empty()
79        || !raw_whole.bytes().all(|byte| byte.is_ascii_digit())
80        || raw_fraction.is_some_and(|fraction| {
81            fraction.is_empty()
82                || fraction.len() > 19
83                || !fraction.bytes().all(|byte| byte.is_ascii_digit())
84        })
85    {
86        return None;
87    }
88    let whole = raw_whole.trim_start_matches('0');
89    let whole = if whole.is_empty() { "0" } else { whole };
90    let fraction = raw_fraction.unwrap_or_default().trim_end_matches('0');
91    let limit = if negative {
92        "9223372036854775808"
93    } else {
94        "9223372036854775807"
95    };
96    match whole.len().cmp(&limit.len()).then_with(|| whole.cmp(limit)) {
97        Ordering::Greater => return None,
98        Ordering::Equal if negative && !fraction.is_empty() => return None,
99        Ordering::Less | Ordering::Equal => {}
100    }
101    Some(CanonicalDecimal {
102        negative: negative && !(whole == "0" && fraction.is_empty()),
103        whole,
104        fraction,
105    })
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn accepts_driver_suffix_and_compares_canonical_values() {
114        let driver = parse_decimal("001234.5600dec").unwrap();
115        let literal = parse_decimal("+1234.56").unwrap();
116        assert_eq!(driver, literal);
117        assert_eq!(driver.compare(&literal), Ordering::Equal);
118        assert_eq!(driver.canonical_string(), "1234.56");
119        assert_eq!(parse_decimal("-0.000dec"), parse_decimal("0"));
120    }
121
122    #[test]
123    fn enforces_fraction_width_and_decimal_range() {
124        for valid in [
125            "-9223372036854775808",
126            "-9223372036854775808.0000000000000000000",
127            "9223372036854775807.9999999999999999999dec",
128        ] {
129            assert!(
130                parse_decimal(valid).is_some(),
131                "expected {valid:?} to parse"
132            );
133        }
134        for invalid in [
135            "",
136            "dec",
137            "1.",
138            ".1",
139            "1.00000000000000000000",
140            "1.0DEC",
141            "1.0decdec",
142            "9223372036854775808",
143            "-9223372036854775808.0000000000000000001",
144            "-9223372036854775809",
145        ] {
146            assert!(
147                parse_decimal(invalid).is_none(),
148                "expected {invalid:?} to fail"
149            );
150        }
151    }
152}