Skip to main content

rivet/types/
decimal.rs

1//! Exact decimal string → scaled integer conversion (roadmap §12).
2//!
3//! The rule from the roadmap: **never convert decimal through f64**.
4//!
5//! Arrow Decimal128 stores a value as `i128 * 10^(-scale)`. So to
6//! represent "123.45" in `Decimal128(18, 2)` we need `i128 = 12345`.
7//!
8//! This module provides one pure function — [`decimal_str_to_scaled_i128`] —
9//! that converts a DB text-protocol decimal string to the scaled i128 Arrow
10//! needs without ever touching floating-point arithmetic.
11
12/// Convert a decimal string (as returned by the database text protocol) to
13/// a scaled `i128` ready to be stored in an Arrow `Decimal128` array.
14///
15/// `scale` is the Rivet/Arrow scale parameter: the result satisfies
16/// `actual_value = result * 10^(-scale)`, i.e. `result = actual_value * 10^scale`.
17///
18/// Returns `None` for strings that are empty, contain non-numeric characters,
19/// or whose values would overflow `i128`. The caller is responsible for
20/// distinguishing SQL `NULL` (which never reaches this function) from parse
21/// errors.
22///
23/// # Examples
24///
25/// ```ignore
26/// use rivet::types::decimal::decimal_str_to_scaled_i128;
27/// assert_eq!(decimal_str_to_scaled_i128("123.45",  2), Some(12345));
28/// assert_eq!(decimal_str_to_scaled_i128("0.10",    2), Some(10));
29/// assert_eq!(decimal_str_to_scaled_i128("-1.23",   2), Some(-123));
30/// assert_eq!(decimal_str_to_scaled_i128("1200",   -2), Some(12));
31/// assert_eq!(decimal_str_to_scaled_i128("",        2), None);
32/// ```
33pub fn decimal_str_to_scaled_i128(s: &str, scale: i8) -> Option<i128> {
34    let s = s.trim();
35    if s.is_empty() {
36        return None;
37    }
38
39    let negative = s.starts_with('-');
40    let s = if negative {
41        &s[1..]
42    } else {
43        s.trim_start_matches('+')
44    };
45
46    if scale < 0 {
47        // Negative scale: the stored integer represents multiples of 10^|scale|.
48        // E.g. scale=-2 means the column stores whole hundreds: "1200" → 12.
49        let divisor = 10i128.pow(scale.unsigned_abs() as u32);
50        let int_val: i128 = s.split('.').next()?.trim().parse().ok()?;
51        let result = int_val.checked_div(divisor)?;
52        return Some(if negative { -result } else { result });
53    }
54
55    let scale_u = scale as u32;
56
57    // Split on the decimal point (may be absent for integer-valued decimals).
58    let (int_part, frac_part) = if let Some(dot) = s.find('.') {
59        (&s[..dot], &s[dot + 1..])
60    } else {
61        (s, "")
62    };
63
64    let int_val: i128 = if int_part.is_empty() {
65        0
66    } else {
67        int_part.parse().ok()?
68    };
69
70    let frac_aligned: i128 = if scale_u == 0 {
71        0
72    } else if frac_part.len() < scale_u as usize {
73        // Pad right with zeros: "0.1" with scale=2 → frac "10" → 10
74        let mut buf = String::with_capacity(scale_u as usize);
75        buf.push_str(frac_part);
76        for _ in 0..(scale_u as usize - frac_part.len()) {
77            buf.push('0');
78        }
79        buf.parse().ok()?
80    } else {
81        // Truncate to exactly `scale` digits. If the source column truly has
82        // scale=2 and a DB value arrives with more digits, that is either a
83        // type mismatch or a DB rounding artefact — we preserve the declared
84        // scale rather than silently extending it.
85        frac_part[..scale_u as usize].parse().ok()?
86    };
87
88    let scale_factor = 10i128.pow(scale_u);
89    let result = int_val
90        .checked_mul(scale_factor)?
91        .checked_add(frac_aligned)?;
92    Some(if negative { -result } else { result })
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn standard_financial_values() {
101        assert_eq!(decimal_str_to_scaled_i128("0.10", 2), Some(10));
102        assert_eq!(decimal_str_to_scaled_i128("0.20", 2), Some(20));
103        assert_eq!(decimal_str_to_scaled_i128("0.30", 2), Some(30));
104        assert_eq!(decimal_str_to_scaled_i128("123.45", 2), Some(12345));
105        assert_eq!(decimal_str_to_scaled_i128("-1.23", 2), Some(-123));
106        assert_eq!(decimal_str_to_scaled_i128("-100.05", 2), Some(-10005));
107    }
108
109    /// Roadmap §12 golden-test values: SUM(amount) must equal 999999999900.24
110    #[test]
111    fn golden_test_payment_values() {
112        let rows = [
113            ("0.10", 10i128),
114            ("0.20", 20),
115            ("999999999999.99", 99999999999999),
116            ("-100.05", -10005),
117        ];
118        let sum: i128 = rows.iter().map(|(_, v)| v).sum();
119        // 10 + 20 + 99999999999999 + (-10005) = 99999999990024
120        // = 999999999900.24 × 100
121        assert_eq!(sum, 99999999990024);
122
123        for (s, expected) in &rows {
124            assert_eq!(
125                decimal_str_to_scaled_i128(s, 2),
126                Some(*expected),
127                "mismatch for '{s}'"
128            );
129        }
130    }
131
132    #[test]
133    fn integer_valued_decimal_with_nonzero_scale() {
134        assert_eq!(decimal_str_to_scaled_i128("100", 2), Some(10000));
135        assert_eq!(decimal_str_to_scaled_i128("0", 2), Some(0));
136    }
137
138    #[test]
139    fn frac_shorter_than_scale_is_right_padded() {
140        // "0.1" with scale=3 means 0.100 → 100
141        assert_eq!(decimal_str_to_scaled_i128("0.1", 3), Some(100));
142        // "5.4" with scale=6 means 5.400000 → 5400000
143        assert_eq!(decimal_str_to_scaled_i128("5.4", 6), Some(5_400_000));
144    }
145
146    #[test]
147    fn negative_scale_represents_large_round_numbers() {
148        // scale=-2: values are multiples of 100
149        assert_eq!(decimal_str_to_scaled_i128("1200", -2), Some(12));
150        assert_eq!(decimal_str_to_scaled_i128("50000", -2), Some(500));
151    }
152
153    #[test]
154    fn zero_scale_ignores_fractional_digits() {
155        assert_eq!(decimal_str_to_scaled_i128("42", 0), Some(42));
156        assert_eq!(decimal_str_to_scaled_i128("42.0", 0), Some(42));
157    }
158
159    #[test]
160    fn null_like_empty_string_returns_none() {
161        assert_eq!(decimal_str_to_scaled_i128("", 2), None);
162        assert_eq!(decimal_str_to_scaled_i128("  ", 2), None);
163    }
164
165    #[test]
166    fn non_numeric_string_returns_none() {
167        assert_eq!(decimal_str_to_scaled_i128("NaN", 2), None);
168        assert_eq!(decimal_str_to_scaled_i128("Infinity", 2), None);
169    }
170
171    #[test]
172    fn large_precision_near_i128_boundary() {
173        // Decimal128 max precision=38, so the largest i128 value is ~1.7e38
174        // This just verifies we don't overflow for values within p=18,s=0
175        let big = "999999999999999999"; // 18 nines
176        assert_eq!(
177            decimal_str_to_scaled_i128(big, 0),
178            Some(999_999_999_999_999_999i128)
179        );
180    }
181}