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 converts a DB text-protocol decimal string to the scaled
9//! integer Arrow needs without ever touching floating-point arithmetic —
10//! `i128` for `Decimal128` (precision ≤ 38) and `i256` for `Decimal256`
11//! (precision 39–76).
12
13use arrow::datatypes::i256;
14
15/// Convert a decimal string (as returned by the database text protocol) to
16/// a scaled `i128` ready to be stored in an Arrow `Decimal128` array.
17///
18/// `scale` is the Rivet/Arrow scale parameter: the result satisfies
19/// `actual_value = result * 10^(-scale)`, i.e. `result = actual_value * 10^scale`.
20///
21/// Returns `None` for strings that are empty, contain non-numeric characters,
22/// or whose values would overflow `i128`. The caller is responsible for
23/// distinguishing SQL `NULL` (which never reaches this function) from parse
24/// errors.
25///
26/// # Examples
27///
28/// ```ignore
29/// use rivet::types::decimal::decimal_str_to_scaled_i128;
30/// assert_eq!(decimal_str_to_scaled_i128("123.45",  2), Some(12345));
31/// assert_eq!(decimal_str_to_scaled_i128("0.10",    2), Some(10));
32/// assert_eq!(decimal_str_to_scaled_i128("-1.23",   2), Some(-123));
33/// assert_eq!(decimal_str_to_scaled_i128("1200",   -2), Some(12));
34/// assert_eq!(decimal_str_to_scaled_i128("",        2), None);
35/// ```
36/// Format a scaled `Decimal128` value as exact decimal text (no float).
37pub fn scaled_i128_to_decimal_str(value: i128, scale: i8) -> String {
38    if scale < 0 {
39        let factor = 10i128.pow(scale.unsigned_abs() as u32);
40        return (value * factor).to_string();
41    }
42    let scale_u = scale as u32;
43    if scale_u == 0 {
44        return value.to_string();
45    }
46    let factor = 10i128.pow(scale_u);
47    let negative = value < 0;
48    let abs = value.abs();
49    let int_part = abs / factor;
50    let frac = abs % factor;
51    format!(
52        "{sign}{int_part}.{frac:0width$}",
53        sign = if negative { "-" } else { "" },
54        width = scale_u as usize
55    )
56}
57
58pub fn decimal_str_to_scaled_i128(s: &str, scale: i8) -> Option<i128> {
59    let s = s.trim();
60    if s.is_empty() {
61        return None;
62    }
63
64    let negative = s.starts_with('-');
65    let s = if negative {
66        &s[1..]
67    } else {
68        s.trim_start_matches('+')
69    };
70
71    if scale < 0 {
72        // Negative scale: the stored integer represents multiples of 10^|scale|.
73        // E.g. scale=-2 means the column stores whole hundreds: "1200" → 12.
74        let divisor = 10i128.pow(scale.unsigned_abs() as u32);
75        let int_val: i128 = s.split('.').next()?.trim().parse().ok()?;
76        let result = int_val.checked_div(divisor)?;
77        return Some(if negative { -result } else { result });
78    }
79
80    let scale_u = scale as u32;
81
82    // Split on the decimal point (may be absent for integer-valued decimals).
83    let (int_part, frac_part) = if let Some(dot) = s.find('.') {
84        (&s[..dot], &s[dot + 1..])
85    } else {
86        (s, "")
87    };
88
89    let int_val: i128 = if int_part.is_empty() {
90        0
91    } else {
92        int_part.parse().ok()?
93    };
94
95    let frac_aligned: i128 = if scale_u == 0 {
96        0
97    } else if frac_part.len() < scale_u as usize {
98        // Pad right with zeros: "0.1" with scale=2 → frac "10" → 10
99        let mut buf = String::with_capacity(scale_u as usize);
100        buf.push_str(frac_part);
101        for _ in 0..(scale_u as usize - frac_part.len()) {
102            buf.push('0');
103        }
104        buf.parse().ok()?
105    } else {
106        // Truncate to exactly `scale` digits. If the source column truly has
107        // scale=2 and a DB value arrives with more digits, that is either a
108        // type mismatch or a DB rounding artefact — we preserve the declared
109        // scale rather than silently extending it.
110        frac_part[..scale_u as usize].parse().ok()?
111    };
112
113    let scale_factor = 10i128.pow(scale_u);
114    let result = int_val
115        .checked_mul(scale_factor)?
116        .checked_add(frac_aligned)?;
117    Some(if negative { -result } else { result })
118}
119
120/// `Decimal256` analogue of [`decimal_str_to_scaled_i128`] — parses straight
121/// into `i256` so values beyond `i128` (precision 39–76) are not truncated.
122/// Returns `None` for empty / non-numeric strings or `i256` overflow.
123pub fn decimal_str_to_scaled_i256(s: &str, scale: i8) -> Option<i256> {
124    let s = s.trim();
125    if s.is_empty() {
126        return None;
127    }
128    let negative = s.starts_with('-');
129    let s = if negative {
130        &s[1..]
131    } else {
132        s.trim_start_matches('+')
133    };
134
135    if scale < 0 {
136        let divisor = pow10_i256(scale.unsigned_abs() as u32)?;
137        let int_val = i256::from_string(s.split('.').next()?.trim())?;
138        let result = int_val.checked_div(divisor)?;
139        return Some(if negative { -result } else { result });
140    }
141
142    let scale_u = scale as u32;
143    let (int_part, frac_part) = match s.find('.') {
144        Some(dot) => (&s[..dot], &s[dot + 1..]),
145        None => (s, ""),
146    };
147    let int_val = if int_part.is_empty() {
148        i256::ZERO
149    } else {
150        i256::from_string(int_part)?
151    };
152    let frac_aligned = if scale_u == 0 {
153        i256::ZERO
154    } else if frac_part.len() < scale_u as usize {
155        let mut buf = String::with_capacity(scale_u as usize);
156        buf.push_str(frac_part);
157        for _ in 0..(scale_u as usize - frac_part.len()) {
158            buf.push('0');
159        }
160        i256::from_string(&buf)?
161    } else {
162        i256::from_string(&frac_part[..scale_u as usize])?
163    };
164
165    let scale_factor = pow10_i256(scale_u)?;
166    let result = int_val
167        .checked_mul(scale_factor)?
168        .checked_add(frac_aligned)?;
169    Some(if negative { -result } else { result })
170}
171
172/// Scale an integer (already widened to `i128`) by `10^scale` into `i256` — the
173/// `Decimal256` analogue of the source drivers' `scale_int_to_i128`. `None` on
174/// negative scale or `i256` overflow.
175pub fn scale_int_to_i256(v: i128, scale: i8) -> Option<i256> {
176    if scale < 0 {
177        return None;
178    }
179    i256::from_i128(v).checked_mul(pow10_i256(scale as u32)?)
180}
181
182/// `10^n` as `i256` (up to 10^76, the `Decimal256` scale ceiling); `None` if it
183/// would exceed `i256`. Repeated `checked_mul` rather than the old
184/// `format!("1{0:0}")` + `i256::from_string` — no per-value string allocation
185/// or parse on the Decimal256 scale path; same overflow contract.
186fn pow10_i256(n: u32) -> Option<i256> {
187    let ten = i256::from_i128(10);
188    let mut acc = i256::from_i128(1);
189    for _ in 0..n {
190        acc = acc.checked_mul(ten)?;
191    }
192    Some(acc)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn scaled_to_str_roundtrip_financial() {
201        assert_eq!(scaled_i128_to_decimal_str(10, 2), "0.10");
202        assert_eq!(scaled_i128_to_decimal_str(12345, 2), "123.45");
203        assert_eq!(scaled_i128_to_decimal_str(-123, 2), "-1.23");
204        assert_eq!(scaled_i128_to_decimal_str(10_123_456, 6), "10.123456");
205    }
206
207    #[test]
208    fn standard_financial_values() {
209        assert_eq!(decimal_str_to_scaled_i128("0.10", 2), Some(10));
210        assert_eq!(decimal_str_to_scaled_i128("0.20", 2), Some(20));
211        assert_eq!(decimal_str_to_scaled_i128("0.30", 2), Some(30));
212        assert_eq!(decimal_str_to_scaled_i128("123.45", 2), Some(12345));
213        assert_eq!(decimal_str_to_scaled_i128("-1.23", 2), Some(-123));
214        assert_eq!(decimal_str_to_scaled_i128("-100.05", 2), Some(-10005));
215    }
216
217    /// Roadmap §12 golden-test values: SUM(amount) must equal 999999999900.24
218    #[test]
219    fn golden_test_payment_values() {
220        let rows = [
221            ("0.10", 10i128),
222            ("0.20", 20),
223            ("999999999999.99", 99999999999999),
224            ("-100.05", -10005),
225        ];
226        let sum: i128 = rows.iter().map(|(_, v)| v).sum();
227        // 10 + 20 + 99999999999999 + (-10005) = 99999999990024
228        // = 999999999900.24 × 100
229        assert_eq!(sum, 99999999990024);
230
231        for (s, expected) in &rows {
232            assert_eq!(
233                decimal_str_to_scaled_i128(s, 2),
234                Some(*expected),
235                "mismatch for '{s}'"
236            );
237        }
238    }
239
240    #[test]
241    fn integer_valued_decimal_with_nonzero_scale() {
242        assert_eq!(decimal_str_to_scaled_i128("100", 2), Some(10000));
243        assert_eq!(decimal_str_to_scaled_i128("0", 2), Some(0));
244    }
245
246    #[test]
247    fn frac_shorter_than_scale_is_right_padded() {
248        // "0.1" with scale=3 means 0.100 → 100
249        assert_eq!(decimal_str_to_scaled_i128("0.1", 3), Some(100));
250        // "5.4" with scale=6 means 5.400000 → 5400000
251        assert_eq!(decimal_str_to_scaled_i128("5.4", 6), Some(5_400_000));
252    }
253
254    #[test]
255    fn negative_scale_represents_large_round_numbers() {
256        // scale=-2: values are multiples of 100
257        assert_eq!(decimal_str_to_scaled_i128("1200", -2), Some(12));
258        assert_eq!(decimal_str_to_scaled_i128("50000", -2), Some(500));
259    }
260
261    #[test]
262    fn zero_scale_ignores_fractional_digits() {
263        assert_eq!(decimal_str_to_scaled_i128("42", 0), Some(42));
264        assert_eq!(decimal_str_to_scaled_i128("42.0", 0), Some(42));
265    }
266
267    #[test]
268    fn null_like_empty_string_returns_none() {
269        assert_eq!(decimal_str_to_scaled_i128("", 2), None);
270        assert_eq!(decimal_str_to_scaled_i128("  ", 2), None);
271    }
272
273    #[test]
274    fn non_numeric_string_returns_none() {
275        assert_eq!(decimal_str_to_scaled_i128("NaN", 2), None);
276        assert_eq!(decimal_str_to_scaled_i128("Infinity", 2), None);
277    }
278
279    #[test]
280    fn large_precision_near_i128_boundary() {
281        // Decimal128 max precision=38, so the largest i128 value is ~1.7e38
282        // This just verifies we don't overflow for values within p=18,s=0
283        let big = "999999999999999999"; // 18 nines
284        assert_eq!(
285            decimal_str_to_scaled_i128(big, 0),
286            Some(999_999_999_999_999_999i128)
287        );
288    }
289
290    /// Overflow edge cases: a value beyond `i128` range must return `None`,
291    /// never panic or wrap. Guards the `checked_mul`/`checked_add` + parse
292    /// paths. See CLAUDE.md "Remediation hints must recover from the degraded
293    /// state" — a wrapped decimal is exactly the silent corruption we forbid.
294    #[test]
295    fn value_beyond_i128_returns_none_not_panic() {
296        // 40-digit integer cannot fit i128 → parse fails → None.
297        let too_big = format!("1{}", "0".repeat(40));
298        assert_eq!(decimal_str_to_scaled_i128(&too_big, 0), None);
299
300        // 38 nines fits i128 (~1e38 < i128::MAX ~1.7e38) at scale 0 …
301        let max_digits = "9".repeat(38);
302        assert!(decimal_str_to_scaled_i128(&max_digits, 0).is_some());
303        // … but scaling it by 10^2 overflows i128 → None, not a wrap.
304        assert_eq!(decimal_str_to_scaled_i128(&max_digits, 2), None);
305
306        // Fractional overflow: huge integer part + any scale.
307        assert_eq!(
308            decimal_str_to_scaled_i128(&format!("{max_digits}.5"), 5),
309            None
310        );
311    }
312
313    // ── i256 (Decimal256) path: the i128 bottleneck is gone ──────────────────
314
315    #[test]
316    fn i256_handles_values_beyond_i128() {
317        // A 45-digit integer overflows i128 (~38 digits) but fits i256.
318        let big = "123456789012345678901234567890123456789012345";
319        assert_eq!(decimal_str_to_scaled_i128(big, 0), None, "i128 overflows");
320        assert_eq!(
321            decimal_str_to_scaled_i256(big, 0).unwrap(),
322            i256::from_string(big).unwrap()
323        );
324        // With a fractional part scaled in.
325        let v = decimal_str_to_scaled_i256("123456789012345678901234567890123456789012.345", 3)
326            .unwrap();
327        assert_eq!(
328            v,
329            i256::from_string("123456789012345678901234567890123456789012345").unwrap()
330        );
331    }
332
333    #[test]
334    fn i256_matches_i128_for_in_range_values() {
335        for (s, scale) in [("123.45", 2i8), ("-1.23", 2), ("0.10", 2), ("1200", -2)] {
336            let small = decimal_str_to_scaled_i128(s, scale).unwrap();
337            assert_eq!(
338                decimal_str_to_scaled_i256(s, scale).unwrap(),
339                i256::from_i128(small),
340                "i256 and i128 must agree for in-range value {s}"
341            );
342        }
343    }
344
345    #[test]
346    fn scale_int_to_i256_scales_beyond_i128() {
347        // u64::MAX (~1.8e19) scaled by 10^30 ≈ 1.8e49 — fits i256.
348        assert!(scale_int_to_i256(u64::MAX as i128, 30).is_some());
349        assert_eq!(scale_int_to_i256(5, 2), Some(i256::from_i128(500)));
350        assert_eq!(scale_int_to_i256(123, -1), None, "negative scale rejected");
351    }
352
353    #[test]
354    fn pow10_i256_matches_string_form_and_respects_ceiling() {
355        // The checked_mul loop must equal the old format!+from_string for every
356        // power, span the i128 boundary, reach the Decimal256 ceiling (10^76),
357        // and overflow to None beyond it.
358        for n in [0u32, 1, 5, 18, 38, 39, 76] {
359            let expected = i256::from_string(&format!("1{}", "0".repeat(n as usize)));
360            assert_eq!(pow10_i256(n), expected, "10^{n} mismatch");
361        }
362        assert!(pow10_i256(76).is_some(), "10^76 fits i256");
363        assert!(pow10_i256(77).is_none(), "10^77 overflows i256 → None");
364    }
365}