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        // frac_part.len() >= scale_u. Keep exactly `scale` digits — but a NON-ZERO
107        // dropped digit is a lossy down-scale (a `columns:` override under-declaring
108        // the source scale), so return None and let the caller fail LOUDLY, matching
109        // MSSQL's rescale_i128 (round-2 audit #20) rather than silently truncating
110        // financial digits. Autodetect never trips this (its scale == the value's);
111        // trailing zeros drop harmlessly.
112        // `.get()` (not `split_at`) so a non-char-boundary index on arbitrary
113        // input returns None rather than panicking — the parser stays total.
114        let keep = frac_part.get(..scale_u as usize)?;
115        let dropped = frac_part.get(scale_u as usize..)?;
116        if dropped.bytes().any(|b| b != b'0') {
117            return None;
118        }
119        keep.parse().ok()?
120    };
121
122    let scale_factor = 10i128.pow(scale_u);
123    let result = int_val
124        .checked_mul(scale_factor)?
125        .checked_add(frac_aligned)?;
126    Some(if negative { -result } else { result })
127}
128
129/// `Decimal256` analogue of [`decimal_str_to_scaled_i128`] — parses straight
130/// into `i256` so values beyond `i128` (precision 39–76) are not truncated.
131/// Returns `None` for empty / non-numeric strings or `i256` overflow.
132pub fn decimal_str_to_scaled_i256(s: &str, scale: i8) -> Option<i256> {
133    let s = s.trim();
134    if s.is_empty() {
135        return None;
136    }
137    let negative = s.starts_with('-');
138    let s = if negative {
139        &s[1..]
140    } else {
141        s.trim_start_matches('+')
142    };
143
144    if scale < 0 {
145        let divisor = pow10_i256(scale.unsigned_abs() as u32)?;
146        let int_val = i256::from_string(s.split('.').next()?.trim())?;
147        let result = int_val.checked_div(divisor)?;
148        return Some(if negative { -result } else { result });
149    }
150
151    let scale_u = scale as u32;
152    let (int_part, frac_part) = match s.find('.') {
153        Some(dot) => (&s[..dot], &s[dot + 1..]),
154        None => (s, ""),
155    };
156    let int_val = if int_part.is_empty() {
157        i256::ZERO
158    } else {
159        i256::from_string(int_part)?
160    };
161    let frac_aligned = if scale_u == 0 {
162        i256::ZERO
163    } else if frac_part.len() < scale_u as usize {
164        let mut buf = String::with_capacity(scale_u as usize);
165        buf.push_str(frac_part);
166        for _ in 0..(scale_u as usize - frac_part.len()) {
167            buf.push('0');
168        }
169        i256::from_string(&buf)?
170    } else {
171        // See the i128 sibling: a non-zero dropped digit is a lossy down-scale —
172        // return None so the caller fails loudly (round-2 audit #20). `.get()`
173        // keeps the parser total on a non-char-boundary index.
174        let keep = frac_part.get(..scale_u as usize)?;
175        let dropped = frac_part.get(scale_u as usize..)?;
176        if dropped.bytes().any(|b| b != b'0') {
177            return None;
178        }
179        i256::from_string(keep)?
180    };
181
182    let scale_factor = pow10_i256(scale_u)?;
183    let result = int_val
184        .checked_mul(scale_factor)?
185        .checked_add(frac_aligned)?;
186    Some(if negative { -result } else { result })
187}
188
189/// Scale an integer (already widened to `i128`) by `10^scale` into `i256` — the
190/// `Decimal256` analogue of the source drivers' `scale_int_to_i128`. `None` on
191/// negative scale or `i256` overflow.
192pub fn scale_int_to_i256(v: i128, scale: i8) -> Option<i256> {
193    if scale < 0 {
194        return None;
195    }
196    i256::from_i128(v).checked_mul(pow10_i256(scale as u32)?)
197}
198
199/// `10^n` as `i256` (up to 10^76, the `Decimal256` scale ceiling); `None` if it
200/// would exceed `i256`. Repeated `checked_mul` rather than the old
201/// `format!("1{0:0}")` + `i256::from_string` — no per-value string allocation
202/// or parse on the Decimal256 scale path; same overflow contract.
203fn pow10_i256(n: u32) -> Option<i256> {
204    let ten = i256::from_i128(10);
205    let mut acc = i256::from_i128(1);
206    for _ in 0..n {
207        acc = acc.checked_mul(ten)?;
208    }
209    Some(acc)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn scaled_to_str_roundtrip_financial() {
218        assert_eq!(scaled_i128_to_decimal_str(10, 2), "0.10");
219        assert_eq!(scaled_i128_to_decimal_str(12345, 2), "123.45");
220        assert_eq!(scaled_i128_to_decimal_str(-123, 2), "-1.23");
221        assert_eq!(scaled_i128_to_decimal_str(10_123_456, 6), "10.123456");
222    }
223
224    #[test]
225    fn scaled_zero_formats_unsigned() {
226        // Mutation-pilot find: `negative = value < 0` mutated to `<=` survived
227        // the suite — zero would render as "-0.00" (the sign is computed before
228        // formatting). A signed zero leaks into CSV output and checksum inputs.
229        assert_eq!(scaled_i128_to_decimal_str(0, 2), "0.00");
230        assert_eq!(scaled_i128_to_decimal_str(0, 0), "0");
231        assert_eq!(scaled_i128_to_decimal_str(0, 6), "0.000000");
232    }
233
234    #[test]
235    fn standard_financial_values() {
236        assert_eq!(decimal_str_to_scaled_i128("0.10", 2), Some(10));
237        assert_eq!(decimal_str_to_scaled_i128("0.20", 2), Some(20));
238        assert_eq!(decimal_str_to_scaled_i128("0.30", 2), Some(30));
239        assert_eq!(decimal_str_to_scaled_i128("123.45", 2), Some(12345));
240        assert_eq!(decimal_str_to_scaled_i128("-1.23", 2), Some(-123));
241        assert_eq!(decimal_str_to_scaled_i128("-100.05", 2), Some(-10005));
242    }
243
244    /// Roadmap §12 golden-test values: SUM(amount) must equal 999999999900.24
245    #[test]
246    fn golden_test_payment_values() {
247        let rows = [
248            ("0.10", 10i128),
249            ("0.20", 20),
250            ("999999999999.99", 99999999999999),
251            ("-100.05", -10005),
252        ];
253        let sum: i128 = rows.iter().map(|(_, v)| v).sum();
254        // 10 + 20 + 99999999999999 + (-10005) = 99999999990024
255        // = 999999999900.24 × 100
256        assert_eq!(sum, 99999999990024);
257
258        for (s, expected) in &rows {
259            assert_eq!(
260                decimal_str_to_scaled_i128(s, 2),
261                Some(*expected),
262                "mismatch for '{s}'"
263            );
264        }
265    }
266
267    #[test]
268    fn integer_valued_decimal_with_nonzero_scale() {
269        assert_eq!(decimal_str_to_scaled_i128("100", 2), Some(10000));
270        assert_eq!(decimal_str_to_scaled_i128("0", 2), Some(0));
271    }
272
273    #[test]
274    fn frac_shorter_than_scale_is_right_padded() {
275        // "0.1" with scale=3 means 0.100 → 100
276        assert_eq!(decimal_str_to_scaled_i128("0.1", 3), Some(100));
277        // "5.4" with scale=6 means 5.400000 → 5400000
278        assert_eq!(decimal_str_to_scaled_i128("5.4", 6), Some(5_400_000));
279    }
280
281    #[test]
282    fn negative_scale_represents_large_round_numbers() {
283        // scale=-2: values are multiples of 100
284        assert_eq!(decimal_str_to_scaled_i128("1200", -2), Some(12));
285        assert_eq!(decimal_str_to_scaled_i128("50000", -2), Some(500));
286    }
287
288    #[test]
289    fn zero_scale_ignores_fractional_digits() {
290        assert_eq!(decimal_str_to_scaled_i128("42", 0), Some(42));
291        assert_eq!(decimal_str_to_scaled_i128("42.0", 0), Some(42));
292    }
293
294    #[test]
295    fn null_like_empty_string_returns_none() {
296        assert_eq!(decimal_str_to_scaled_i128("", 2), None);
297        assert_eq!(decimal_str_to_scaled_i128("  ", 2), None);
298    }
299
300    #[test]
301    fn non_numeric_string_returns_none() {
302        assert_eq!(decimal_str_to_scaled_i128("NaN", 2), None);
303        assert_eq!(decimal_str_to_scaled_i128("Infinity", 2), None);
304    }
305
306    #[test]
307    fn lossy_scale_downcast_fails_loudly_lossless_still_ok() {
308        // Round-2 audit #20: a `columns:` override under-declaring the source scale
309        // must NOT silently truncate financial digits — a non-zero dropped digit
310        // returns None (the PG/MySQL builders turn that into a loud error, matching
311        // MSSQL's rescale_i128). RED before the guard (it truncated to Some(123)).
312        assert_eq!(
313            decimal_str_to_scaled_i128("1.234567", 2),
314            None,
315            "i128 lossy"
316        );
317        assert_eq!(
318            decimal_str_to_scaled_i256("1.234567", 2),
319            None,
320            "i256 lossy"
321        );
322        // Lossless: the dropped digits are zeros → keep exactly `scale` digits.
323        assert_eq!(decimal_str_to_scaled_i128("1.2300", 2), Some(123));
324        assert_eq!(
325            decimal_str_to_scaled_i256("1.2300", 2),
326            Some(i256::from_i128(123))
327        );
328        // Exact-scale input (the money path shape) is unaffected.
329        assert_eq!(decimal_str_to_scaled_i128("1.23", 2), Some(123));
330    }
331
332    #[test]
333    fn large_precision_near_i128_boundary() {
334        // Decimal128 max precision=38, so the largest i128 value is ~1.7e38
335        // This just verifies we don't overflow for values within p=18,s=0
336        let big = "999999999999999999"; // 18 nines
337        assert_eq!(
338            decimal_str_to_scaled_i128(big, 0),
339            Some(999_999_999_999_999_999i128)
340        );
341    }
342
343    /// Overflow edge cases: a value beyond `i128` range must return `None`,
344    /// never panic or wrap. Guards the `checked_mul`/`checked_add` + parse
345    /// paths. See CLAUDE.md "Remediation hints must recover from the degraded
346    /// state" — a wrapped decimal is exactly the silent corruption we forbid.
347    #[test]
348    fn value_beyond_i128_returns_none_not_panic() {
349        // 40-digit integer cannot fit i128 → parse fails → None.
350        let too_big = format!("1{}", "0".repeat(40));
351        assert_eq!(decimal_str_to_scaled_i128(&too_big, 0), None);
352
353        // 38 nines fits i128 (~1e38 < i128::MAX ~1.7e38) at scale 0 …
354        let max_digits = "9".repeat(38);
355        assert!(decimal_str_to_scaled_i128(&max_digits, 0).is_some());
356        // … but scaling it by 10^2 overflows i128 → None, not a wrap.
357        assert_eq!(decimal_str_to_scaled_i128(&max_digits, 2), None);
358
359        // Fractional overflow: huge integer part + any scale.
360        assert_eq!(
361            decimal_str_to_scaled_i128(&format!("{max_digits}.5"), 5),
362            None
363        );
364    }
365
366    // ── i256 (Decimal256) path: the i128 bottleneck is gone ──────────────────
367
368    #[test]
369    fn i256_handles_values_beyond_i128() {
370        // A 45-digit integer overflows i128 (~38 digits) but fits i256.
371        let big = "123456789012345678901234567890123456789012345";
372        assert_eq!(decimal_str_to_scaled_i128(big, 0), None, "i128 overflows");
373        assert_eq!(
374            decimal_str_to_scaled_i256(big, 0).unwrap(),
375            i256::from_string(big).unwrap()
376        );
377        // With a fractional part scaled in.
378        let v = decimal_str_to_scaled_i256("123456789012345678901234567890123456789012.345", 3)
379            .unwrap();
380        assert_eq!(
381            v,
382            i256::from_string("123456789012345678901234567890123456789012345").unwrap()
383        );
384    }
385
386    #[test]
387    fn i256_matches_i128_for_in_range_values() {
388        for (s, scale) in [("123.45", 2i8), ("-1.23", 2), ("0.10", 2), ("1200", -2)] {
389            let small = decimal_str_to_scaled_i128(s, scale).unwrap();
390            assert_eq!(
391                decimal_str_to_scaled_i256(s, scale).unwrap(),
392                i256::from_i128(small),
393                "i256 and i128 must agree for in-range value {s}"
394            );
395        }
396    }
397
398    #[test]
399    fn scale_int_to_i256_scales_beyond_i128() {
400        // u64::MAX (~1.8e19) scaled by 10^30 ≈ 1.8e49 — fits i256.
401        assert!(scale_int_to_i256(u64::MAX as i128, 30).is_some());
402        assert_eq!(scale_int_to_i256(5, 2), Some(i256::from_i128(500)));
403        assert_eq!(scale_int_to_i256(123, -1), None, "negative scale rejected");
404    }
405
406    #[test]
407    fn pow10_i256_matches_string_form_and_respects_ceiling() {
408        // The checked_mul loop must equal the old format!+from_string for every
409        // power, span the i128 boundary, reach the Decimal256 ceiling (10^76),
410        // and overflow to None beyond it.
411        for n in [0u32, 1, 5, 18, 38, 39, 76] {
412            let expected = i256::from_string(&format!("1{}", "0".repeat(n as usize)));
413            assert_eq!(pow10_i256(n), expected, "10^{n} mismatch");
414        }
415        assert!(pow10_i256(76).is_some(), "10^76 fits i256");
416        assert!(pow10_i256(77).is_none(), "10^77 overflows i256 → None");
417    }
418}
419
420#[cfg(test)]
421mod fuzz {
422    use super::*;
423
424    proptest::proptest! {
425        #![proptest_config(proptest::prelude::ProptestConfig {
426            cases: 256, ..Default::default()
427        })]
428
429        // Total on arbitrary text; EXACT on well-formed digit strings — the
430        // one decimal canon must scale int_part·10^s + frac verbatim.
431        #[test]
432        fn decimal_parsers_total_and_exact(
433            junk in ".{0,60}",
434            int_part in 0u64..1_000_000_000_000,
435            frac in 0u32..10_000,
436            // The fraction's WIDTH varies independently of the scale — the
437            // first fuzz shape always rendered exactly scale digits, so the
438            // zero-PADDING arm (frac shorter than scale) was never entered
439            // and its `-`→`+` mutant survived.
440            frac_width in 0usize..=6,
441            neg in proptest::prelude::any::<bool>(),
442        ) {
443            let _ = decimal_str_to_scaled_i128(&junk, 4);
444            let _ = decimal_str_to_scaled_i256(&junk, 4);
445            let frac_str = format!("{frac:04}");
446            let frac_used: String = frac_str.chars().cycle().take(frac_width).collect();
447            let s = format!(
448                "{}{}{}{}",
449                if neg { "-" } else { "" },
450                int_part,
451                if frac_width > 0 { "." } else { "" },
452                frac_used
453            );
454            // Round-2 audit #20: a NON-ZERO digit dropped beyond scale 4 is a
455            // lossy down-scale → the parsers now return None (loud), not a silently
456            // truncated value. Only a lossless case (frac ≤ 4 digits, or the extra
457            // digits are zeros) yields Some, padded/kept to exactly 4 digits.
458            let dropped = frac_used.get(4..).unwrap_or("");
459            if dropped.bytes().any(|b| b != b'0') {
460                proptest::prop_assert_eq!(decimal_str_to_scaled_i128(&s, 4), None);
461                proptest::prop_assert_eq!(decimal_str_to_scaled_i256(&s, 4), None);
462            } else {
463                let aligned: String = frac_used
464                    .chars()
465                    .chain(std::iter::repeat('0'))
466                    .take(4)
467                    .collect();
468                let mag = int_part as i128 * 10_000 + aligned.parse::<i128>().unwrap();
469                let expect = if neg { -mag } else { mag };
470                proptest::prop_assert_eq!(decimal_str_to_scaled_i128(&s, 4), Some(expect));
471                proptest::prop_assert_eq!(
472                    decimal_str_to_scaled_i256(&s, 4),
473                    Some(arrow::datatypes::i256::from_i128(expect))
474                );
475            }
476        }
477
478        // scale_int_to_i256 is LIVE in the mysql batch Decimal256 path for
479        // integer wire cells — pin exact scaling + the negative-scale refusal
480        // (its guard's mutants survived: nothing exercised the function).
481        #[test]
482        fn scale_int_to_i256_scales_exactly(v in -1_000_000i128..1_000_000, scale in 0i8..10) {
483            let got = scale_int_to_i256(v, scale).expect("in range");
484            let expect = arrow::datatypes::i256::from_i128(v * 10i128.pow(scale as u32));
485            proptest::prop_assert_eq!(got, expect);
486            proptest::prop_assert_eq!(scale_int_to_i256(v, -1), None);
487        }
488    }
489}
490
491#[cfg(test)]
492mod mutant_kills {
493    //! Targeted kills for the cargo-mutants survivors (the meta-gate's first
494    //! harvest). Each test pins exactly the behaviour a surviving mutant
495    //! would corrupt — most sit on the NEGATIVE-scale arms (unreachable from
496    //! today's engine mappings, but live in the public CSV-render path) and
497    //! on the CSV renderer's digit arithmetic.
498    use super::*;
499
500    // Kills: `delete -` in both parses (a negative value must stay negative
501    // through the negative-scale arm), and `<`→`==`/`<=` on the arm guards.
502    #[test]
503    fn negative_scale_parse_keeps_the_sign_and_scales_down() {
504        assert_eq!(decimal_str_to_scaled_i128("1200", -2), Some(12));
505        assert_eq!(decimal_str_to_scaled_i128("-1200", -2), Some(-12));
506        assert_eq!(decimal_str_to_scaled_i128("-1200.99", -2), Some(-12));
507        assert_eq!(
508            decimal_str_to_scaled_i256("-3400", -2),
509            Some(arrow::datatypes::i256::from_i128(-34))
510        );
511        // scale == 0 must NOT take the negative-scale arm (kills < → <=).
512        assert_eq!(decimal_str_to_scaled_i128("-7", 0), Some(-7));
513        assert_eq!(
514            decimal_str_to_scaled_i256("-7", 0),
515            Some(arrow::datatypes::i256::from_i128(-7))
516        );
517    }
518
519    // Kills the renderer survivors: `*`→`+`/`/` on the negative-scale factor,
520    // `<`→`==`/`<=` on its guard, and the fraction zero-PADDING width (the
521    // CSV path — a mutant here writes corrupt decimals into every CSV export).
522    #[test]
523    fn csv_decimal_renderer_is_exact_for_every_scale_shape() {
524        // negative scale: stored 12 at scale -2 renders whole hundreds.
525        assert_eq!(scaled_i128_to_decimal_str(12, -2), "1200");
526        assert_eq!(scaled_i128_to_decimal_str(-12, -2), "-1200");
527        // zero scale: verbatim integer.
528        assert_eq!(scaled_i128_to_decimal_str(-7, 0), "-7");
529        // positive scale: exact digits, fraction padded to WIDTH — "1.05",
530        // never "1.5"; "1.230" would be a padding mutant, "1.23" is truth.
531        assert_eq!(scaled_i128_to_decimal_str(105, 2), "1.05");
532        assert_eq!(scaled_i128_to_decimal_str(123, 2), "1.23");
533        assert_eq!(scaled_i128_to_decimal_str(-1, 4), "-0.0001");
534        assert_eq!(scaled_i128_to_decimal_str(10, 2), "0.10");
535    }
536}