Skip to main content

icydb_schema/decimal/
mod.rs

1//! Module: types::decimal
2//! Defines the fixed-point decimal runtime type and its arithmetic,
3//! normalization, and value-conversion helpers.
4
5mod arithmetic;
6mod compare;
7mod text;
8mod wire;
9
10#[cfg(test)]
11mod tests;
12
13use crate::NumericValue;
14use std::fmt::{Display, Formatter};
15use std::str::FromStr;
16
17// We cap scale at 28 to keep i128 intermediate math practical while still
18// covering common fixed-point workloads (including e8/e18 compatibility).
19pub(crate) const MAX_SUPPORTED_SCALE: u32 = 28;
20pub(crate) const DEFAULT_DIVISION_SCALE: u32 = 18;
21pub(crate) const DECIMAL_DIGIT_BUFFER_LEN: usize = 39;
22
23///
24/// DecimalParts
25///
26/// Canonical decomposition of a `Decimal`.
27///
28/// `mantissa * 10^-scale` reconstructs the represented value, and the mantissa
29/// carries the sign.
30///
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct DecimalParts {
34    mantissa: i128,
35    scale: u32,
36}
37
38impl DecimalParts {
39    /// Return the canonical decimal mantissa component.
40    #[must_use]
41    pub const fn mantissa(&self) -> i128 {
42        self.mantissa
43    }
44
45    /// Return the canonical decimal scale component.
46    #[must_use]
47    pub const fn scale(&self) -> u32 {
48        self.scale
49    }
50}
51
52///
53/// ParseDecimalError
54///
55/// User-facing parse failure for decimal text input.
56///
57/// This keeps text parsing errors explicit without pulling transport or
58/// arithmetic semantics into the error surface.
59///
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct ParseDecimalError {
63    reason: ParseDecimalErrorReason,
64}
65
66impl ParseDecimalError {
67    pub(crate) const fn new(reason: ParseDecimalErrorReason) -> Self {
68        Self { reason }
69    }
70
71    /// Return the compact reason code for this parse failure.
72    #[must_use]
73    pub const fn reason(&self) -> ParseDecimalErrorReason {
74        self.reason
75    }
76}
77
78impl std::error::Error for ParseDecimalError {}
79
80impl Display for ParseDecimalError {
81    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
82        f.write_str("decimal parse error")
83    }
84}
85
86///
87/// ParseDecimalErrorReason
88///
89/// Compact decimal parse-failure reason.
90///
91
92#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93#[repr(u8)]
94pub enum ParseDecimalErrorReason {
95    Empty,
96    ExponentNotationUnsupported,
97    FractionalLengthOverflow,
98    ScaleOverflow,
99    MantissaOverflow,
100    ScaleExceedsSupportedRange,
101    InvalidSignificand,
102    InvalidDigits,
103}
104
105///
106/// Decimal
107///
108/// Owned fixed-point decimal with an explicit i128 mantissa and base-10 scale.
109///
110/// Arithmetic saturates on overflow, division by zero resolves to `ZERO`, and
111/// normalization keeps equivalent values on one canonical representation.
112///
113
114#[derive(Clone, Copy, Debug, Default)]
115pub struct Decimal {
116    mantissa: i128,
117    scale: u32,
118}
119
120impl Decimal {
121    pub const ZERO: Self = Self {
122        mantissa: 0,
123        scale: 0,
124    };
125
126    /// Returns the maximum supported decimal scale.
127    #[must_use]
128    pub const fn max_supported_scale() -> u32 {
129        MAX_SUPPORTED_SCALE
130    }
131
132    /// Construct a decimal from mantissa and scale.
133    ///
134    /// # Panics
135    ///
136    /// Panics when `scale` exceeds the supported range.
137    #[must_use]
138    pub const fn new(num: i64, scale: u32) -> Self {
139        assert!(
140            scale <= MAX_SUPPORTED_SCALE,
141            "decimal scale exceeds supported range"
142        );
143        Self::new_unchecked(num, scale)
144    }
145
146    /// Fallible constructor from mantissa and scale.
147    #[must_use]
148    pub const fn try_new(num: i64, scale: u32) -> Option<Self> {
149        if scale > MAX_SUPPORTED_SCALE {
150            return None;
151        }
152
153        Some(Self::new_unchecked(num, scale))
154    }
155
156    /// Unchecked constructor from mantissa and scale.
157    ///
158    /// This constructor may violate the decimal scale invariant and should only
159    /// be used when the caller already enforces `scale <= MAX_SUPPORTED_SCALE`.
160    ///
161    /// # Safety
162    ///
163    /// This bypasses all `Decimal` invariants, including scale bounds. It must
164    /// only be used by checked constructors, tests, or controlled internal
165    /// construction. Never call it from runtime execution paths.
166    #[must_use]
167    pub(crate) const fn new_unchecked(num: i64, scale: u32) -> Self {
168        Self {
169            mantissa: num as i128,
170            scale,
171        }
172    }
173
174    /// Fallible conversion from common numeric types.
175    ///
176    /// This path is lossy for float inputs and may lose precision for large values.
177    /// Prefer exact integer constructors (`from_i64`, `from_u64`) or explicit
178    /// float constructors (`from_f32_lossy`, `from_f64_lossy`) when possible.
179    pub fn from_num<N: NumericValue>(n: N) -> Option<Self> {
180        n.try_to_decimal()
181    }
182
183    /// Exact conversion from `i64`.
184    #[must_use]
185    pub const fn from_i64(n: i64) -> Option<Self> {
186        Some(Self {
187            mantissa: n as i128,
188            scale: 0,
189        })
190    }
191
192    /// Exact conversion from `u64`.
193    #[must_use]
194    pub const fn from_u64(n: u64) -> Option<Self> {
195        Some(Self {
196            mantissa: n as i128,
197            scale: 0,
198        })
199    }
200
201    /// Exact conversion from `i128`.
202    #[must_use]
203    pub const fn from_i128(n: i128) -> Option<Self> {
204        Some(Self {
205            mantissa: n,
206            scale: 0,
207        })
208    }
209
210    /// Exact conversion from `u128`.
211    #[must_use]
212    pub fn from_u128(n: u128) -> Option<Self> {
213        Some(Self {
214            mantissa: i128::try_from(n).ok()?,
215            scale: 0,
216        })
217    }
218
219    /// Explicit lossy conversion from `f32`.
220    ///
221    /// Uses decimal text round-tripping from the binary float representation.
222    /// This is intentionally lossy and should be used only when float input is required.
223    #[must_use]
224    pub fn from_f32_lossy(n: f32) -> Option<Self> {
225        if !n.is_finite() {
226            return None;
227        }
228
229        Self::from_str(&n.to_string()).ok()
230    }
231
232    /// Explicit lossy conversion from `f64`.
233    ///
234    /// Uses decimal text round-tripping from the binary float representation.
235    /// This is intentionally lossy and should be used only when float input is required.
236    #[must_use]
237    pub fn from_f64_lossy(n: f64) -> Option<Self> {
238        if !n.is_finite() {
239            return None;
240        }
241
242        Self::from_str(&n.to_string()).ok()
243    }
244
245    ///
246    /// PARTS
247    ///
248
249    /// Decompose into mantissa and scale.
250    #[must_use]
251    pub const fn parts(&self) -> DecimalParts {
252        DecimalParts {
253            mantissa: self.mantissa,
254            scale: self.scale,
255        }
256    }
257
258    /// Returns true if the decimal has no fractional component.
259    #[must_use]
260    pub const fn is_integer(&self) -> bool {
261        self.scale == 0
262    }
263
264    /// Scale by 10^target_scale and require an integer result.
265    ///
266    /// Returns `None` if:
267    /// - fractional precision would be lost
268    /// - integer overflow occurs
269    #[must_use]
270    pub fn scale_to_integer(&self, target_scale: u32) -> Option<i128> {
271        if self.scale > target_scale {
272            return None;
273        }
274
275        let factor = Self::checked_pow10(target_scale - self.scale)?;
276        self.mantissa.checked_mul(factor)
277    }
278
279    /// Convert to `i32` when the decimal is integral and in range.
280    #[must_use]
281    pub fn to_i32(&self) -> Option<i32> {
282        self.to_i64().and_then(|value| i32::try_from(value).ok())
283    }
284
285    /// Convert to `i64` when the decimal is integral and in range.
286    #[must_use]
287    pub fn to_i64(&self) -> Option<i64> {
288        let integer = Self::decimal_integer_value(self.mantissa, self.scale)?;
289
290        i64::try_from(integer).ok()
291    }
292
293    /// Convert to `i128` when the decimal is integral.
294    #[must_use]
295    pub fn to_i128(&self) -> Option<i128> {
296        Self::decimal_integer_value(self.mantissa, self.scale)
297    }
298
299    /// Convert to `u64` when the decimal is integral and in range.
300    #[must_use]
301    pub fn to_u64(&self) -> Option<u64> {
302        let integer = Self::decimal_integer_value(self.mantissa, self.scale)?;
303
304        u64::try_from(integer).ok()
305    }
306
307    /// Convert to `u128` when the decimal is integral and in range.
308    #[must_use]
309    pub fn to_u128(&self) -> Option<u128> {
310        let integer = Self::decimal_integer_value(self.mantissa, self.scale)?;
311
312        u128::try_from(integer).ok()
313    }
314
315    /// Convert to `f32` when the decimal is finite in `f32`.
316    #[must_use]
317    #[expect(clippy::cast_possible_truncation)]
318    pub fn to_f32(&self) -> Option<f32> {
319        self.to_f64().and_then(|value| {
320            let float = value as f32;
321            if float.is_finite() { Some(float) } else { None }
322        })
323    }
324
325    /// Convert to `f64` when the decimal is finite in `f64`.
326    #[must_use]
327    #[expect(clippy::cast_precision_loss)]
328    pub fn to_f64(&self) -> Option<f64> {
329        let divisor = 10f64.powi(i32::try_from(self.scale).ok()?);
330        let value = (self.mantissa as f64) / divisor;
331
332        if value.is_finite() { Some(value) } else { None }
333    }
334
335    /// Fallibly build from a raw mantissa and scale.
336    #[must_use]
337    pub const fn try_from_i128_with_scale(num: i128, scale: u32) -> Option<Self> {
338        Self::checked_from_mantissa_scale(num, scale)
339    }
340
341    /// Build from a raw mantissa and scale.
342    ///
343    /// # Panics
344    ///
345    /// Panics when the mantissa and scale cannot be represented without
346    /// violating the decimal scale invariant.
347    #[must_use]
348    pub const fn from_i128_with_scale(num: i128, scale: u32) -> Self {
349        Self::try_from_i128_with_scale(num, scale).expect("decimal invariant")
350    }
351
352    /// Normalize trailing zeros.
353    #[must_use]
354    pub const fn normalize(&self) -> Self {
355        let (mantissa, scale) = self.normalized_parts();
356        Self { mantissa, scale }
357    }
358
359    /// Returns `true` if the value is negative.
360    #[must_use]
361    pub const fn is_sign_negative(&self) -> bool {
362        self.mantissa < 0
363    }
364
365    /// Returns the number of fractional decimal places.
366    #[must_use]
367    pub const fn scale(&self) -> u32 {
368        self.scale
369    }
370
371    /// Returns the mantissa component.
372    #[must_use]
373    pub const fn mantissa(&self) -> i128 {
374        self.mantissa
375    }
376
377    /// Returns `true` if the value is zero.
378    #[must_use]
379    pub const fn is_zero(&self) -> bool {
380        self.mantissa == 0
381    }
382
383    const fn normalized_parts(&self) -> (i128, u32) {
384        Self::normalize_parts(self.mantissa, self.scale)
385    }
386
387    const fn checked_from_mantissa_scale(mantissa: i128, scale: u32) -> Option<Self> {
388        if scale <= MAX_SUPPORTED_SCALE {
389            return Some(Self { mantissa, scale });
390        }
391
392        let mut m = mantissa;
393        let mut s = scale;
394
395        while s > MAX_SUPPORTED_SCALE {
396            if m == 0 {
397                return Some(Self {
398                    mantissa: 0,
399                    scale: MAX_SUPPORTED_SCALE,
400                });
401            }
402
403            if m % 10 != 0 {
404                return None;
405            }
406
407            m /= 10;
408            s -= 1;
409        }
410
411        Some(Self {
412            mantissa: m,
413            scale: s,
414        })
415    }
416
417    const fn checked_pow10(power: u32) -> Option<i128> {
418        10i128.checked_pow(power)
419    }
420
421    fn decimal_integer_value(mantissa: i128, scale: u32) -> Option<i128> {
422        if scale == 0 {
423            return Some(mantissa);
424        }
425
426        let divisor = Self::checked_pow10(scale)?;
427        if mantissa % divisor != 0 {
428            return None;
429        }
430
431        Some(mantissa / divisor)
432    }
433
434    const fn normalize_parts(mantissa: i128, scale: u32) -> (i128, u32) {
435        if mantissa == 0 {
436            return (0, 0);
437        }
438
439        let mut m = mantissa;
440        let mut s = scale;
441
442        while s > 0 {
443            if m % 10 != 0 {
444                break;
445            }
446
447            m /= 10;
448            s -= 1;
449        }
450
451        (m, s)
452    }
453
454    const fn saturating_extreme(scale: u32, negative: bool) -> Self {
455        let mantissa = if negative { i128::MIN } else { i128::MAX };
456        Self { mantissa, scale }
457    }
458}
459
460impl NumericValue for Decimal {
461    fn try_to_decimal(&self) -> Option<Self> {
462        Some(*self)
463    }
464
465    fn try_from_decimal(value: Decimal) -> Option<Self> {
466        Some(value)
467    }
468}