Skip to content

Commit 791a7f5

Browse files
committed
doc(pg): document behavior of bigdecimal and rust_decimal with out-of-range values
also add a regression test
1 parent e5c18b3 commit 791a7f5

File tree

6 files changed

+99
-15
lines changed

6 files changed

+99
-15
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#### Note: `BigDecimal` Has a Larger Range than `NUMERIC`
2+
`BigDecimal` can represent values with a far, far greater range than the `NUMERIC` type in Postgres can.
3+
4+
`NUMERIC` is limited to 131,072 digits before the decimal point, and 16,384 digits after it.
5+
See [Section 8.1, Numeric Types] of the Postgres manual for details.
6+
7+
Meanwhile, `BigDecimal` can theoretically represent a value with an arbitrary number of decimal digits, albeit
8+
with a maximum of 2<sup>63</sup> significant figures.
9+
10+
Because encoding in the current API design _must_ be infallible,
11+
when attempting to encode a `BigDecimal` that cannot fit in the wire representation of `NUMERIC`,
12+
SQLx may instead encode a sentinel value that falls outside the allowed range but is still representable.
13+
14+
This will cause the query to return a `DatabaseError` with code `22P03` (`invalid_binary_representation`)
15+
and the error message `invalid scale in external "numeric" value` (though this may be subject to change).
16+
17+
However, `BigDecimal` should be able to decode any `NUMERIC` value except `NaN`,
18+
for which it has no representation.
19+
20+
[Section 8.1, Numeric Types]: https://www.postgresql.org/docs/current/datatype-numeric.html

sqlx-postgres/src/types/bigdecimal.rs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,28 +127,30 @@ impl TryFrom<&'_ BigDecimal> for PgNumeric {
127127
}
128128

129129
Ok(PgNumeric::Number {
130-
sign: match sign {
131-
Sign::Plus | Sign::NoSign => PgNumericSign::Positive,
132-
Sign::Minus => PgNumericSign::Negative,
133-
},
130+
sign: sign_to_pg(sign),
134131
scale,
135132
weight,
136133
digits,
137134
})
138135
}
139136
}
140137

138+
#[doc=include_str!("bigdecimal-range.md")]
141139
impl Encode<'_, Postgres> for BigDecimal {
142140
fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> IsNull {
143-
use std::str::FromStr;
144141
// If the argument is too big, then we replace it with a less big argument.
145142
// This less big argument is already outside the range of allowed PostgreSQL DECIMAL, which
146143
// means that PostgreSQL will return the 22P03 error kind upon receiving it. This is the
147144
// expected error, and the user should be ready to handle it anyway.
148145
PgNumeric::try_from(self)
149146
.unwrap_or_else(|_| {
150-
PgNumeric::try_from(&BigDecimal::from_str(&format!("{:030000}", 0)).unwrap())
151-
.unwrap()
147+
PgNumeric::Number {
148+
digits: vec![1],
149+
// This is larger than the maximum allowed value, so Postgres should return an error.
150+
scale: 0x4000,
151+
weight: 0,
152+
sign: sign_to_pg(self.sign()),
153+
}
152154
})
153155
.encode(buf);
154156

@@ -162,6 +164,9 @@ impl Encode<'_, Postgres> for BigDecimal {
162164
}
163165
}
164166

167+
/// ### Note: `NaN`
168+
/// `BigDecimal` has a greater range than `NUMERIC` (see the corresponding `Encode` impl for details)
169+
/// but cannot represent `NaN`, so decoding may return an error.
165170
impl Decode<'_, Postgres> for BigDecimal {
166171
fn decode(value: PgValueRef<'_>) -> Result<Self, BoxDynError> {
167172
match value.format() {
@@ -171,6 +176,13 @@ impl Decode<'_, Postgres> for BigDecimal {
171176
}
172177
}
173178

