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
91pub struct Precision(u8);
92pub(crate) const MAX_SUPPORTED_PRECISION: u8 = 75;
93
94impl Precision {
95    /// Constructor for creating a Precision instance
96    pub fn new(value: u8) -> Result<Self, DecimalError> {
97        if value > MAX_SUPPORTED_PRECISION || value == 0 {
98            Err(DecimalError::InvalidPrecision {
99                error: value.to_string(),
100            })
101        } else {
102            Ok(Precision(value))
103        }
104    }
105
106    /// Gets the precision as a u8 for this decimal
107    #[must_use]
108    pub fn value(&self) -> u8 {
109        self.0
110    }
111}
112
113impl TryFrom<u64> for Precision {
114    type Error = DecimalError;
115    fn try_from(value: u64) -> Result<Self, Self::Error> {
116        Precision::new(
117            value
118                .try_into()
119                .map_err(|_| DecimalError::InvalidPrecision {
120                    error: value.to_string(),
121                })?,
122        )
123    }
124}
125
126// Custom deserializer for precision since we need to limit its value to 75
127impl<'de> Deserialize<'de> for Precision {
128    fn deserialize<D>(deserializer: D) -> Result<Precision, D::Error>
129    where
130        D: Deserializer<'de>,
131    {
132        // Deserialize as a u8
133        let value = u8::deserialize(deserializer)?;
134
135        // Use the Precision::new method to ensure the value is within the allowed range
136        Precision::new(value).map_err(serde::de::Error::custom)
137    }
138}
139
140/// Fallibly attempts to convert an `IntermediateDecimal` into the
141/// native proof-of-sql [Scalar] backing store. This function adjusts
142/// the decimal to the specified `target_precision` and `target_scale`,
143/// and validates that the adjusted decimal does not exceed the specified precision.
144/// If the conversion is successful, it returns the `Scalar` representation;
145/// otherwise, it returns a `DecimalError` indicating the type of failure
146/// (e.g., exceeding precision limits).
147///
148/// ## Arguments
149/// * `d` - The `IntermediateDecimal` to convert.
150/// * `target_precision` - The maximum number of digits the scalar can represent.
151/// * `target_scale` - The scale (number of decimal places) to use in the scalar.
152///
153/// ## Errors
154/// Returns `DecimalError::InvalidPrecision` error if the number of digits in
155/// the decimal exceeds the `target_precision` before or after adjusting for
156/// `target_scale`, or if the target precision is zero.
157pub(crate) fn try_convert_intermediate_decimal_to_scalar<S: Scalar>(
158    d: &BigDecimal,
159    target_precision: Precision,
160    target_scale: i8,
161) -> DecimalResult<S> {
162    d.try_into_bigint_with_precision_and_scale(target_precision.value(), target_scale)?
163        .try_into()
164        .map_err(|e: ScalarConversionError| DecimalError::InvalidDecimal {
165            error: e.to_string(),
166        })
167}
168
169#[cfg(test)]
170mod scale_adjust_test {
171
172    use super::*;
173    use crate::base::scalar::test_scalar::TestScalar;
174    use num_bigint::BigInt;
175
176    #[test]
177    fn we_cannot_scale_past_max_precision() {
178        let decimal = "12345678901234567890123456789012345678901234567890123456789012345678900.0"
179            .parse()
180            .unwrap();
181
182        let target_scale = 5;
183
184        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
185            &decimal,
186            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX)).unwrap(),
187            target_scale
188        )
189        .is_err());
190    }
191
192    #[test]
193    fn we_can_match_exact_decimals_from_queries_to_db() {
194        let decimal: BigDecimal = "123.45".parse().unwrap();
195        let target_scale = 2;
196        let target_precision = 20;
197        let big_int =
198            decimal.try_into_bigint_with_precision_and_scale(target_precision, target_scale);
199        let expected_big_int: BigInt = "12345".parse().unwrap();
200        assert_eq!(big_int, Ok(expected_big_int));
201    }
202
203    #[test]
204    fn we_can_match_decimals_with_negative_scale() {
205        let decimal = "120.00".parse().unwrap();
206        let target_scale = -1;
207        let expected = [12, 0, 0, 0];
208        let result = try_convert_intermediate_decimal_to_scalar::<TestScalar>(
209            &decimal,
210            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
211            target_scale,
212        )
213        .unwrap();
214        assert_eq!(result, TestScalar::from(expected));
215    }
216
217    #[test]
218    fn we_can_match_integers_with_negative_scale() {
219        let decimal = "12300".parse().unwrap();
220        let target_scale = -2;
221        let expected_limbs = [123, 0, 0, 0];
222
223        let limbs = try_convert_intermediate_decimal_to_scalar::<TestScalar>(
224            &decimal,
225            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX)).unwrap(),
226            target_scale,
227        )
228        .unwrap();
229
230        assert_eq!(limbs, TestScalar::from(expected_limbs));
231    }
232
233    #[test]
234    fn we_can_match_negative_decimals() {
235        let decimal = "-123.45".parse().unwrap();
236        let target_scale = 2;
237        let expected_limbs = [12345, 0, 0, 0];
238        let limbs = try_convert_intermediate_decimal_to_scalar::<TestScalar>(
239            &decimal,
240            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX)).unwrap(),
241            target_scale,
242        )
243        .unwrap();
244        assert_eq!(limbs, -TestScalar::from(expected_limbs));
245    }
246
247    #[allow(clippy::cast_possible_wrap)]
248    #[test]
249    fn we_can_match_decimals_at_extrema() {
250        // a big decimal cannot scale up past the supported precision
251        let decimal = "1234567890123456789012345678901234567890123456789012345678901234567890.0"
252            .parse()
253            .unwrap();
254        let target_scale = 6; // now precision exceeds maximum
255        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
256            &decimal,
257            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX),).unwrap(),
258            target_scale
259        )
260        .is_err());
261
262        // maximum decimal value we can support
263        let decimal =
264            "99999999999999999999999999999999999999999999999999999999999999999999999999.0"
265                .parse()
266                .unwrap();
267        let target_scale = 1;
268        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
269            &decimal,
270            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
271            target_scale
272        )
273        .is_ok());
274
275        // scaling larger than max will fail
276        let decimal =
277            "999999999999999999999999999999999999999999999999999999999999999999999999999.0"
278                .parse()
279                .unwrap();
280        let target_scale = 1;
281        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
282            &decimal,
283            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
284            target_scale
285        )
286        .is_err());
287
288        // smallest possible decimal value we can support (either signed/unsigned)
289        let decimal =
290            "0.000000000000000000000000000000000000000000000000000000000000000000000000001"
291                .parse()
292                .unwrap();
293        let target_scale = MAX_SUPPORTED_PRECISION as i8;
294        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
295            &decimal,
296            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX),).unwrap(),
297            target_scale
298        )
299        .is_ok());
300
301        // this is ok because it can be scaled to 75 precision
302        let decimal = "0.1".parse().unwrap();
303        let target_scale = MAX_SUPPORTED_PRECISION as i8;
304        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
305            &decimal,
306            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
307            target_scale
308        )
309        .is_ok());
310
311        // this exceeds max precision
312        let decimal = "1.0".parse().unwrap();
313        let target_scale = 75;
314        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
315            &decimal,
316            Precision::new(u8::try_from(decimal.precision()).unwrap_or(u8::MAX),).unwrap(),
317            target_scale
318        )
319        .is_err());
320
321        // but this is ok
322        let decimal = "1.0".parse().unwrap();
323        let target_scale = 74;
324        assert!(try_convert_intermediate_decimal_to_scalar::<TestScalar>(
325            &decimal,
326            Precision::new(MAX_SUPPORTED_PRECISION).unwrap(),
327            target_scale
328        )
329        .is_ok());
330    }
331}