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.get(..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.get(..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 scaled_zero_formats_unsigned() {
209        // Mutation-pilot find: `negative = value < 0` mutated to `<=` survived
210        // the suite — zero would render as "-0.00" (the sign is computed before
211        // formatting). A signed zero leaks into CSV output and checksum inputs.
212        assert_eq!(scaled_i128_to_decimal_str(0, 2), "0.00");
213        assert_eq!(scaled_i128_to_decimal_str(0, 0), "0");
214        assert_eq!(scaled_i128_to_decimal_str(0, 6), "0.000000");
215    }
216
217    #[test]
218    fn standard_financial_values() {
219        assert_eq!(decimal_str_to_scaled_i128("0.10", 2), Some(10));
220        assert_eq!(decimal_str_to_scaled_i128("0.20", 2), Some(20));
221        assert_eq!(decimal_str_to_scaled_i128("0.30", 2), Some(30));
222        assert_eq!(decimal_str_to_scaled_i128("123.45", 2), Some(12345));
223        assert_eq!(decimal_str_to_scaled_i128("-1.23", 2), Some(-123));
224        assert_eq!(decimal_str_to_scaled_i128("-100.05", 2), Some(-10005));
225    }
226
227    /// Roadmap §12 golden-test values: SUM(amount) must equal 999999999900.24
228    #[test]
229    fn golden_test_payment_values() {
230        let rows = [
231            ("0.10", 10i128),
232            ("0.20", 20),
233            ("999999999999.99", 99999999999999),
234            ("-100.05", -10005),
235        ];
236        let sum: i128 = rows.iter().map(|(_, v)| v).sum();
237        // 10 + 20 + 99999999999999 + (-10005) = 99999999990024
238        // = 999999999900.24 × 100
239        assert_eq!(sum, 99999999990024);
240
241        for (s, expected) in &rows {
242            assert_eq!(
243                decimal_str_to_scaled_i128(s, 2),
244                Some(*expected),
245                "mismatch for '{s}'"
246            );
247        }
248    }
249
250    #[test]
251    fn integer_valued_decimal_with_nonzero_scale() {
252        assert_eq!(decimal_str_to_scaled_i128("100", 2), Some(10000));
253        assert_eq!(decimal_str_to_scaled_i128("0", 2), Some(0));
254    }
255
256    #[test]
257    fn frac_shorter_than_scale_is_right_padded() {
258        // "0.1" with scale=3 means 0.100 → 100
259        assert_eq!(decimal_str_to_scaled_i128("0.1", 3), Some(100));
260        // "5.4" with scale=6 means 5.400000 → 5400000
261        assert_eq!(decimal_str_to_scaled_i128("5.4", 6), Some(5_400_000));
262    }
263
264    #[test]
265    fn negative_scale_represents_large_round_numbers() {
266        // scale=-2: values are multiples of 100
267        assert_eq!(decimal_str_to_scaled_i128("1200", -2), Some(12));
268        assert_eq!(decimal_str_to_scaled_i128("50000", -2), Some(500));
269    }
270
271    #[test]
272    fn zero_scale_ignores_fractional_digits() {
273        assert_eq!(decimal_str_to_scaled_i128("42", 0), Some(42));
274        assert_eq!(decimal_str_to_scaled_i128("42.0", 0), Some(42));
275    }
276
277    #[test]
278    fn null_like_empty_string_returns_none() {
279        assert_eq!(decimal_str_to_scaled_i128("", 2), None);
280        assert_eq!(decimal_str_to_scaled_i128("  ", 2), None);
281    }
282
283    #[test]
284    fn non_numeric_string_returns_none() {
285        assert_eq!(decimal_str_to_scaled_i128("NaN", 2), None);
286        assert_eq!(decimal_str_to_scaled_i128("Infinity", 2), None);
287    }
288
289    #[test]
290    fn large_precision_near_i128_boundary() {
291        // Decimal128 max precision=38, so the largest i128 value is ~1.7e38
292        // This just verifies we don't overflow for values within p=18,s=0
293        let big = "999999999999999999"; // 18 nines
294        assert_eq!(
295            decimal_str_to_scaled_i128(big, 0),
296            Some(999_999_999_999_999_999i128)
297        );
298    }
299
300    /// Overflow edge cases: a value beyond `i128` range must return `None`,
301    /// never panic or wrap. Guards the `checked_mul`/`checked_add` + parse
302    /// paths. See CLAUDE.md "Remediation hints must recover from the degraded
303    /// state" — a wrapped decimal is exactly the silent corruption we forbid.
304    #[test]
305    fn value_beyond_i128_returns_none_not_panic() {
306        // 40-digit integer cannot fit i128 → parse fails → None.
307        let too_big = format!("1{}", "0".repeat(40));
308        assert_eq!(decimal_str_to_scaled_i128(&too_big, 0), None);
309
310        // 38 nines fits i128 (~1e38 < i128::MAX ~1.7e38) at scale 0 …
311        let max_digits = "9".repeat(38);
312        assert!(decimal_str_to_scaled_i128(&max_digits, 0).is_some());
313        // … but scaling it by 10^2 overflows i128 → None, not a wrap.
314        assert_eq!(decimal_str_to_scaled_i128(&max_digits, 2), None);
315
316        // Fractional overflow: huge integer part + any scale.
317        assert_eq!(
318            decimal_str_to_scaled_i128(&format!("{max_digits}.5"), 5),
319            None
320        );
321    }
322
323    // ── i256 (Decimal256) path: the i128 bottleneck is gone ──────────────────
324
325    #[test]
326    fn i256_handles_values_beyond_i128() {
327        // A 45-digit integer overflows i128 (~38 digits) but fits i256.
328        let big = "123456789012345678901234567890123456789012345";
329        assert_eq!(decimal_str_to_scaled_i128(big, 0), None, "i128 overflows");
330        assert_eq!(
331            decimal_str_to_scaled_i256(big, 0).unwrap(),
332            i256::from_string(big).unwrap()
333        );
334        // With a fractional part scaled in.
335        let v = decimal_str_to_scaled_i256("123456789012345678901234567890123456789012.345", 3)
336            .unwrap();
337        assert_eq!(
338            v,
339            i256::from_string("123456789012345678901234567890123456789012345").unwrap()
340        );
341    }
342
343    #[test]
344    fn i256_matches_i128_for_in_range_values() {
345        for (s, scale) in [("123.45", 2i8), ("-1.23", 2), ("0.10", 2), ("1200", -2)] {
346            let small = decimal_str_to_scaled_i128(s, scale).unwrap();
347            assert_eq!(
348                decimal_str_to_scaled_i256(s, scale).unwrap(),
349                i256::from_i128(small),
350                "i256 and i128 must agree for in-range value {s}"
351            );
352        }
353    }
354
355    #[test]
356    fn scale_int_to_i256_scales_beyond_i128() {
357        // u64::MAX (~1.8e19) scaled by 10^30 ≈ 1.8e49 — fits i256.
358        assert!(scale_int_to_i256(u64::MAX as i128, 30).is_some());
359        assert_eq!(scale_int_to_i256(5, 2), Some(i256::from_i128(500)));
360        assert_eq!(scale_int_to_i256(123, -1), None, "negative scale rejected");
361    }
362
363    #[test]
364    fn pow10_i256_matches_string_form_and_respects_ceiling() {
365        // The checked_mul loop must equal the old format!+from_string for every
366        // power, span the i128 boundary, reach the Decimal256 ceiling (10^76),
367        // and overflow to None beyond it.
368        for n in [0u32, 1, 5, 18, 38, 39, 76] {
369            let expected = i256::from_string(&format!("1{}", "0".repeat(n as usize)));
370            assert_eq!(pow10_i256(n), expected, "10^{n} mismatch");
371        }
372        assert!(pow10_i256(76).is_some(), "10^76 fits i256");
373        assert!(pow10_i256(77).is_none(), "10^77 overflows i256 → None");
374    }
375}
376
377#[cfg(test)]
378mod fuzz {
379    use super::*;
380
381    proptest::proptest! {
382        #![proptest_config(proptest::prelude::ProptestConfig {
383            cases: 256, ..Default::default()
384        })]
385
386        // Total on arbitrary text; EXACT on well-formed digit strings — the
387        // one decimal canon must scale int_part·10^s + frac verbatim.
388        #[test]
389        fn decimal_parsers_total_and_exact(
390            junk in ".{0,60}",
391            int_part in 0u64..1_000_000_000_000,
392            frac in 0u32..10_000,
393            // The fraction's WIDTH varies independently of the scale — the
394            // first fuzz shape always rendered exactly scale digits, so the
395            // zero-PADDING arm (frac shorter than scale) was never entered
396            // and its `-`→`+` mutant survived.
397            frac_width in 0usize..=6,
398            neg in proptest::prelude::any::<bool>(),
399        ) {
400            let _ = decimal_str_to_scaled_i128(&junk, 4);
401            let _ = decimal_str_to_scaled_i256(&junk, 4);
402            let frac_str = format!("{frac:04}");
403            let frac_used: String = frac_str.chars().cycle().take(frac_width).collect();
404            let s = format!(
405                "{}{}{}{}",
406                if neg { "-" } else { "" },
407                int_part,
408                if frac_width > 0 { "." } else { "" },
409                frac_used
410            );
411            // Expected: pad-or-truncate frac_used to exactly 4 digits.
412            let aligned: String = frac_used
413                .chars()
414                .chain(std::iter::repeat('0'))
415                .take(4)
416                .collect();
417            let expect = {
418                let mag = int_part as i128 * 10_000 + aligned.parse::<i128>().unwrap();
419                if neg { -mag } else { mag }
420            };
421            proptest::prop_assert_eq!(decimal_str_to_scaled_i128(&s, 4), Some(expect));
422            proptest::prop_assert_eq!(
423                decimal_str_to_scaled_i256(&s, 4),
424                Some(arrow::datatypes::i256::from_i128(expect))
425            );
426        }
427
428        // scale_int_to_i256 is LIVE in the mysql batch Decimal256 path for
429        // integer wire cells — pin exact scaling + the negative-scale refusal
430        // (its guard's mutants survived: nothing exercised the function).
431        #[test]
432        fn scale_int_to_i256_scales_exactly(v in -1_000_000i128..1_000_000, scale in 0i8..10) {
433            let got = scale_int_to_i256(v, scale).expect("in range");
434            let expect = arrow::datatypes::i256::from_i128(v * 10i128.pow(scale as u32));
435            proptest::prop_assert_eq!(got, expect);
436            proptest::prop_assert_eq!(scale_int_to_i256(v, -1), None);
437        }
438    }
439}
440
441#[cfg(test)]
442mod mutant_kills {
443    //! Targeted kills for the cargo-mutants survivors (the meta-gate's first
444    //! harvest). Each test pins exactly the behaviour a surviving mutant
445    //! would corrupt — most sit on the NEGATIVE-scale arms (unreachable from
446    //! today's engine mappings, but live in the public CSV-render path) and
447    //! on the CSV renderer's digit arithmetic.
448    use super::*;
449
450    // Kills: `delete -` in both parses (a negative value must stay negative
451    // through the negative-scale arm), and `<`→`==`/`<=` on the arm guards.
452    #[test]
453    fn negative_scale_parse_keeps_the_sign_and_scales_down() {
454        assert_eq!(decimal_str_to_scaled_i128("1200", -2), Some(12));
455        assert_eq!(decimal_str_to_scaled_i128("-1200", -2), Some(-12));
456        assert_eq!(decimal_str_to_scaled_i128("-1200.99", -2), Some(-12));
457        assert_eq!(
458            decimal_str_to_scaled_i256("-3400", -2),
459            Some(arrow::datatypes::i256::from_i128(-34))
460        );
461        // scale == 0 must NOT take the negative-scale arm (kills < → <=).
462        assert_eq!(decimal_str_to_scaled_i128("-7", 0), Some(-7));
463        assert_eq!(
464            decimal_str_to_scaled_i256("-7", 0),
465            Some(arrow::datatypes::i256::from_i128(-7))
466        );
467    }
468
469    // Kills the renderer survivors: `*`→`+`/`/` on the negative-scale factor,
470    // `<`→`==`/`<=` on its guard, and the fraction zero-PADDING width (the
471    // CSV path — a mutant here writes corrupt decimals into every CSV export).
472    #[test]
473    fn csv_decimal_renderer_is_exact_for_every_scale_shape() {
474        // negative scale: stored 12 at scale -2 renders whole hundreds.
475        assert_eq!(scaled_i128_to_decimal_str(12, -2), "1200");
476        assert_eq!(scaled_i128_to_decimal_str(-12, -2), "-1200");
477        // zero scale: verbatim integer.
478        assert_eq!(scaled_i128_to_decimal_str(-7, 0), "-7");
479        // positive scale: exact digits, fraction padded to WIDTH — "1.05",
480        // never "1.5"; "1.230" would be a padding mutant, "1.23" is truth.
481        assert_eq!(scaled_i128_to_decimal_str(105, 2), "1.05");
482        assert_eq!(scaled_i128_to_decimal_str(123, 2), "1.23");
483        assert_eq!(scaled_i128_to_decimal_str(-1, 4), "-0.0001");
484        assert_eq!(scaled_i128_to_decimal_str(10, 2), "0.10");
485    }
486}