From bdaee42156843d45e01271ad45732e4e58a5b62c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Nied=C5=BAwied=C5=BA?= <bloodywojtas@gmail.com> Date: Thu, 5 May 2022 20:00:58 +0200 Subject: [PATCH] Provide integration for `bigdecimal` and `rust_decimal` crates (#1060, #1059) Co-authored-by: Kai Ren <tyranron@gmail.com> --- .github/workflows/ci.yml | 2 + juniper/CHANGELOG.md | 3 + juniper/Cargo.toml | 2 + juniper/README.md | 4 + juniper/src/integrations/bigdecimal.rs | 132 +++++++++++++++++++++++ juniper/src/integrations/mod.rs | 4 + juniper/src/integrations/rust_decimal.rs | 123 +++++++++++++++++++++ 7 files changed, 270 insertions(+) create mode 100644 juniper/src/integrations/bigdecimal.rs create mode 100644 juniper/src/integrations/rust_decimal.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6768559..313a4f4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,12 +97,14 @@ jobs: matrix: include: - { feature: <none>, crate: juniper } + - { feature: bigdecimal, crate: juniper } - { feature: bson, crate: juniper } - { feature: chrono, crate: juniper } - { feature: chrono-clock, crate: juniper } - { feature: chrono-tz, crate: juniper } - { feature: expose-test-schema, crate: juniper } - { feature: graphql-parser, crate: juniper } + - { feature: rust_decimal, crate: juniper } - { feature: schema-language, crate: juniper } - { feature: serde_json, crate: juniper } - { feature: time, crate: juniper } diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md index 92991bae..1bff5bca 100644 --- a/juniper/CHANGELOG.md +++ b/juniper/CHANGELOG.md @@ -56,6 +56,8 @@ All user visible changes to `juniper` crate will be documented in this file. Thi - `graphql_input_value!` and `graphql_vars!` macros. ([#996]) - [`time` crate] integration behind `time` [Cargo feature]. ([#1006]) - `#[derive(GraphQLInterface)]` macro allowing using structs as GraphQL interfaces. ([#1026]) +- [`bigdecimal` crate] integration behind `bigdecimal` [Cargo feature]. ([#1060]) +- [`rust_decimal` crate] integration behind `rust_decimal` [Cargo feature]. ([#1060]) ### Changed @@ -95,6 +97,7 @@ All user visible changes to `juniper` crate will be documented in this file. Thi [#1051]: /../../issues/1051 [#1054]: /../../pull/1054 [#1057]: /../../pull/1057 +[#1060]: /../../pull/1060 [ba1ed85b]: /../../commit/ba1ed85b3c3dd77fbae7baf6bc4e693321a94083 diff --git a/juniper/Cargo.toml b/juniper/Cargo.toml index f5f22c90..ea089c5f 100644 --- a/juniper/Cargo.toml +++ b/juniper/Cargo.toml @@ -37,6 +37,7 @@ schema-language = ["graphql-parser"] [dependencies] anyhow = { version = "1.0.32", default-features = false, optional = true } async-trait = "0.1.39" +bigdecimal = { version = "0.3", optional = true } bson = { version = "2.0", features = ["chrono-0_4"], optional = true } chrono = { version = "0.4", features = ["alloc"], default-features = false, optional = true } chrono-tz = { version = "0.6", default-features = false, optional = true } @@ -46,6 +47,7 @@ futures-enum = { version = "0.1.12", default-features = false } graphql-parser = { version = "0.4", optional = true } indexmap = { version = "1.0", features = ["serde-1"] } juniper_codegen = { version = "0.16.0-dev", path = "../juniper_codegen" } +rust_decimal = { version = "1.0", default-features = false, optional = true } serde = { version = "1.0.8", features = ["derive"], default-features = false } serde_json = { version = "1.0.2", default-features = false, optional = true } smartstring = "1.0" diff --git a/juniper/README.md b/juniper/README.md index 03b1ed8c..feb17329 100644 --- a/juniper/README.md +++ b/juniper/README.md @@ -44,9 +44,11 @@ As an exception to other [GraphQL] libraries for other languages, [Juniper] buil ### Data types [Juniper] has automatic integration with some very common [Rust] crates to make building schemas a breeze. The types from these crates will be usable in your schemas automatically: +- [`bigdecimal`] (feature gated) - [`bson`] - [`chrono`] (feature gated) - [`chrono-tz`] (feature gated) +- [`rust_decimal`] (feature gated) - [`time`] (feature gated) - [`url`] - [`uuid`] @@ -78,6 +80,7 @@ This project is licensed under [BSD 2-Clause License](https://github.com/graphql [`actix-web`]: https://docs.rs/actix-web +[`bigdecimal`]: https://docs.rs/bigdecimal [`bson`]: https://docs.rs/bson [`chrono`]: https://docs.rs/chrono [`chrono-tz`]: https://docs.rs/chrono-tz @@ -89,6 +92,7 @@ This project is licensed under [BSD 2-Clause License](https://github.com/graphql [`hyper`]: https://docs.rs/hyper [`iron`]: https://docs.rs/iron [`rocket`]: https://docs.rs/rocket +[`rust_decimal`]: https://docs.rs/rust_decimal [`time`]: https://docs.rs/time [`url`]: https://docs.rs/url [`uuid`]: https://docs.rs/uuid diff --git a/juniper/src/integrations/bigdecimal.rs b/juniper/src/integrations/bigdecimal.rs new file mode 100644 index 00000000..a1635fcc --- /dev/null +++ b/juniper/src/integrations/bigdecimal.rs @@ -0,0 +1,132 @@ +//! GraphQL support for [`bigdecimal`] crate types. +//! +//! # Supported types +//! +//! | Rust type | GraphQL scalar | +//! |----------------|----------------| +//! | [`BigDecimal`] | `BigDecimal` | +//! +//! [`BigDecimal`]: bigdecimal::BigDecimal + +use std::str::FromStr as _; + +use crate::{graphql_scalar, InputValue, ScalarValue, Value}; + +/// Big decimal type. +/// +/// Allows storing any real number to arbitrary precision; which avoids common +/// floating point errors (such as 0.1 + 0.2 ≠ 0.3) at the cost of complexity. +/// +/// Always serializes as `String`. But may be deserialized from `Int` and +/// `Float` values too. It's not recommended to deserialize from a `Float` +/// directly, as the floating point representation may be unexpected. +/// +/// See also [`bigdecimal`] crate for details. +/// +/// [`bigdecimal`]: https://docs.rs/bigdecimal +#[graphql_scalar( + with = bigdecimal_scalar, + parse_token(i32, f64, String), + specified_by_url = "https://docs.rs/bigdecimal", +)] +type BigDecimal = bigdecimal::BigDecimal; + +mod bigdecimal_scalar { + use std::convert::TryFrom as _; + + use super::*; + + pub(super) fn to_output<S: ScalarValue>(v: &BigDecimal) -> Value<S> { + Value::scalar(v.to_string()) + } + + pub(super) fn from_input<S: ScalarValue>(v: &InputValue<S>) -> Result<BigDecimal, String> { + if let Some(i) = v.as_int_value() { + Ok(BigDecimal::from(i)) + } else if let Some(f) = v.as_float_value() { + BigDecimal::try_from(f) + .map_err(|e| format!("Failed to parse `BigDecimal` from `Float`: {}", e)) + } else { + v.as_string_value() + .ok_or_else(|| format!("Expected `String`, found: {}", v)) + .and_then(|s| { + BigDecimal::from_str(s) + .map_err(|e| format!("Failed to parse `BigDecimal` from `String`: {}", e)) + }) + } + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr as _; + + use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _}; + + use super::BigDecimal; + + #[test] + fn parses_correct_input() { + for (input, expected) in [ + (graphql_input_value!("4.20"), "4.20"), + (graphql_input_value!("0"), "0"), + ( + graphql_input_value!("999999999999.999999999"), + "999999999999.999999999", + ), + ( + graphql_input_value!("87553378877997984345"), + "87553378877997984345", + ), + (graphql_input_value!(123), "123"), + (graphql_input_value!(0), "0"), + (graphql_input_value!(43.44), "43.44"), + ] { + let input: InputValue = input; + let parsed = BigDecimal::from_input_value(&input); + let expected = BigDecimal::from_str(expected).unwrap(); + + assert!( + parsed.is_ok(), + "failed to parse `{:?}`: {:?}", + input, + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {:?}", input); + } + } + + #[test] + fn fails_on_invalid_input() { + for input in [ + graphql_input_value!(""), + graphql_input_value!("0,0"), + graphql_input_value!("12,"), + graphql_input_value!("1996-12-19T14:23:43"), + graphql_input_value!("i'm not even a number"), + graphql_input_value!(null), + graphql_input_value!(false), + ] { + let input: InputValue = input; + let parsed = BigDecimal::from_input_value(&input); + + assert!(parsed.is_err(), "allows input: {:?}", input); + } + } + + #[test] + fn formats_correctly() { + for raw in [ + "4.20", + "0", + "999999999999.999999999", + "87553378877997984345", + "123", + "43.44", + ] { + let actual: InputValue = BigDecimal::from_str(raw).unwrap().to_input_value(); + + assert_eq!(actual, graphql_input_value!((raw)), "on value: {}", raw); + } + } +} diff --git a/juniper/src/integrations/mod.rs b/juniper/src/integrations/mod.rs index d966997f..05f988cb 100644 --- a/juniper/src/integrations/mod.rs +++ b/juniper/src/integrations/mod.rs @@ -1,11 +1,15 @@ //! Provides GraphQLType implementations for some external types +#[cfg(feature = "bigdecimal")] +pub mod bigdecimal; #[cfg(feature = "bson")] pub mod bson; #[cfg(feature = "chrono")] pub mod chrono; #[cfg(feature = "chrono-tz")] pub mod chrono_tz; +#[cfg(feature = "rust_decimal")] +pub mod rust_decimal; #[doc(hidden)] pub mod serde; #[cfg(feature = "time")] diff --git a/juniper/src/integrations/rust_decimal.rs b/juniper/src/integrations/rust_decimal.rs new file mode 100644 index 00000000..2e74db32 --- /dev/null +++ b/juniper/src/integrations/rust_decimal.rs @@ -0,0 +1,123 @@ +//! GraphQL support for [`rust_decimal`] crate types. +//! +//! # Supported types +//! +//! | Rust type | GraphQL scalar | +//! |-------------|----------------| +//! | [`Decimal`] | `Decimal` | +//! +//! [`Decimal`]: rust_decimal::Decimal + +use std::str::FromStr as _; + +use crate::{graphql_scalar, InputValue, ScalarValue, Value}; + +/// 128 bit representation of a fixed-precision decimal number. +/// +/// The finite set of values of `Decimal` scalar are of the form +/// m / 10<sup>e</sup>, where m is an integer such that +/// -2<sup>96</sup> < m < 2<sup>96</sup>, and e is an integer between 0 and 28 +/// inclusive. +/// +/// Always serializes as `String`. But may be deserialized from `Int` and +/// `Float` values too. It's not recommended to deserialize from a `Float` +/// directly, as the floating point representation may be unexpected. +/// +/// See also [`rust_decimal`] crate for details. +/// +/// [`rust_decimal`]: https://docs.rs/rust_decimal +#[graphql_scalar( + with = rust_decimal_scalar, + parse_token(i32, f64, String), + specified_by_url = "https://docs.rs/rust_decimal", +)] +type Decimal = rust_decimal::Decimal; + +mod rust_decimal_scalar { + use std::convert::TryFrom as _; + + use super::*; + + pub(super) fn to_output<S: ScalarValue>(v: &Decimal) -> Value<S> { + Value::scalar(v.to_string()) + } + + pub(super) fn from_input<S: ScalarValue>(v: &InputValue<S>) -> Result<Decimal, String> { + if let Some(i) = v.as_int_value() { + Ok(Decimal::from(i)) + } else if let Some(f) = v.as_float_value() { + Decimal::try_from(f) + .map_err(|e| format!("Failed to parse `Decimal` from `Float`: {}", e)) + } else { + v.as_string_value() + .ok_or_else(|| format!("Expected `String`, found: {}", v)) + .and_then(|s| { + Decimal::from_str(s) + .map_err(|e| format!("Failed to parse `Decimal` from `String`: {}", e)) + }) + } + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr as _; + + use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _}; + + use super::Decimal; + + #[test] + fn parses_correct_input() { + for (input, expected) in [ + (graphql_input_value!("4.20"), "4.20"), + (graphql_input_value!("0"), "0"), + (graphql_input_value!("999.999999999"), "999.999999999"), + (graphql_input_value!("875533788"), "875533788"), + (graphql_input_value!(123), "123"), + (graphql_input_value!(0), "0"), + (graphql_input_value!(43.44), "43.44"), + ] { + let input: InputValue = input; + let parsed = Decimal::from_input_value(&input); + let expected = Decimal::from_str(expected).unwrap(); + + assert!( + parsed.is_ok(), + "failed to parse `{:?}`: {:?}", + input, + parsed.unwrap_err(), + ); + assert_eq!(parsed.unwrap(), expected, "input: {:?}", input); + } + } + + #[test] + fn fails_on_invalid_input() { + for input in [ + graphql_input_value!(""), + graphql_input_value!("0,0"), + graphql_input_value!("12,"), + graphql_input_value!("1996-12-19T14:23:43"), + graphql_input_value!("99999999999999999999999999999999999999"), + graphql_input_value!("99999999999999999999999999999999999999.99"), + graphql_input_value!("i'm not even a number"), + graphql_input_value!(null), + graphql_input_value!(false), + ] { + let input: InputValue = input; + let parsed = Decimal::from_input_value(&input); + + assert!(parsed.is_err(), "allows input: {:?}", input); + } + } + + #[test] + fn formats_correctly() { + for raw in ["4.20", "0", "999.999999999", "875533788", "123", "43.44"] { + let actual: InputValue = Decimal::from_str(raw).unwrap().to_input_value(); + + assert_eq!(actual, graphql_input_value!((raw)), "on value: {}", raw); + } + } +}