Skip to main content

ant_types/
decimal.rs

1//! Exact decimal (SQL DECIMAL/NUMERIC, money).
2//!
3//! `f64` is not a decimal type and never was. It cannot represent `0.1`,
4//! and past ~15 significant digits it silently rounds — so an invoice
5//! total of `12345678901234567.89` comes back as `12345678901234568`,
6//! with no error, no warning, and no way for the caller to tell. That is
7//! the single most damaging thing a knowledge graph can do to financial
8//! data, so this type never touches binary floating point.
9//!
10//! Representation is an `i128` of unscaled digits plus a decimal `scale`
11//! — `12.3400` is `unscaled = 123400, scale = 4`. Scale is preserved
12//! rather than trimmed: in SQL, `DECIMAL(10,4)` carrying `12.3400` is
13//! not the same column value as `12.34`, and a round-trip that quietly
14//! drops the trailing zeros has changed the data.
15//!
16//! `i128` holds 38 significant digits, which covers `DECIMAL(38, s)` —
17//! the maximum precision of Postgres, MySQL, SQL Server, and Oracle
18//! alike. Input beyond that is rejected (`None`) rather than truncated.
19
20use std::cmp::Ordering;
21use std::fmt;
22
23use serde::{Deserialize, Deserializer, Serialize, Serializer};
24
25/// An exact base-10 number: `unscaled / 10^scale`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct Decimal {
28    unscaled: i128,
29    scale: u32,
30}
31
32/// Beyond this, `unscaled` would overflow `i128`.
33const MAX_PRECISION: u32 = 38;
34
35impl Decimal {
36    /// Construct from raw parts. `None` if `scale` exceeds the maximum
37    /// precision, which would make the value unrepresentable.
38    pub fn from_parts(unscaled: i128, scale: u32) -> Option<Self> {
39        (scale <= MAX_PRECISION).then_some(Self { unscaled, scale })
40    }
41
42    pub fn unscaled(&self) -> i128 {
43        self.unscaled
44    }
45
46    /// Digits after the decimal point, as declared. Preserved exactly:
47    /// `12.3400` reports 4, not 2.
48    pub fn scale(&self) -> u32 {
49        self.scale
50    }
51
52    /// Total significant digits — the `p` of `DECIMAL(p, s)`. Zero has
53    /// precision 1.
54    pub fn precision(&self) -> u32 {
55        let mut n = self.unscaled.unsigned_abs();
56        if n == 0 {
57            return 1;
58        }
59        let mut digits = 0;
60        while n > 0 {
61            digits += 1;
62            n /= 10;
63        }
64        // A pure fraction still needs its leading zero counted against
65        // the scale: 0.004 is DECIMAL(3,3), not DECIMAL(1,3).
66        digits.max(self.scale)
67    }
68
69    pub fn is_zero(&self) -> bool {
70        self.unscaled == 0
71    }
72
73    /// Parse a decimal literal: optional sign, digits, optional
74    /// fraction, optional `e±nn` exponent. The exponent is folded into
75    /// the scale, so `1.5e3` parses as `1500` and `15e-3` as `0.015` —
76    /// both exact.
77    ///
78    /// Returns `None` for anything that is not a decimal literal, or
79    /// that needs more than 38 significant digits. NEVER falls back to
80    /// float parsing: silently accepting a value we cannot represent
81    /// exactly is the bug this type exists to prevent.
82    pub fn parse(s: &str) -> Option<Self> {
83        let s = s.trim();
84        if s.is_empty() {
85            return None;
86        }
87
88        // Split off the exponent first.
89        let (mantissa, exp) = match s.find(['e', 'E']) {
90            Some(i) => {
91                let e: i32 = s[i + 1..].parse().ok()?;
92                (&s[..i], e)
93            }
94            None => (s, 0),
95        };
96
97        let (neg, digits) = match mantissa.strip_prefix('-') {
98            Some(rest) => (true, rest),
99            None => (false, mantissa.strip_prefix('+').unwrap_or(mantissa)),
100        };
101
102        let (int_part, frac_part) = match digits.find('.') {
103            Some(i) => (&digits[..i], &digits[i + 1..]),
104            None => (digits, ""),
105        };
106        if int_part.is_empty() && frac_part.is_empty() {
107            return None;
108        }
109        if !int_part.bytes().all(|b| b.is_ascii_digit())
110            || !frac_part.bytes().all(|b| b.is_ascii_digit())
111        {
112            return None;
113        }
114
115        // Accumulate every digit; overflow means we cannot be exact.
116        let mut unscaled: i128 = 0;
117        for b in int_part.bytes().chain(frac_part.bytes()) {
118            unscaled = unscaled.checked_mul(10)?.checked_add((b - b'0') as i128)?;
119        }
120
121        let scale = i64::from(frac_part.len() as u32) - i64::from(exp);
122        let (unscaled, scale) = if scale < 0 {
123            // Negative scale: multiply out so the value stays an
124            // integer with scale 0 rather than growing a synthetic
125            // fraction.
126            let mut u = unscaled;
127            for _ in 0..(-scale) {
128                u = u.checked_mul(10)?;
129            }
130            (u, 0u32)
131        } else {
132            (unscaled, u32::try_from(scale).ok()?)
133        };
134        if scale > MAX_PRECISION {
135            return None;
136        }
137
138        Some(Self {
139            unscaled: if neg { -unscaled } else { unscaled },
140            scale,
141        })
142    }
143
144    /// Rescale to `target` digits after the point. `None` when that
145    /// would drop non-zero digits (an inexact narrowing) or overflow.
146    pub fn rescale(&self, target: u32) -> Option<Self> {
147        if target > MAX_PRECISION {
148            return None;
149        }
150        match target.cmp(&self.scale) {
151            Ordering::Equal => Some(*self),
152            Ordering::Greater => {
153                let mut u = self.unscaled;
154                for _ in 0..(target - self.scale) {
155                    u = u.checked_mul(10)?;
156                }
157                Some(Self {
158                    unscaled: u,
159                    scale: target,
160                })
161            }
162            Ordering::Less => {
163                let mut u = self.unscaled;
164                for _ in 0..(self.scale - target) {
165                    if u % 10 != 0 {
166                        return None; // would lose a significant digit
167                    }
168                    u /= 10;
169                }
170                Some(Self {
171                    unscaled: u,
172                    scale: target,
173                })
174            }
175        }
176    }
177
178    /// Exact ordering, independent of scale: `1.50` equals `1.5` in
179    /// value even though they are distinct column values.
180    ///
181    /// Compares by aligning scales in `i128`; if alignment would
182    /// overflow it falls back to comparing sign, integer-digit count,
183    /// and then digits pairwise — which needs no arithmetic at all and
184    /// so is exact at any magnitude.
185    pub fn cmp_value(&self, other: &Self) -> Ordering {
186        let target = self.scale.max(other.scale);
187        if let (Some(a), Some(b)) = (self.rescale(target), other.rescale(target)) {
188            return a.unscaled.cmp(&b.unscaled);
189        }
190        digitwise_cmp(self, other)
191    }
192}
193
194/// Scale-independent comparison that never multiplies, for values too
195/// large to align. Compares sign, then magnitude by integer-digit
196/// count, then the digit strings pairwise.
197fn digitwise_cmp(a: &Decimal, b: &Decimal) -> Ordering {
198    let (sa, sb) = (a.unscaled.signum(), b.unscaled.signum());
199    if sa != sb {
200        return sa.cmp(&sb);
201    }
202    let flip = sa < 0;
203    let (ai, af) = split_digits(a);
204    let (bi, bf) = split_digits(b);
205
206    // Integer parts: more digits (after stripping leading zeros) wins.
207    let (ai, bi) = (ai.trim_start_matches('0'), bi.trim_start_matches('0'));
208    let ord = ai
209        .len()
210        .cmp(&bi.len())
211        .then_with(|| ai.cmp(bi))
212        .then_with(|| {
213            // Fractions compare left-aligned, zero-padded.
214            let n = af.len().max(bf.len());
215            let pad = |s: &str| {
216                let mut t = s.to_string();
217                t.extend(std::iter::repeat_n('0', n - s.len()));
218                t
219            };
220            pad(&af).cmp(&pad(&bf))
221        });
222    if flip {
223        ord.reverse()
224    } else {
225        ord
226    }
227}
228
229/// `(integer digits, fraction digits)` of the magnitude, unsigned.
230fn split_digits(d: &Decimal) -> (String, String) {
231    let digits = d.unscaled.unsigned_abs().to_string();
232    let scale = d.scale as usize;
233    if digits.len() > scale {
234        let (i, f) = digits.split_at(digits.len() - scale);
235        (i.to_string(), f.to_string())
236    } else {
237        let mut f = "0".repeat(scale - digits.len());
238        f.push_str(&digits);
239        ("0".into(), f)
240    }
241}
242
243impl fmt::Display for Decimal {
244    /// The canonical literal, with exactly `scale` fractional digits —
245    /// the round-trip form. `Decimal::parse(&d.to_string()) == Some(d)`
246    /// for every representable value, trailing zeros included.
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        let (int, frac) = split_digits(self);
249        if self.unscaled < 0 {
250            f.write_str("-")?;
251        }
252        f.write_str(&int)?;
253        if self.scale > 0 {
254            write!(f, ".{frac}")?;
255        }
256        Ok(())
257    }
258}
259
260/// Value ordering, NOT the derived field order — `1.50` and `1.5` are
261/// `Ordering::Equal` here while remaining distinct under `Eq`.
262impl PartialOrd for Decimal {
263    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
264        Some(self.cmp_value(other))
265    }
266}
267
268/// Serialized as its canonical STRING, never a JSON number: a JSON
269/// number goes through `f64` in most parsers (including JavaScript),
270/// which is exactly the corruption this type exists to prevent.
271impl Serialize for Decimal {
272    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
273        s.collect_str(self)
274    }
275}
276
277impl<'de> Deserialize<'de> for Decimal {
278    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
279        use serde::de::Error;
280        let raw = serde_json::Value::deserialize(d)?;
281        let text = match &raw {
282            serde_json::Value::String(s) => s.clone(),
283            // A bare JSON number is accepted ONLY when the parser
284            // already held it exactly — i.e. an integer. A fractional
285            // literal has been through `f64` before it ever reaches
286            // here (serde_json parses it that way), so `1.10` arrives
287            // as `1.1` with the scale gone and `0.1` as an approximation
288            // of itself. Accepting those would launder precisely the
289            // corruption this type exists to prevent, so they are
290            // refused and the caller is told to send a string.
291            serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => n.to_string(),
292            serde_json::Value::Number(n) => {
293                return Err(D::Error::custom(format!(
294                    "decimal {n} arrived as a JSON number, which is parsed as f64 and has \
295                     already lost precision; send it as a string"
296                )))
297            }
298            other => {
299                return Err(D::Error::custom(format!(
300                    "decimal must be a string: {other}"
301                )))
302            }
303        };
304        Decimal::parse(&text)
305            .ok_or_else(|| D::Error::custom(format!("not an exact decimal: {text}")))
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn money_that_f64_corrupts_survives_to_the_digit() {
315        // 19 significant digits: f64 carries ~15-16.
316        let s = "12345678901234567.89";
317        let d = Decimal::parse(s).unwrap();
318        assert_eq!(d.to_string(), s);
319        assert_eq!(d.unscaled(), 1_234_567_890_123_456_789);
320        assert_eq!(d.scale(), 2);
321        // Demonstrate the corruption being avoided.
322        assert_ne!(s.parse::<f64>().unwrap().to_string(), s);
323    }
324
325    #[test]
326    fn scale_is_preserved_not_trimmed() {
327        let d = Decimal::parse("12.3400").unwrap();
328        assert_eq!(d.scale(), 4);
329        assert_eq!(d.to_string(), "12.3400");
330        // Equal in VALUE, distinct as column values.
331        let e = Decimal::parse("12.34").unwrap();
332        assert_eq!(d.cmp_value(&e), Ordering::Equal);
333        assert_ne!(d, e);
334    }
335
336    #[test]
337    fn parses_sign_fraction_and_exponent_exactly() {
338        assert_eq!(Decimal::parse("-0.004").unwrap().to_string(), "-0.004");
339        assert_eq!(Decimal::parse("1.5e3").unwrap().to_string(), "1500");
340        assert_eq!(Decimal::parse("15e-3").unwrap().to_string(), "0.015");
341        assert_eq!(Decimal::parse(".5").unwrap().to_string(), "0.5");
342        assert_eq!(Decimal::parse("+7").unwrap().to_string(), "7");
343    }
344
345    #[test]
346    fn rejects_what_it_cannot_represent_exactly() {
347        assert!(Decimal::parse("abc").is_none());
348        assert!(Decimal::parse("1.2.3").is_none());
349        assert!(Decimal::parse("").is_none());
350        assert!(Decimal::parse("NaN").is_none());
351        // 40 digits > i128's 38.
352        assert!(Decimal::parse(&"9".repeat(40)).is_none());
353    }
354
355    #[test]
356    fn ordering_is_exact_across_scales_and_signs() {
357        let ordered = [
358            "-100", "-2.5", "-0.001", "0", "0.001", "0.0010", "1.5", "1.50", "2.5", "100",
359        ];
360        for w in ordered.windows(2) {
361            let (a, b) = (Decimal::parse(w[0]).unwrap(), Decimal::parse(w[1]).unwrap());
362            assert!(
363                a.cmp_value(&b) != Ordering::Greater,
364                "{} should sort <= {}",
365                w[0],
366                w[1]
367            );
368        }
369        // Differing only past the f64 precision wall.
370        let a = Decimal::parse("100000000000000000.01").unwrap();
371        let b = Decimal::parse("100000000000000000.02").unwrap();
372        assert_eq!(a.cmp_value(&b), Ordering::Less);
373    }
374
375    #[test]
376    fn digitwise_fallback_matches_aligned_compare() {
377        // Scales that cannot be aligned without overflowing i128 take
378        // the no-arithmetic path; it must agree with the fast path.
379        let a = Decimal::from_parts(i128::MAX, 0).unwrap();
380        let b = Decimal::from_parts(1, 30).unwrap();
381        assert_eq!(a.cmp_value(&b), Ordering::Greater);
382        assert_eq!(b.cmp_value(&a), Ordering::Less);
383        let c = Decimal::from_parts(-1, 30).unwrap();
384        assert_eq!(c.cmp_value(&b), Ordering::Less);
385    }
386
387    #[test]
388    fn precision_counts_significant_digits() {
389        assert_eq!(Decimal::parse("0").unwrap().precision(), 1);
390        assert_eq!(Decimal::parse("123.45").unwrap().precision(), 5);
391        assert_eq!(Decimal::parse("0.004").unwrap().precision(), 3);
392    }
393
394    #[test]
395    fn json_is_a_string_and_round_trips() {
396        let d = Decimal::parse("12345678901234567.89").unwrap();
397        let j = serde_json::to_string(&d).unwrap();
398        assert_eq!(j, "\"12345678901234567.89\"");
399        assert_eq!(serde_json::from_str::<Decimal>(&j).unwrap(), d);
400        // Integers are exact in the parser, so they are accepted.
401        assert_eq!(
402            serde_json::from_str::<Decimal>("5").unwrap().to_string(),
403            "5"
404        );
405        // Fractional JSON numbers have already been through f64 by the
406        // time we see them ("1.10" arrives as 1.1), so they are refused
407        // rather than silently accepted at the wrong scale.
408        let err = serde_json::from_str::<Decimal>("1.10")
409            .unwrap_err()
410            .to_string();
411        assert!(err.contains("send it as a string"), "{err}");
412    }
413}