Skip to main content

spg_sqlx/types/
decimal.rs

1//! v7.17.0 Phase 3.P0-67 — `Type` / `Encode` / `Decode` for the
2//! NUMERIC family.
3//!
4//! Engine-side, NUMERIC carries `(scaled: i128, scale: u16)`.
5//! `(scaled, scale)` ↔ BigDecimal mantissa/exponent is a
6//! straight reshape — both encode "digits × 10^-scale". The
7//! `i128` ceiling caps SPG NUMERIC at precision 38; values
8//! beyond that range surface as Encode errors instead of
9//! silently truncating.
10//!
11//! The text path (`Decode<String>` for NUMERIC) lives in
12//! `types/text.rs` and uses [`numeric_to_text`] below — every
13//! sqlx user gets it for free.
14
15use spg_embedded::ValueOwned as EngineValue;
16
17#[cfg(feature = "bigdecimal")]
18use sqlx_core::decode::Decode;
19#[cfg(feature = "bigdecimal")]
20use sqlx_core::error::BoxDynError;
21#[cfg(feature = "bigdecimal")]
22use sqlx_core::types::Type;
23
24#[cfg(feature = "bigdecimal")]
25use crate::database::Spg;
26#[cfg(feature = "bigdecimal")]
27use crate::type_info::{Kind, SpgTypeInfo};
28#[cfg(feature = "bigdecimal")]
29use crate::value::SpgValueRef;
30
31/// Render a NUMERIC cell into PG's canonical decimal text
32/// (`"123.45"` / `"-0.001"` / `"42"`). Mirrors what
33/// `spg_engine::eval::format_numeric` produces — kept in lock-
34/// step so the sqlx adapter and pgwire show identical strings
35/// for the same cell. Inlined here so spg-sqlx doesn't grow a
36/// direct dep on spg-engine.
37pub(crate) fn numeric_to_text(scaled: i128, scale: u16) -> String {
38    if scale == 0 {
39        return format!("{scaled}");
40    }
41    let negative = scaled < 0;
42    let mag_str = scaled.unsigned_abs().to_string();
43    let mag_bytes = mag_str.as_bytes();
44    let scale_u = scale as usize;
45    let mut out = String::with_capacity(mag_str.len() + 3);
46    if negative {
47        out.push('-');
48    }
49    if mag_bytes.len() <= scale_u {
50        out.push('0');
51        out.push('.');
52        for _ in mag_bytes.len()..scale_u {
53            out.push('0');
54        }
55        out.push_str(&mag_str);
56    } else {
57        let split = mag_bytes.len() - scale_u;
58        out.push_str(&mag_str[..split]);
59        out.push('.');
60        out.push_str(&mag_str[split..]);
61    }
62    out
63}
64
65/// Internal — `Decode<String>` for NUMERIC cells in
66/// `types/text.rs` falls through to this helper.
67pub(crate) fn try_numeric_as_string(value: &EngineValue) -> Option<String> {
68    match value {
69        EngineValue::Numeric {
70            scaled,
71            scale,
72            kind: spg_storage::NumericKind::Finite,
73        } => Some(numeric_to_text(*scaled, *scale)),
74        _ => None,
75    }
76}
77
78// ---- bigdecimal::BigDecimal bridge ---------------------------
79
80#[cfg(feature = "bigdecimal")]
81mod bd {
82    use super::*;
83    use bigdecimal::BigDecimal;
84    use num_bigint::{BigInt, Sign};
85    use num_traits::ToPrimitive;
86    use sqlx_core::encode::{Encode, IsNull};
87
88    use crate::arguments::SpgArgumentValue;
89
90    impl Type<Spg> for BigDecimal {
91        fn type_info() -> SpgTypeInfo {
92            SpgTypeInfo::of(Kind::Numeric)
93        }
94
95        fn compatible(ty: &SpgTypeInfo) -> bool {
96            // Mirrors sqlx-postgres's BigDecimal::compatible:
97            // integer columns are valid NUMERIC inputs on the
98            // wire, so the bridge accepts them too.
99            matches!(
100                ty.kind(),
101                Kind::Numeric | Kind::Int | Kind::BigInt | Kind::SmallInt
102            )
103        }
104    }
105
106    impl<'q> Encode<'q, Spg> for BigDecimal {
107        fn encode_by_ref(
108            &self,
109            buf: &mut Vec<SpgArgumentValue<'q>>,
110        ) -> Result<IsNull, BoxDynError> {
111            let (scaled, scale) = bigdecimal_to_scaled(self)?;
112            buf.push(SpgArgumentValue {
113                value: EngineValue::Numeric {
114                    scaled,
115                    scale,
116                    kind: spg_storage::NumericKind::Finite,
117                },
118                type_info: Some(SpgTypeInfo::of(Kind::Numeric)),
119                _phantom: core::marker::PhantomData,
120            });
121            Ok(IsNull::No)
122        }
123    }
124
125    impl<'r> Decode<'r, Spg> for BigDecimal {
126        fn decode(value: SpgValueRef<'r>) -> Result<Self, BoxDynError> {
127            match value.engine() {
128                EngineValue::Numeric {
129                    scaled,
130                    scale,
131                    kind: spg_storage::NumericKind::Finite,
132                } => Ok(scaled_to_bigdecimal(*scaled, *scale)),
133                // Generous coerce: small ints are valid NUMERICs
134                // too. Mirrors what PG would do on the wire when
135                // a column is widened.
136                EngineValue::Int(n) => Ok(BigDecimal::from(*n)),
137                EngineValue::BigInt(n) => Ok(BigDecimal::from(*n)),
138                EngineValue::SmallInt(n) => Ok(BigDecimal::from(i32::from(*n))),
139                other => Err(format!("cannot decode {other:?} as BigDecimal / NUMERIC").into()),
140            }
141        }
142    }
143
144    /// Reshape a BigDecimal into SPG's `(i128 scaled, u8 scale)`
145    /// pair. Errors out if the mantissa overflows i128 (precision
146    /// `> 38`) or the exponent is outside `0..=u8::MAX` — both
147    /// states fall outside what SPG NUMERIC can represent.
148    fn bigdecimal_to_scaled(d: &BigDecimal) -> Result<(i128, u16), BoxDynError> {
149        // BigDecimal::as_bigint_and_exponent gives (mantissa,
150        // exponent) where the value = mantissa * 10^(-exponent).
151        // Non-negative exponent → `scale = exponent`; negative
152        // exponent → fold the magnitude into the mantissa, with
153        // `scale = 0`.
154        let (mantissa, exp) = d.as_bigint_and_exponent();
155        let (scaled, scale) = if exp < 0 {
156            let factor = BigInt::from(10u8).pow((-exp) as u32);
157            let folded = mantissa * factor;
158            (folded, 0u16)
159        } else if exp > i64::from(u16::MAX) {
160            return Err(format!(
161                "BigDecimal scale {exp} exceeds SPG NUMERIC ceiling ({})",
162                u16::MAX
163            )
164            .into());
165        } else {
166            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
167            let s = exp as u16;
168            (mantissa, s)
169        };
170        let scaled_i128 = scaled.to_i128().ok_or_else(|| {
171            format!(
172                "BigDecimal mantissa {scaled} overflows i128 — SPG NUMERIC tops out at precision 38"
173            )
174        })?;
175        Ok((scaled_i128, scale))
176    }
177
178    fn scaled_to_bigdecimal(scaled: i128, scale: u16) -> BigDecimal {
179        let sign = if scaled < 0 { Sign::Minus } else { Sign::Plus };
180        let magnitude: u128 = scaled.unsigned_abs();
181        let mantissa = BigInt::from_biguint(sign, num_bigint::BigUint::from(magnitude));
182        BigDecimal::new(mantissa, i64::from(scale))
183    }
184}