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/// ```
33/// Format a scaled `Decimal128` value as exact decimal text (no float).
34pub fn scaled_i128_to_decimal_str(value: i128, scale: i8) -> String {
35    if scale < 0 {
36        let factor = 10i128.pow(scale.unsigned_abs() as u32);
37        return (value * factor).to_string();
38    }
39    let scale_u = scale as u32;
40    if scale_u == 0 {
41        return value.to_string();
42    }
43    let factor = 10i128.pow(scale_u);
44    let negative = value < 0;
45    let abs = value.abs();
46    let int_part = abs / factor;
47    let frac = abs % factor;
48    format!(
49        "{sign}{int_part}.{frac:0width$}",
50        sign = if negative { "-" } else { "" },
51        width = scale_u as usize
52    )
53}
54
55pub fn decimal_str_to_scaled_i128(s: &str, scale: i8) -> Option<i128> {
56    let s = s.trim();
57    if s.is_empty() {
58        return None;
59    }
60
61    let negative = s.starts_with('-');
62    let s = if negative {
63        &s[1..]
64    } else {
65        s.trim_start_matches('+')
66    };
67
68    if scale < 0 {
69        // Negative scale: the stored integer represents multiples of 10^|scale|.
70        // E.g. scale=-2 means the column stores whole hundreds: "1200" → 12.
71        let divisor = 10i128.pow(scale.unsigned_abs() as u32);
72        let int_val: i128 = s.split('.').next()?.trim().parse().ok()?;
73        let result = int_val.checked_div(divisor)?;
74        return Some(if negative { -result } else { result });
75    }
76
77    let scale_u = scale as u32;
78
79    // Split on the decimal point (may be absent for integer-valued decimals).
80    let (int_part, frac_part) = if let Some(dot) = s.find('.') {
81        (&s[..dot], &s[dot + 1..])
82    } else {
83        (s, "")
84    };
85
86    let int_val: i128 = if int_part.is_empty() {
87        0
88    } else {
89        int_part.parse().ok()?
90    };
91
92    let frac_aligned: i128 = if scale_u == 0 {
93        0
94    } else if frac_part.len() < scale_u as usize {
95        // Pad right with zeros: "0.1" with scale=2 → frac "10" → 10
96        let mut buf = String::with_capacity(scale_u as usize);
97        buf.push_str(frac_part);
98        for _ in 0..(scale_u as usize - frac_part.len()) {
99            buf.push('0');
100        }
101        buf.parse().ok()?
102    } else {
103        // Truncate to exactly `scale` digits. If the source column truly has
104        // scale=2 and a DB value arrives with more digits, that is either a
105        // type mismatch or a DB rounding artefact — we preserve the declared
106        // scale rather than silently extending it.
107        frac_part[..scale_u as usize].parse().ok()?
108    };
109
110    let scale_factor = 10i128.pow(scale_u);
111    let result = int_val
112        .checked_mul(scale_factor)?
113        .checked_add(frac_aligned)?;
114    Some(if negative { -result } else { result })
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn scaled_to_str_roundtrip_financial() {
123        assert_eq!(scaled_i128_to_decimal_str(10, 2), "0.10");
124        assert_eq!(scaled_i128_to_decimal_str(12345, 2), "123.45");
125        assert_eq!(scaled_i128_to_decimal_str(-123, 2), "-1.23");
126        assert_eq!(scaled_i128_to_decimal_str(10_123_456, 6), "10.123456");
127    }
128
129    #[test]
130    fn standard_financial_values() {
131        assert_eq!(decimal_str_to_scaled_i128("0.10", 2), Some(10));
132        assert_eq!(decimal_str_to_scaled_i128("0.20", 2), Some(20));
133        assert_eq!(decimal_str_to_scaled_i128("0.30", 2), Some(30));
134        assert_eq!(decimal_str_to_scaled_i128("123.45", 2), Some(12345));
135        assert_eq!(decimal_str_to_scaled_i128("-1.23", 2), Some(-123));
136        assert_eq!(decimal_str_to_scaled_i128("-100.05", 2), Some(-10005));
137    }
138
139    /// Roadmap §12 golden-test values: SUM(amount) must equal 999999999900.24
140    #[test]
141    fn golden_test_payment_values() {
142        let rows = [
143            ("0.10", 10i128),
144            ("0.20", 20),
145            ("999999999999.99", 99999999999999),
146            ("-100.05", -10005),
147        ];
148        let sum: i128 = rows.iter().map(|(_, v)| v).sum();
149        // 10 + 20 + 99999999999999 + (-10005) = 99999999990024
150        // = 999999999900.24 × 100
151        assert_eq!(sum, 99999999990024);
152
153        for (s, expected) in &rows {
154            assert_eq!(
155                decimal_str_to_scaled_i128(s, 2),
156                Some(*expected),
157                "mismatch for '{s}'"
158            );
159        }
160    }
161
162    #[test]
163    fn integer_valued_decimal_with_nonzero_scale() {
164        assert_eq!(decimal_str_to_scaled_i128("100", 2), Some(10000));
165        assert_eq!(decimal_str_to_scaled_i128("0", 2), Some(0));
166    }
167
168    #[test]
169    fn frac_shorter_than_scale_is_right_padded() {
170        // "0.1" with scale=3 means 0.100 → 100
171        assert_eq!(decimal_str_to_scaled_i128("0.1", 3), Some(100));
172        // "5.4" with scale=6 means 5.400000 → 5400000
173        assert_eq!(decimal_str_to_scaled_i128("5.4", 6), Some(5_400_000));
174    }
175
176    #[test]
177    fn negative_scale_represents_large_round_numbers() {
178        // scale=-2: values are multiples of 100
179        assert_eq!(decimal_str_to_scaled_i128("1200", -2), Some(12));
180        assert_eq!(decimal_str_to_scaled_i128("50000", -2), Some(500));
181    }
182
183    #[test]
184    fn zero_scale_ignores_fractional_digits() {
185        assert_eq!(decimal_str_to_scaled_i128("42", 0), Some(42));
186        assert_eq!(decimal_str_to_scaled_i128("42.0", 0), Some(42));
187    }
188
189    #[test]
190    fn null_like_empty_string_returns_none() {
191        assert_eq!(decimal_str_to_scaled_i128("", 2), None);
192        assert_eq!(decimal_str_to_scaled_i128("  ", 2), None);
193    }
194
195    #[test]
196    fn non_numeric_string_returns_none() {
197        assert_eq!(decimal_str_to_scaled_i128("NaN", 2), None);
198        assert_eq!(decimal_str_to_scaled_i128("Infinity", 2), None);
199    }
200
201    #[test]
202    fn large_precision_near_i128_boundary() {
203        // Decimal128 max precision=38, so the largest i128 value is ~1.7e38
204        // This just verifies we don't overflow for values within p=18,s=0
205        let big = "999999999999999999"; // 18 nines
206        assert_eq!(
207            decimal_str_to_scaled_i128(big, 0),
208            Some(999_999_999_999_999_999i128)
209        );
210    }
211}