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