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: u8)`.
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::Value 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: u8) -> 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 { scaled, scale } => Some(numeric_to_text(*scaled, *scale)),
70        _ => None,
71    }
72}
73
74// ---- bigdecimal::BigDecimal bridge ---------------------------
75
76#[cfg(feature = "bigdecimal")]
77mod bd {
78    use super::*;
79    use bigdecimal::BigDecimal;
80    use num_bigint::{BigInt, Sign};
81    use num_traits::ToPrimitive;
82    use sqlx_core::encode::{Encode, IsNull};
83
84    use crate::arguments::SpgArgumentValue;
85
86    impl Type<Spg> for BigDecimal {
87        fn type_info() -> SpgTypeInfo {
88            SpgTypeInfo::of(Kind::Numeric)
89        }
90
91        fn compatible(ty: &SpgTypeInfo) -> bool {
92            // Mirrors sqlx-postgres's BigDecimal::compatible:
93            // integer columns are valid NUMERIC inputs on the
94            // wire, so the bridge accepts them too.
95            matches!(
96                ty.kind(),
97                Kind::Numeric | Kind::Int | Kind::BigInt | Kind::SmallInt
98            )
99        }
100    }
101
102    impl<'q> Encode<'q, Spg> for BigDecimal {
103        fn encode_by_ref(
104            &self,
105            buf: &mut Vec<SpgArgumentValue<'q>>,
106        ) -> Result<IsNull, BoxDynError> {
107            let (scaled, scale) = bigdecimal_to_scaled(self)?;
108            buf.push(SpgArgumentValue {
109                value: EngineValue::Numeric { scaled, scale },
110                type_info: Some(SpgTypeInfo::of(Kind::Numeric)),
111                _phantom: core::marker::PhantomData,
112            });
113            Ok(IsNull::No)
114        }
115    }
116
117    impl<'r> Decode<'r, Spg> for BigDecimal {
118        fn decode(value: SpgValueRef<'r>) -> Result<Self, BoxDynError> {
119            match value.engine() {
120                EngineValue::Numeric { scaled, scale } => Ok(scaled_to_bigdecimal(*scaled, *scale)),
121                // Generous coerce: small ints are valid NUMERICs
122                // too. Mirrors what PG would do on the wire when
123                // a column is widened.
124                EngineValue::Int(n) => Ok(BigDecimal::from(*n)),
125                EngineValue::BigInt(n) => Ok(BigDecimal::from(*n)),
126                EngineValue::SmallInt(n) => Ok(BigDecimal::from(i32::from(*n))),
127                other => Err(format!("cannot decode {other:?} as BigDecimal / NUMERIC").into()),
128            }
129        }
130    }
131
132    /// Reshape a BigDecimal into SPG's `(i128 scaled, u8 scale)`
133    /// pair. Errors out if the mantissa overflows i128 (precision
134    /// `> 38`) or the exponent is outside `0..=u8::MAX` — both
135    /// states fall outside what SPG NUMERIC can represent.
136    fn bigdecimal_to_scaled(d: &BigDecimal) -> Result<(i128, u8), BoxDynError> {
137        // BigDecimal::as_bigint_and_exponent gives (mantissa,
138        // exponent) where the value = mantissa * 10^(-exponent).
139        // Non-negative exponent → `scale = exponent`; negative
140        // exponent → fold the magnitude into the mantissa, with
141        // `scale = 0`.
142        let (mantissa, exp) = d.as_bigint_and_exponent();
143        let (scaled, scale) = if exp < 0 {
144            let factor = BigInt::from(10u8).pow((-exp) as u32);
145            let folded = mantissa * factor;
146            (folded, 0u8)
147        } else if exp > i64::from(u8::MAX) {
148            return Err(format!(
149                "BigDecimal scale {exp} exceeds SPG NUMERIC ceiling ({})",
150                u8::MAX
151            )
152            .into());
153        } else {
154            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
155            let s = exp as u8;
156            (mantissa, s)
157        };
158        let scaled_i128 = scaled.to_i128().ok_or_else(|| {
159            format!(
160                "BigDecimal mantissa {scaled} overflows i128 — SPG NUMERIC tops out at precision 38"
161            )
162        })?;
163        Ok((scaled_i128, scale))
164    }
165
166    fn scaled_to_bigdecimal(scaled: i128, scale: u8) -> BigDecimal {
167        let sign = if scaled < 0 { Sign::Minus } else { Sign::Plus };
168        let magnitude: u128 = scaled.unsigned_abs();
169        let mantissa = BigInt::from_biguint(sign, num_bigint::BigUint::from(magnitude));
170        BigDecimal::new(mantissa, i64::from(scale))
171    }
172}