proof_of_sql/base/math/
decimal.rs

1//! Module for parsing an `IntermediateDecimal` into a `Decimal75`.
2use crate::base::{
3    math::BigDecimalExt,
4    scalar::{Scalar, ScalarConversionError},
5};
6use alloc::string::{String, ToString};
7use bigdecimal::{BigDecimal, ParseBigDecimalError};
8use serde::{Deserialize, Deserializer, Serialize};
9use snafu::Snafu;
10
11/// Errors related to the processing of decimal values in proof-of-sql
12#[derive(Snafu, Debug, PartialEq)]
13pub enum IntermediateDecimalError {
14    /// Represents an error encountered during the parsing of a decimal string.
15    #[snafu(display("{error}"))]
16    ParseError {
17        /// The underlying error
18        error: ParseBigDecimalError,
19    },
20    /// Error occurs when this decimal cannot fit in a primitive.
21    #[snafu(display("Value out of range for target type"))]
22    OutOfRange,
23    /// Error occurs when this decimal cannot be losslessly cast into a primitive.
24    #[snafu(display("Fractional part of decimal is non-zero"))]
25    LossyCast,
26    /// Cannot cast this decimal to a big integer
27    #[snafu(display("Conversion to integer failed"))]
28    ConversionFailure,
29}
30
31impl Eq for IntermediateDecimalError {}
32
33/// Errors related to decimal operations.
34#[derive(Snafu, Debug, Eq, PartialEq)]
35pub enum DecimalError {
36    #[snafu(display("Invalid decimal format or value: {error}"))]
37    /// Error when a decimal format or value is incorrect,
38    /// the string isn't even a decimal e.g. "notastring",
39    /// "-21.233.122" etc aka `InvalidDecimal`
40    InvalidDecimal {
41        /// The underlying error
42        error: String,
43    },
44
45    #[snafu(display("Decimal precision is not valid: {error}"))]
46    /// Decimal precision exceeds the allowed limit,
47    /// e.g. precision above 75/76/whatever set by Scalar
48    /// or non-positive aka `InvalidPrecision`
49    InvalidPrecision {
50        /// The underlying error
51        error: String,
52    },
53
54    #[snafu(display("Decimal scale is not valid: {scale}"))]
55    /// Decimal scale is not valid. Here we use i16 in order to include
56    /// invalid scale values
57    InvalidScale {
58        /// The invalid scale value
59        scale: String,
60    },
61
62    #[snafu(display("Unsupported operation: cannot round decimal: {error}"))]
63    /// This error occurs when attempting to scale a
64    /// decimal in such a way that a loss of precision occurs.
65    RoundingError {
66        /// The underlying error
67        error: String,
68    },
69
70    /// Errors that may occur when parsing an intermediate decimal
71    /// into a posql decimal
72    #[snafu(transparent)]
73    IntermediateDecimalConversionError {
74        /// The underlying source error
75        source: IntermediateDecimalError,
76    },
77}
78
79/// Result type for decimal operations.
80pub type DecimalResult<T> = Result<T, DecimalError>;
81
82// This exists because `TryFrom<arrow::datatypes::DataType>` for `ColumnType` error is String
83impl From<DecimalError> for String {
84    fn from(error: DecimalError) -> Self {
85        error.to_string()
86    }
87}
88
89#[derive(Eq, PartialEq, Debug, Clone, Hash, Serialize, Copy)]
90/// limit-enforced precision
91#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
92pub struct Precision(#[cfg_attr(test, proptest(strategy = "1..76u8"))] u8);
93pub(crate) const MAX_SUPPORTED_PRECISION: u8 = 75;
94
95impl Precision {
96    /// Constructor for creating a Precision instance
97    pub fn new(value: u8) -> Result<Self, DecimalError> {
98        if value > MAX_SUPPORTED_PRECISION || value == 0 {
99            Err(DecimalError::InvalidPrecision {
100                error: value.to_string(),
101            })
102        } else {
103            Ok(Precision(value))
104        }
105    }
106
107    /// Gets the precision as a u8 for this decimal
108    #[must_use]
109    pub fn value(&self) -> u8 {
110        self.0
111    }
112}
113
114impl TryFrom<u64> for Precision {
115    type Error = DecimalError;
116    fn try_from(value: u64) -> Result<Self, Self::Error> {
117        Precision::new(
118            value
119                .try_into()
120                .map_err(|_| DecimalError::InvalidPrecision {
121                    error: value.to_string(),
122                })?,
123        )
124    }
125}
126
127// Custom deserializer for precision since we need to limit its value to 75
128impl<'de> Deserialize<'de> for Precision {
129    fn deserialize<D>(deserializer: D) -> Result<Precision, D::Error>
130    where
131        D: Deserializer<'de>,
132    {
133        // Deserialize as a u8
134        let value = u8::deserialize(deserializer)?;
135
136        // Use the Precision::new method to ensure the value is within the allowed range
137        Precision::new(value).map_err(serde::de::Error::custom)
138    }
139}
140
141/// Fallibly attempts to convert an `IntermediateDecimal` into the
142/// native proof-of-sql [Scalar] backing store. This function adjusts
143/// the decimal to the specified `target_precision` and `target_scale`,
144/// and validates that the adjusted decimal does not exceed the specified precision.
145/// If the conversion is successful, it returns the `Scalar` representation;
146/// otherwise, it returns a `DecimalError` indicating the type of failure
147/// (e.g., exceeding precision limits).
148///
149/// ## Arguments
150/// * `d` - The `IntermediateDecimal` to convert.
151/// * `target_precision` - The maximum number of digits the scalar can represent.
152/// * `target_scale` - The scale (number of decimal places) to use in the scalar.
153///
154/// ## Errors
155/// Returns `DecimalError::InvalidPrecision` error if the number of digits in
156/// the decimal exceeds the `target_precision` before or after adjusting for
157/// `target_scale`, or if the target precision is zero.
158pub(crate) fn try_convert_intermediate_decimal_to_scalar<S: Scalar>(
159    d: &BigDecimal,
160    target_precision: Precision,
161    target_scale: i8,
162) -> DecimalResult<S> {
163    d.try_into_bigint_with_precision_and_scale(target_precision.value(), target_scale)?
164        .try_into()
165        .map_err(|e: ScalarConversionError| DecimalError::InvalidDecimal {
166            error: e.to_string(),
167        })
168}
169
170#[cfg(test)]
171mod scale_adjust_test {
172
173    use super::*;
174    use crate::base::scalar::test_scalar::TestScalar;
175    use num_bigint::BigInt;
176
177    #[test]
178    fn we_cannot_scale_past_max_precision() {
179        let decimal = "12345678901234567890123456789012345678901234567890123456789012345678900.0"
180            .parse()
181            .unwrap();
182
183        let target_scale = 5;
184
185        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
186            &decimal,
187            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX)).unwrap(),
188            target_scale
189        )
190        .is_err());
191    }
192
193    #[test]
194    fn we_can_match_exact_decimals_from_queries_to_db() {
195        let decimal: BigDecimal = "123.45".parse().unwrap();
196        let target_scale = 2;
197        let target_precision = 20;
198        let big_int =
199            decimal.try_into_bigint_with_precision_and_scale(target_precision, target_scale);
200        let expected_big_int: BigInt = "12345".parse().unwrap();
201        assert_eq!(big_int, Ok(expected_big_int));
202    }
203
204    #[test]
205    fn we_can_match_decimals_with_negative_scale() {
206        let decimal = "120.00".parse().unwrap();
207        let target_scale = -1;
208        let expected = [12, 0, 0, 0];
209        let result = try_convert_intermediate_decimal_to_scalar::<TestScalar>(
210            &decimal,
211            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
212            target_scale,
213        )
214        .unwrap();
215        assert_eq!(result, TestScalar::from(expected));
216    }
217
218    #[test]
219    fn we_can_match_integers_with_negative_scale() {
220        let decimal = "12300".parse().unwrap();
221        let target_scale = -2;
222        let expected_limbs = [123, 0, 0, 0];
223
224        let limbs = try_convert_intermediate_decimal_to_scalar::<TestScalar>(
225            &decimal,
226            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX)).unwrap(),
227            target_scale,
228        )
229        .unwrap();
230
231        assert_eq!(limbs, TestScalar::from(expected_limbs));
232    }
233
234    #[test]
235    fn we_can_match_negative_decimals() {
236        let decimal = "-123.45".parse().unwrap();
237        let target_scale = 2;
238        let expected_limbs = [12345, 0, 0, 0];
239        let limbs = try_convert_intermediate_decimal_to_scalar::<TestScalar>(
240            &decimal,
241            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX)).unwrap(),
242            target_scale,
243        )
244        .unwrap();
245        assert_eq!(limbs, -TestScalar::from(expected_limbs));
246    }
247
248    #[expect(clippy::cast_possible_wrap)]
249    #[test]
250    fn we_can_match_decimals_at_extrema() {
251        // a big decimal cannot scale up past the supported precision
252        let decimal = "1234567890123456789012345678901234567890123456789012345678901234567890.0"
253            .parse()
254            .unwrap();
255        let target_scale = 6; // now precision exceeds maximum
256        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
257            &decimal,
258            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX),).unwrap(),
259            target_scale
260        )
261        .is_err());
262
263        // maximum decimal value we can support
264        let decimal =
265            "99999999999999999999999999999999999999999999999999999999999999999999999999.0"
266                .parse()
267                .unwrap();
268        let target_scale = 1;
269        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
270            &decimal,
271            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
272            target_scale
273        )
274        .is_ok());
275
276        // scaling larger than max will fail
277        let decimal =
278            "999999999999999999999999999999999999999999999999999999999999999999999999999.0"
279                .parse()
280                .unwrap();
281        let target_scale = 1;
282        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
283            &decimal,
284            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
285            target_scale
286        )
287        .is_err());
288
289        // smallest possible decimal value we can support (either signed/unsigned)
290        let decimal =
291            "0.000000000000000000000000000000000000000000000000000000000000000000000000001"
292                .parse()
293                .unwrap();
294        let target_scale = MAX_SUPPORTED_PRECISION as i8;
295        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
296            &decimal,
297            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX),).unwrap(),
298            target_scale
299        )
300        .is_ok());
301
302        // this is ok because it can be scaled to 75 precision
303        let decimal = "0.1".parse().unwrap();
304        let target_scale = MAX_SUPPORTED_PRECISION as i8;
305        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
306            &decimal,
307            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
308            target_scale
309        )
310        .is_ok());
311
312        // this exceeds max precision
313        let decimal = "1.0".parse().unwrap();
314        let target_scale = 75;
315        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
316            &decimal,
317            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX),).unwrap(),
318            target_scale
319        )
320        .is_err());
321
322        // but this is ok
323        let decimal = "1.0".parse().unwrap();
324        let target_scale = 74;
325        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
326            &decimal,
327            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
328            target_scale
329        )
330        .is_ok());
331    }
332}