diff --git a/sqlx-core/src/postgres/types/mod.rs b/sqlx-core/src/postgres/types/mod.rs index 19b9aeedb..aaed2d579 100644 --- a/sqlx-core/src/postgres/types/mod.rs +++ b/sqlx-core/src/postgres/types/mod.rs @@ -14,9 +14,11 @@ //! | `&[u8]`, `Vec` | BYTEA | //! | [`PgInterval`] | INTERVAL | //! | [`PgRange`] | INT8RANGE, INT4RANGE, TSRANGE, TSTZTRANGE, DATERANGE, NUMRANGE | +//! | [`PgMoney`] | MONEY | //! //! [`PgInterval`]: struct.PgInterval.html //! [`PgRange`]: struct.PgRange.html +//! [`PgMoney`]: struct.PgMoney.html //! //! ### [`chrono`](https://crates.io/crates/chrono) //! @@ -143,6 +145,7 @@ mod bytes; mod float; mod int; mod interval; +mod money; mod range; mod record; mod str; @@ -170,6 +173,7 @@ mod json; mod ipnetwork; pub use interval::PgInterval; +pub use money::PgMoney; pub use range::PgRange; // used in derive(Type) for `struct` diff --git a/sqlx-core/src/postgres/types/money.rs b/sqlx-core/src/postgres/types/money.rs new file mode 100644 index 000000000..bc0b0835d --- /dev/null +++ b/sqlx-core/src/postgres/types/money.rs @@ -0,0 +1,217 @@ +use crate::{ + decode::Decode, + encode::{Encode, IsNull}, + error::BoxDynError, + postgres::{PgArgumentBuffer, PgTypeInfo, PgValueFormat, PgValueRef, Postgres}, + types::Type, +}; +use byteorder::{BigEndian, ByteOrder}; +use std::{ + io, + ops::{Add, AddAssign, Sub, SubAssign}, +}; + +/// The PostgreSQL [`MONEY`] type stores a currency amount with a fixed fractional +/// precision. The fractional precision is determined by the database's +/// `lc_monetary` setting. +/// +/// Data is read and written as 64-bit signed integers, and conversion into a +/// decimal should be done using the right precision. +/// +/// Reading `MONEY` value in text format is not supported and will cause an error. +/// +/// [`MONEY`]: https://www.postgresql.org/docs/current/datatype-money.html +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct PgMoney(pub i64); + +impl PgMoney { + /// Convert the money value into a [`BigDecimal`] using the correct precision + /// defined in the PostgreSQL settings. The default precision is two. + /// + /// [`BigDecimal`]: ../../types/struct.BigDecimal.html + #[cfg(feature = "bigdecimal")] + pub fn as_bigdecimal(&self, scale: i64) -> bigdecimal::BigDecimal { + let digits = num_bigint::BigInt::from(self.0); + + bigdecimal::BigDecimal::new(digits, scale) + } +} + +impl Type for PgMoney { + fn type_info() -> PgTypeInfo { + PgTypeInfo::MONEY + } +} + +impl Type for [PgMoney] { + fn type_info() -> PgTypeInfo { + PgTypeInfo::MONEY_ARRAY + } +} + +impl Type for Vec { + fn type_info() -> PgTypeInfo { + <[PgMoney] as Type>::type_info() + } +} + +impl From for PgMoney +where + T: Into, +{ + fn from(num: T) -> Self { + Self(num.into()) + } +} + +impl Encode<'_, Postgres> for PgMoney { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> IsNull { + buf.extend(&self.0.to_be_bytes()); + + IsNull::No + } +} + +impl Decode<'_, Postgres> for PgMoney { + fn decode(value: PgValueRef<'_>) -> Result { + match value.format() { + PgValueFormat::Binary => { + let cents = BigEndian::read_i64(value.as_bytes()?); + + Ok(PgMoney(cents)) + } + PgValueFormat::Text => { + let error = io::Error::new( + io::ErrorKind::InvalidData, + "Reading a `MONEY` value in text format is not supported.", + ); + + Err(Box::new(error)) + } + } + } +} + +impl Add for PgMoney { + type Output = PgMoney; + + /// Adds two monetary values. + /// + /// # Panics + /// Panics if overflowing the `i64::MAX`. + fn add(self, rhs: PgMoney) -> Self::Output { + self.0 + .checked_add(rhs.0) + .map(PgMoney) + .expect("overflow adding money amounts") + } +} + +impl AddAssign for PgMoney { + /// An assigning add for two monetary values. + /// + /// # Panics + /// Panics if overflowing the `i64::MAX`. + fn add_assign(&mut self, rhs: PgMoney) { + self.0 = self + .0 + .checked_add(rhs.0) + .expect("overflow adding money amounts") + } +} + +impl Sub for PgMoney { + type Output = PgMoney; + + /// Subtracts two monetary values. + /// + /// # Panics + /// Panics if underflowing the `i64::MIN`. + fn sub(self, rhs: PgMoney) -> Self::Output { + self.0 + .checked_sub(rhs.0) + .map(PgMoney) + .expect("overflow subtracting money amounts") + } +} + +impl SubAssign for PgMoney { + /// An assigning subtract for two monetary values. + /// + /// # Panics + /// Panics if underflowing the `i64::MIN`. + fn sub_assign(&mut self, rhs: PgMoney) { + self.0 = self + .0 + .checked_sub(rhs.0) + .expect("overflow subtracting money amounts") + } +} + +#[cfg(test)] +mod tests { + use super::PgMoney; + + #[test] + fn adding_works() { + assert_eq!(PgMoney(3), PgMoney(1) + PgMoney(2)) + } + + #[test] + fn add_assign_works() { + let mut money = PgMoney(1); + money += PgMoney(2); + + assert_eq!(PgMoney(3), money); + } + + #[test] + fn subtracting_works() { + assert_eq!(PgMoney(4), PgMoney(5) - PgMoney(1)) + } + + #[test] + fn sub_assign_works() { + let mut money = PgMoney(1); + money -= PgMoney(2); + + assert_eq!(PgMoney(-1), money); + } + + #[test] + #[should_panic] + fn add_overflow_panics() { + let _ = PgMoney(i64::MAX) + PgMoney(1); + } + + #[test] + #[should_panic] + fn add_assign_overflow_panics() { + let mut money = PgMoney(i64::MAX); + money += PgMoney(1); + } + + #[test] + #[should_panic] + fn sub_overflow_panics() { + let _ = PgMoney(i64::MIN) - PgMoney(1); + } + + #[test] + #[should_panic] + fn sub_assign_overflow_panics() { + let mut money = PgMoney(i64::MIN); + money -= PgMoney(1); + } + + #[test] + #[cfg(feature = "bigdecimal")] + fn conversion_to_bigdecimal_works() { + let money = PgMoney(12345); + + assert_eq!( + bigdecimal::BigDecimal::new(num_bigint::BigInt::from(12345), 2), + money.as_bigdecimal(2) + ); + } +} diff --git a/tests/postgres/types.rs b/tests/postgres/types.rs index bf6f7e9b6..c9bb49b94 100644 --- a/tests/postgres/types.rs +++ b/tests/postgres/types.rs @@ -2,7 +2,7 @@ extern crate time_ as time; use std::ops::Bound; -use sqlx::postgres::types::{PgInterval, PgRange}; +use sqlx::postgres::types::{PgInterval, PgMoney, PgRange}; use sqlx::postgres::Postgres; use sqlx_test::{test_decode_type, test_prepared_type, test_type}; @@ -388,3 +388,9 @@ test_prepared_type!(interval( microseconds: (3 * 3_600 + 10 * 60 + 20) * 1_000_000 + 116100 }, )); + +test_prepared_type!(money(Postgres, "123.45::money" == PgMoney(12345))); + +test_prepared_type!(money_vec>(Postgres, + "array[123.45,420.00,666.66]::money[]" == vec![PgMoney(12345), PgMoney(42000), PgMoney(66666)], +));