179+
fn sign_to_pg(sign: Sign) -> PgNumericSign {
180+
match sign {
181+
Sign::Plus | Sign::NoSign => PgNumericSign::Positive,
182+
Sign::Minus => PgNumericSign::Negative,
183+
}
184+
}
185+
174186
#[cfg(test)]
175187
mod bigdecimal_to_pgnumeric {
176188
use super::{BigDecimal, PgNumeric, PgNumericSign};

sqlx-postgres/src/types/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,17 @@
3232
//! |---------------------------------------|------------------------------------------------------|
3333
//! | `bigdecimal::BigDecimal` | NUMERIC |
3434
//!
35+
#![doc=include_str!("bigdecimal-range.md")]
36+
//!
3537
//! ### [`rust_decimal`](https://crates.io/crates/rust_decimal)
3638
//! Requires the `rust_decimal` Cargo feature flag.
3739
//!
3840
//! | Rust type | Postgres type(s) |
3941
//! |---------------------------------------|------------------------------------------------------|
4042
//! | `rust_decimal::Decimal` | NUMERIC |
4143
//!
44+
#![doc=include_str!("rust_decimal-range.md")]
45+
//!
4246
//! ### [`chrono`](https://crates.io/crates/chrono)
4347
//!
4448
//! Requires the `chrono` Cargo feature flag.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#### Note: `rust_decimal::Decimal` Has a Smaller Range than `NUMERIC`
2+
`NUMERIC` is can have up to 131,072 digits before the decimal point, and 16,384 digits after it.
3+
See [Section 8.1, Numeric Types] of the Postgres manual for details.
4+
5+
However, `rust_decimal::Decimal` is limited to a maximum absolute magnitude of 2<sup>96</sup> - 1,
6+
a number with 67 decimal digits, and a minimum absolute magnitude of 10<sup>-28</sup>, a number with, unsurprisingly,
7+
28 decimal digits.
8+
9+
Thus, in contrast with `BigDecimal`, `NUMERIC` can actually represent every possible value of `rust_decimal::Decimal`,
10+
but not the other way around. This means that encoding should never fail, but decoding can.

sqlx-postgres/src/types/rust_decimal.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ impl TryFrom<PgNumeric> for Decimal {
7171
}
7272
}
7373

74+
// This impl is effectively infallible because `NUMERIC` has a greater range than `Decimal`.
7475
impl TryFrom<&'_ Decimal> for PgNumeric {
7576
type Error = BoxDynError;
7677

@@ -142,18 +143,17 @@ impl TryFrom<&'_ Decimal> for PgNumeric {
142143
}
143144
}
144145

145-
/// ### Panics
146-
/// If this `Decimal` cannot be represented by `PgNumeric`.
147146
impl Encode<'_, Postgres> for Decimal {
148147
fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> IsNull {
149148
PgNumeric::try_from(self)
150-
.expect("Decimal magnitude too great for Postgres NUMERIC type")
149+
.expect("BUG: `Decimal` to `PgNumeric` conversion should be infallible")
151150
.encode(buf);
152151

153152
IsNull::No
154153
}
155154
}
156155

156+
#[doc=include_str!("rust_decimal-range.md")]
157157
impl Decode<'_, Postgres> for Decimal {
158158
fn decode(value: PgValueRef<'_>) -> Result<Self, BoxDynError> {
159159
match value.format() {

tests/postgres/postgres.rs

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -933,11 +933,7 @@ from (values (null)) vals(val)
933933

934934
#[sqlx_macros::test]
935935
async fn test_listener_cleanup() -> anyhow::Result<()> {
936-
#[cfg(feature = "_rt-tokio")]
937-
use tokio::time::timeout;
938-
939-
#[cfg(feature = "_rt-async-std")]
940-
use async_std::future::timeout;
936+
use sqlx_core::rt::timeout;
941937

942938
use sqlx::pool::PoolOptions;
943939
use sqlx::postgres::PgListener;
@@ -1838,3 +1834,45 @@ async fn test_error_handling_with_deferred_constraints() -> anyhow::Result<()> {
18381834

18391835
Ok(())
18401836
}
1837+
1838+
#[sqlx_macros::test]
1839+
#[cfg(feature = "bigdecimal")]
1840+
async fn test_issue_3052() {
1841+
use sqlx::types::BigDecimal;
1842+
1843+
// https://github.com/launchbadge/sqlx/issues/3052
1844+
// Previously, attempting to bind a `BigDecimal` would panic if the value was out of range.
1845+
// Now, we rewrite it to a sentinel value so that Postgres will return a range error.
1846+
let too_small: BigDecimal = "1E-65536".parse().unwrap();
1847+
let too_large: BigDecimal = "1E262144".parse().unwrap();
1848+
1849+
let mut conn = new::<Postgres>().await.unwrap();
1850+
1851+
let too_small_res = sqlx::query_scalar::<_, BigDecimal>("SELECT $1::numeric")
1852+
.bind(&too_small)
1853+
.fetch_one(&mut conn)
1854+
.await;
1855+
1856+
match too_small_res {
1857+
Err(sqlx::Error::Database(dbe)) => {
1858+
let dbe = dbe.downcast::<PgDatabaseError>();
1859+
1860+
assert_eq!(dbe.code(), "22P03");
1861+
}
1862+
other => panic!("expected Err(DatabaseError), got {other:?}"),
1863+
}
1864+
1865+
let too_large_res = sqlx::query_scalar::<_, BigDecimal>("SELECT $1::numeric")
1866+
.bind(&too_large)
1867+
.fetch_one(&mut conn)
1868+
.await;
1869+
1870+
match too_large_res {
1871+
Err(sqlx::Error::Database(dbe)) => {
1872+
let dbe = dbe.downcast::<PgDatabaseError>();
1873+
1874+
assert_eq!(dbe.code(), "22P03");
1875+
}
1876+
other => panic!("expected Err(DatabaseError), got {other:?}"),
1877+
}
1878+
}

0 commit comments

Comments
 (0)