Add Jiff date/time types Zoned/TimeZone (#1278)

* Enable support for Jiff's Zoned type

* Enable support for Jiff's TimeZone type

* Remove unnecessary feature flag `jiff-tz`

* Avoid panic and allow offset-only time zones to be returned

* Add ZonedDateTime and TimeZone to integration test

* Split TimeZone into TimeZone and UTCOffset for Jiff integration

* Fix capitalization of `UtcOffset`

* Provide TimeZoneOrUtcOffset integration for jiff::tz::TimeZone

* Adjust documentation for newtypes and `TimeZoneOrUtcOffset`

* Use `jiff::tz::Offset` directly in `UtcOffset` newtype

* Remove unnecessary newtype for `UtcOffset` scalar

* Fix parsing of fixed offsets from `TimeZoneOrUtcOffset` scalar

* Add unrelated missing link to books page on scalars

* State necessity for newtype in scalars table in book

* Remove redundant `alloc` feature, is included in `std`

* Implement `Display` for TimeZone newtype

* Rename error enum variant to clarify meaning

* Use concise wording in description of TimeZone

Co-authored-by: Christian Legnitto <LegNeato@users.noreply.github.com>

* Revert extra notes column in scalar table

* Add link to documentation of newtype `TimeZone`

* Remove unused duplicate link reference

* Sort links and group links from the same crate together

---------

Co-authored-by: Christian Legnitto <LegNeato@users.noreply.github.com>
This commit is contained in:
Sebastian Goll 2024-09-06 18:06:18 +02:00 committed by GitHub
parent eb2079b2bd
commit fb1531f90c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 848 additions and 42 deletions

View file

@ -386,7 +386,7 @@ mod date_scalar {
[Juniper] provides out-of-the-box [GraphQL scalar][0] implementations for some very common [Rust] crates. The types from these crates will be usable in your schemas automatically after enabling the correspondent self-titled [Cargo feature]. [Juniper] provides out-of-the-box [GraphQL scalar][0] implementations for some very common [Rust] crates. The types from these crates will be usable in your schemas automatically after enabling the correspondent self-titled [Cargo feature].
| [Rust] type | [GraphQL] scalar | [Cargo feature] | | [Rust] type | [GraphQL] scalar | [Cargo feature] |
|-----------------------------|-------------------|------------------| |-----------------------------|-----------------------|------------------|
| [`bigdecimal::BigDecimal`] | `BigDecimal` | [`bigdecimal`] | | [`bigdecimal::BigDecimal`] | `BigDecimal` | [`bigdecimal`] |
| [`bson::oid::ObjectId`] | [`ObjectID`] | [`bson`] | | [`bson::oid::ObjectId`] | [`ObjectID`] | [`bson`] |
| [`bson::DateTime`] | [`DateTime`] | [`bson`] | | [`bson::DateTime`] | [`DateTime`] | [`bson`] |
@ -400,6 +400,10 @@ mod date_scalar {
| [`jiff::civil::Time`] | [`LocalTime`] | [`jiff`] | | [`jiff::civil::Time`] | [`LocalTime`] | [`jiff`] |
| [`jiff::civil::DateTime`] | [`LocalDateTime`] | [`jiff`] | | [`jiff::civil::DateTime`] | [`LocalDateTime`] | [`jiff`] |
| [`jiff::Timestamp`] | [`DateTime`] | [`jiff`] | | [`jiff::Timestamp`] | [`DateTime`] | [`jiff`] |
| [`jiff::Zoned`] | `ZonedDateTime` | [`jiff`] |
| [`jiff::tz::TimeZone`] | `TimeZoneOrUtcOffset` | [`jiff`] |
| [`jiff::tz::TimeZone`] | [`TimeZone`] [^n1] | [`jiff`] |
| [`jiff::tz::Offset`] | [`UtcOffset`] | [`jiff`] |
| [`jiff::Span`] | [`Duration`] | [`jiff`] | | [`jiff::Span`] | [`Duration`] | [`jiff`] |
| [`time::Date`] | [`LocalDate`] | [`time`] | | [`time::Date`] | [`LocalDate`] | [`time`] |
| [`time::Time`] | [`LocalTime`] | [`time`] | | [`time::Time`] | [`LocalTime`] | [`time`] |
@ -409,6 +413,8 @@ mod date_scalar {
| [`url::Url`] | [`URL`] | [`url`] | | [`url::Url`] | [`URL`] | [`url`] |
| [`uuid::Uuid`] | [`UUID`] | [`uuid`] | | [`uuid::Uuid`] | [`UUID`] | [`uuid`] |
[^n1]: Conversion supported via newtype [`integrations::jiff::TimeZone`][10].
@ -425,7 +431,6 @@ mod date_scalar {
[`chrono-tz`]: https://docs.rs/chrono-tz [`chrono-tz`]: https://docs.rs/chrono-tz
[`chrono_tz::Tz`]: https://docs.rs/chrono-tz/latest/chrono_tz/enum.Tz.html [`chrono_tz::Tz`]: https://docs.rs/chrono-tz/latest/chrono_tz/enum.Tz.html
[`DateTime`]: https://graphql-scalars.dev/docs/scalars/date-time [`DateTime`]: https://graphql-scalars.dev/docs/scalars/date-time
[`Decimal`]: https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html
[`Duration`]: https://graphql-scalars.dev/docs/scalars/duration [`Duration`]: https://graphql-scalars.dev/docs/scalars/duration
[`ID`]: https://spec.graphql.org/October2021#sec-ID [`ID`]: https://spec.graphql.org/October2021#sec-ID
[`jiff`]: https://docs.rs/jiff [`jiff`]: https://docs.rs/jiff
@ -434,11 +439,15 @@ mod date_scalar {
[`jiff::civil::Time`]: https://docs.rs/jiff/latest/jiff/civil/struct.Time.html [`jiff::civil::Time`]: https://docs.rs/jiff/latest/jiff/civil/struct.Time.html
[`jiff::Span`]: https://docs.rs/jiff/latest/jiff/struct.Span.html [`jiff::Span`]: https://docs.rs/jiff/latest/jiff/struct.Span.html
[`jiff::Timestamp`]: https://docs.rs/jiff/latest/jiff/struct.Timestamp.html [`jiff::Timestamp`]: https://docs.rs/jiff/latest/jiff/struct.Timestamp.html
[`jiff::tz::Offset`]: https://docs.rs/jiff/latest/jiff/tz/struct.Offset.html
[`jiff::tz::TimeZone`]: https://docs.rs/jiff/latest/jiff/tz/struct.TimeZone.html
[`jiff::Zoned`]: https://docs.rs/jiff/latest/jiff/struct.Zoned.html
[`LocalDate`]: https://graphql-scalars.dev/docs/scalars/local-date [`LocalDate`]: https://graphql-scalars.dev/docs/scalars/local-date
[`LocalDateTime`]: https://graphql-scalars.dev/docs/scalars/local-date-time [`LocalDateTime`]: https://graphql-scalars.dev/docs/scalars/local-date-time
[`LocalTime`]: https://graphql-scalars.dev/docs/scalars/local-time [`LocalTime`]: https://graphql-scalars.dev/docs/scalars/local-time
[`ObjectID`]: https://the-guild.dev/graphql/scalars/docs/scalars/object-id [`ObjectID`]: https://the-guild.dev/graphql/scalars/docs/scalars/object-id
[`rust_decimal`]: https://docs.rs/rust_decimal [`rust_decimal`]: https://docs.rs/rust_decimal
[`rust_decimal::Decimal`]: https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html
[`ScalarValue`]: https://docs.rs/juniper/0.16.1/juniper/trait.ScalarValue.html [`ScalarValue`]: https://docs.rs/juniper/0.16.1/juniper/trait.ScalarValue.html
[`serde`]: https://docs.rs/serde [`serde`]: https://docs.rs/serde
[`time`]: https://docs.rs/time [`time`]: https://docs.rs/time
@ -472,3 +481,4 @@ mod date_scalar {
[7]: https://spec.graphql.org/October2021#sec-Value-Resolution [7]: https://spec.graphql.org/October2021#sec-Value-Resolution
[8]: https://docs.rs/juniper/0.16.1/juniper/derive.GraphQLScalar.html [8]: https://docs.rs/juniper/0.16.1/juniper/derive.GraphQLScalar.html
[9]: https://docs.rs/juniper/0.16.1/juniper/attr.graphql_scalar.html [9]: https://docs.rs/juniper/0.16.1/juniper/attr.graphql_scalar.html
[10]: https://docs.rs/juniper/0.16.1/juniper/integrations/jiff/struct.TimeZone.html

View file

@ -34,11 +34,14 @@ All user visible changes to `juniper` crate will be documented in this file. Thi
### Added ### Added
- [`jiff` crate] integration behind `jiff` [Cargo feature]: ([#1271], [#1270]) - [`jiff` crate] integration behind `jiff` [Cargo feature]: ([#1271], [#1278], [#1270])
- `jiff::civil::Date` as `LocalDate` scalar. - `jiff::civil::Date` as `LocalDate` scalar.
- `jiff::civil::Time` as `LocalTime` scalar. - `jiff::civil::Time` as `LocalTime` scalar.
- `jiff::civil::DateTime` as `LocalDateTime` scalar. ([#1275]) - `jiff::civil::DateTime` as `LocalDateTime` scalar. ([#1275])
- `jiff::Timestamp` as `DateTime` scalar. - `jiff::Timestamp` as `DateTime` scalar.
- `jiff::Zoned` as `ZonedDateTime` scalar.
- `jiff::tz::TimeZone` as `TimeZoneOrUtcOffset` scalar.
- `jiff::tz::Offset` as `UtcOffset` scalar.
- `jiff::Span` as `Duration` scalar. - `jiff::Span` as `Duration` scalar.
### Changed ### Changed
@ -51,6 +54,8 @@ All user visible changes to `juniper` crate will be documented in this file. Thi
[#1272]: /../../pull/1272 [#1272]: /../../pull/1272
[#1275]: /../../pull/1275 [#1275]: /../../pull/1275
[#1277]: /../../pull/1277 [#1277]: /../../pull/1277
[#1278]: /../../pull/1278
[#1279]: /../../pull/1279
[#1281]: /../../pull/1281 [#1281]: /../../pull/1281

View file

@ -53,7 +53,7 @@ fnv = "1.0.5"
futures = { version = "0.3.22", features = ["alloc"], default-features = false } futures = { version = "0.3.22", features = ["alloc"], default-features = false }
graphql-parser = { version = "0.4", optional = true } graphql-parser = { version = "0.4", optional = true }
indexmap = { version = "2.0", features = ["serde"] } indexmap = { version = "2.0", features = ["serde"] }
jiff = { version = "0.1.5", features = ["alloc"], default-features = false, optional = true } jiff = { version = "0.1.5", features = ["std"], default-features = false, optional = true }
juniper_codegen = { version = "0.16.0", path = "../juniper_codegen" } juniper_codegen = { version = "0.16.0", path = "../juniper_codegen" }
rust_decimal = { version = "1.20", default-features = false, optional = true } rust_decimal = { version = "1.20", default-features = false, optional = true }
ryu = { version = "1.0", optional = true } ryu = { version = "1.0", optional = true }
@ -78,6 +78,7 @@ void = { version = "1.0.2", optional = true }
[dev-dependencies] [dev-dependencies]
bencher = "0.1.2" bencher = "0.1.2"
chrono = { version = "0.4.30", features = ["alloc"], default-features = false } chrono = { version = "0.4.30", features = ["alloc"], default-features = false }
jiff = { version = "0.1.5", features = ["tzdb-bundle-always"], default-features = false }
pretty_assertions = "1.0.0" pretty_assertions = "1.0.0"
serde_json = "1.0.18" serde_json = "1.0.18"
serial_test = "3.0" serial_test = "3.0"

View file

@ -3,25 +3,38 @@
//! # Supported types //! # Supported types
//! //!
//! | Rust type | Format | GraphQL scalar | //! | Rust type | Format | GraphQL scalar |
//! |---------------------|-----------------------|-----------------------| //! |---------------------------------------|-----------------------------|-----------------------|
//! | [`civil::Date`] | `yyyy-MM-dd` | [`LocalDate`][s1] | //! | [`civil::Date`] | `yyyy-MM-dd` | [`LocalDate`][s1] |
//! | [`civil::Time`] | `HH:mm[:ss[.SSS]]` | [`LocalTime`][s2] | //! | [`civil::Time`] | `HH:mm[:ss[.SSS]]` | [`LocalTime`][s2] |
//! | [`civil::DateTime`] | `yyyy-MM-ddTHH:mm:ss` | [`LocalDateTime`][s3] | //! | [`civil::DateTime`] | `yyyy-MM-ddTHH:mm:ss` | [`LocalDateTime`][s3] |
//! | [`Timestamp`] | [RFC 3339] string | [`DateTime`][s4] | //! | [`Timestamp`] | [RFC 3339] string | [`DateTime`][s4] |
//! | [`Span`] | [ISO 8601] duration | [`Duration`][s5] | //! | [`Zoned`][^1] | [RFC 9557] string | `ZonedDateTime` |
//! | [`tz::TimeZone`][^1] | [IANA database][1]/`±hh:mm` | `TimeZoneOrUtcOffset` |
//! | [`tz::TimeZone`] via [`TimeZone`][^1] | [IANA database][1] | [`TimeZone`][s5] |
//! | [`tz::Offset`] | `±hh:mm` | [`UtcOffset`][s6] |
//! | [`Span`] | [ISO 8601] duration | [`Duration`][s7] |
//! //!
//! # Unsupported types //! [^1]: For these, crate [`jiff`] must be installed with a feature flag that provides access to
//! the Time Zone Database (e.g. by using the crate's default feature flags). See [`jiff` time zone
//! features][tz] for details.
//! //!
//! [`Zoned`] is not supported because the GraphQL scalar [`DateTime`][s4] only supports time zone //! # Time zone types
//! offsets but no IANA time zone names (as in `2024-08-10T23:14:00-04:00[America/New_York]`, cf. //!
//! [RFC 9557]). Serializing such values would incur a loss of information with unexpected and //! `tz::TimeZone` values can be IANA time zone identifiers or fixed offsets, corresponding to
//! subtle consequences (a fixed offset would only _seem_ to work in most cases). //! GraphQL scalars [`TimeZone`][s5] and [`UtcOffset`][s6]. While `UtcOffset` can be serialized from
//! [`tz::Offset`] directly, newtype [`TimeZone`] handles serialization to `TimeZone`, with
//! [`TryFrom`] and [`Into`] implementations from and to `tz::TimeZone`.
//!
//! In addition, `tz::TimeZone` serializes to `TimeZoneOrUtcOffset` which is a GraphQL scalar that
//! contains either an IANA identifier or a fixed offset for clients that can consume both values.
//! //!
//! [`civil::Date`]: jiff::civil::Date //! [`civil::Date`]: jiff::civil::Date
//! [`civil::DateTime`]: jiff::civil::DateTime //! [`civil::DateTime`]: jiff::civil::DateTime
//! [`civil::Time`]: jiff::civil::Time //! [`civil::Time`]: jiff::civil::Time
//! [`Span`]: jiff::Span //! [`Span`]: jiff::Span
//! [`Timestamp`]: jiff::Timestamp //! [`Timestamp`]: jiff::Timestamp
//! [`tz::Offset`]: jiff::tz::Offset
//! [`tz::TimeZone`]: jiff::tz::TimeZone
//! [`Zoned`]: jiff::Zoned //! [`Zoned`]: jiff::Zoned
//! [ISO 8601]: https://en.wikipedia.org/wiki/ISO_8601#Durations //! [ISO 8601]: https://en.wikipedia.org/wiki/ISO_8601#Durations
//! [RFC 3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6 //! [RFC 3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
@ -30,7 +43,13 @@
//! [s2]: https://graphql-scalars.dev/docs/scalars/local-time //! [s2]: https://graphql-scalars.dev/docs/scalars/local-time
//! [s3]: https://graphql-scalars.dev/docs/scalars/local-date-time //! [s3]: https://graphql-scalars.dev/docs/scalars/local-date-time
//! [s4]: https://graphql-scalars.dev/docs/scalars/date-time //! [s4]: https://graphql-scalars.dev/docs/scalars/date-time
//! [s5]: https://graphql-scalars.dev/docs/scalars/duration //! [s5]: https://graphql-scalars.dev/docs/scalars/time-zone
//! [s6]: https://graphql-scalars.dev/docs/scalars/utc-offset
//! [s7]: https://graphql-scalars.dev/docs/scalars/duration
//! [tz]: https://docs.rs/jiff/latest/jiff/index.html#time-zone-features
//! [1]: http://www.iana.org/time-zones
use std::{error::Error, fmt, str};
use crate::{graphql_scalar, InputValue, ScalarValue, Value}; use crate::{graphql_scalar, InputValue, ScalarValue, Value};
@ -242,6 +261,53 @@ mod date_time {
} }
} }
/// Time zone aware instant in time.
///
/// Can be thought of as combination of the following types, all rolled into one:
///
/// - [`Timestamp`][3] for indicating precise instant in time.
/// - [`DateTime`][4] for indicating "civil" calendar date and clock time.
/// - [`TimeZone`][5] for indicating how to apply time zone transitions while performing arithmetic.
///
/// [RFC 9557][1] compliant.
///
/// See also [`jiff::Zoned`][2] for details.
///
/// [1]: https://datatracker.ietf.org/doc/html/rfc9557#section-4.1
/// [2]: https://docs.rs/jiff/latest/jiff/struct.Zoned.html
/// [3]: https://docs.rs/jiff/latest/jiff/struct.Timestamp.html
/// [4]: https://docs.rs/jiff/latest/jiff/civil/struct.DateTime.html
/// [5]: https://docs.rs/jiff/latest/jiff/tz/struct.TimeZone.html
#[graphql_scalar(
with = zoned_date_time,
parse_token(String),
)]
pub type ZonedDateTime = jiff::Zoned;
mod zoned_date_time {
use std::str::FromStr as _;
use super::*;
pub(super) fn to_output<S>(v: &ZonedDateTime) -> Value<S>
where
S: ScalarValue,
{
Value::scalar(v.to_string())
}
pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<ZonedDateTime, String>
where
S: ScalarValue,
{
v.as_string_value()
.ok_or_else(|| format!("Expected `String`, found: {v}"))
.and_then(|s| {
ZonedDateTime::from_str(s).map_err(|e| format!("Invalid `ZonedDateTime`: {e}"))
})
}
}
/// Span of time represented via a mixture of calendar and clock units. /// Span of time represented via a mixture of calendar and clock units.
/// ///
/// Represents a duration of time in units of years, months, weeks, days, hours, minutes, seconds, /// Represents a duration of time in units of years, months, weeks, days, hours, minutes, seconds,
@ -282,6 +348,231 @@ mod duration {
} }
} }
/// Representation of time zone or UTC offset.
///
/// [IANA database][1] or `±hh:mm`.
///
/// See also [`jiff::tz::TimeZone`][2] for details.
///
/// [1]: http://www.iana.org/time-zones
/// [2]: https://docs.rs/jiff/latest/jiff/tz/struct.TimeZone.html
#[graphql_scalar(
with = time_zone_or_utc_offset,
parse_token(String),
)]
pub type TimeZoneOrUtcOffset = jiff::tz::TimeZone;
mod time_zone_or_utc_offset {
use super::*;
/// Format of a `TimeZoneOrUtcOffset` scalar.
const FORMAT: &str = "%:V";
pub(super) fn to_output<S>(v: &TimeZoneOrUtcOffset) -> Value<S>
where
S: ScalarValue,
{
Value::scalar(v.iana_name().map_or_else(
|| {
// If no IANA time zone identifier is available, fall back to displaying the time
// offset directly (using format `[+-]HH:MM[:SS]` from RFC 9557, e.g. `+05:30`).
//
// <https://github.com/graphql-rust/juniper/pull/1278#discussion_r1719161686>
jiff::Zoned::now()
.with_time_zone(v.clone())
.strftime(FORMAT)
.to_string()
},
ToOwned::to_owned,
))
}
pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<TimeZoneOrUtcOffset, String>
where
S: ScalarValue,
{
v.as_string_value()
.ok_or_else(|| format!("Expected `String`, found: {v}"))
.and_then(|s| {
TimeZoneOrUtcOffset::get(s)
.map_err(TimeZoneError::InvalidTimeZone)
.or_else(|_| utc_offset::utc_offset_from_str(s).map(TimeZoneOrUtcOffset::fixed))
.map_err(|e| format!("Invalid `TimeZoneOrUtcOffset`: {e}"))
})
}
}
/// Error while handling [`TimeZone`] value.
#[derive(Clone)]
pub enum TimeZoneError {
/// Identifier could not be parsed by [`tz::TimeZone::get`](jiff::tz::TimeZone::get).
InvalidTimeZone(jiff::Error),
/// GraphQL scalar [`TimeZone`] requires `tz::TimeZone` with IANA name.
MissingIanaName(jiff::tz::TimeZone),
}
impl fmt::Debug for TimeZoneError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidTimeZone(err) => write!(f, "TimeZoneError::InvalidTimeZone({err:?})"),
Self::MissingIanaName(_value) => write!(f, "TimeZoneError::MissingIanaName(..)"),
}
}
}
impl fmt::Display for TimeZoneError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidTimeZone(err) => err.fmt(f),
Self::MissingIanaName(_value) => write!(f, "missing IANA name"),
}
}
}
impl Error for TimeZoneError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::InvalidTimeZone(err) => Some(err),
Self::MissingIanaName(_) => None,
}
}
}
/// Representation of time zone.
///
/// A set of rules for determining the civil time, via an offset from UTC, in a particular
/// geographic region. In many cases, the offset in a particular time zone can vary over the course
/// of a year through transitions into and out of daylight saving time.
///
/// [`TimeZone` scalar][1] compliant.
///
/// See also [`jiff::tz::TimeZone`][2] for details.
///
/// [1]: https://graphql-scalars.dev/docs/scalars/time-zone
/// [2]: https://docs.rs/jiff/latest/jiff/tz/struct.TimeZone.html
#[graphql_scalar(
with = time_zone,
parse_token(String),
specified_by_url = "https://graphql-scalars.dev/docs/scalars/time-zone",
)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TimeZone(jiff::tz::TimeZone);
impl TryFrom<jiff::tz::TimeZone> for TimeZone {
type Error = TimeZoneError;
fn try_from(value: jiff::tz::TimeZone) -> Result<Self, Self::Error> {
if value.iana_name().is_none() {
return Err(TimeZoneError::MissingIanaName(value));
}
Ok(Self(value))
}
}
impl str::FromStr for TimeZone {
type Err = TimeZoneError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let value = jiff::tz::TimeZone::get(value).map_err(TimeZoneError::InvalidTimeZone)?;
value.try_into()
}
}
impl fmt::Display for TimeZone {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0
.iana_name()
// PANIC: We made sure that IANA name is available when constructing `Self`.
.unwrap_or_else(|| panic!("Failed to display `TimeZone`: no IANA name"))
.fmt(f)
}
}
impl From<TimeZone> for jiff::tz::TimeZone {
fn from(value: TimeZone) -> Self {
value.0
}
}
mod time_zone {
use std::str::FromStr as _;
use super::*;
pub(super) fn to_output<S>(v: &TimeZone) -> Value<S>
where
S: ScalarValue,
{
Value::scalar(v.to_string())
}
pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<TimeZone, String>
where
S: ScalarValue,
{
v.as_string_value()
.ok_or_else(|| format!("Expected `String`, found: {v}"))
.and_then(|s| TimeZone::from_str(s).map_err(|e| format!("Invalid `TimeZone`: {e}")))
}
}
/// Represents fixed time zone offset.
///
/// [`UtcOffset` scalar][1] compliant.
///
/// See also [`jiff::tz::Offset`][2] for details.
///
/// [1]: https://graphql-scalars.dev/docs/scalars/utc-offset
/// [2]: https://docs.rs/jiff/latest/jiff/tz/struct.Offset.html
#[graphql_scalar(
with = utc_offset,
parse_token(String),
specified_by_url = "https://graphql-scalars.dev/docs/scalars/utc-offset",
)]
pub type UtcOffset = jiff::tz::Offset;
mod utc_offset {
use super::*;
/// Format of a [`UtcOffset` scalar][1].
///
/// [1]: https://graphql-scalars.dev/docs/scalars/utc-offset
const FORMAT: &str = "%:z";
pub(super) fn utc_offset_from_str(value: &str) -> Result<jiff::tz::Offset, jiff::Error> {
let tm = jiff::fmt::strtime::BrokenDownTime::parse(FORMAT, value)?;
let offset = tm
.offset()
.expect("successful %:z parsing guarantees offset");
Ok(offset)
}
fn utc_offset_to_string(value: jiff::tz::Offset) -> String {
let mut buf = String::new();
let tm = jiff::fmt::strtime::BrokenDownTime::from(
&jiff::Zoned::now().with_time_zone(jiff::tz::TimeZone::fixed(value)),
);
tm.format(FORMAT, &mut buf).unwrap();
buf
}
pub(super) fn to_output<S>(v: &UtcOffset) -> Value<S>
where
S: ScalarValue,
{
Value::scalar(utc_offset_to_string(*v))
}
pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<UtcOffset, String>
where
S: ScalarValue,
{
v.as_string_value()
.ok_or_else(|| format!("Expected `String`, found: {v}"))
.and_then(|s| utc_offset_from_str(s).map_err(|e| format!("Invalid `UtcOffset`: {e}")))
}
}
#[cfg(test)] #[cfg(test)]
mod local_date_test { mod local_date_test {
use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _}; use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
@ -640,6 +931,192 @@ mod date_time_test {
} }
} }
#[cfg(test)]
mod zoned_date_time_test {
use jiff::{civil, tz, tz::TimeZone};
use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
use super::ZonedDateTime;
#[test]
fn parses_correct_input() {
for (raw, expected) in [
(
"2014-11-28T21:00:09+09:00[Asia/Tokyo]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::get("Asia/Tokyo").unwrap())
.unwrap(),
),
(
"2014-11-28T21:00:09[America/New_York]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::get("America/New_York").unwrap())
.unwrap(),
),
(
"2014-11-28 21:00:09[America/New_York]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::get("America/New_York").unwrap())
.unwrap(),
),
(
"2014-11-28T21:00:09Z[gmt+0]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::get("GMT+0").unwrap())
.unwrap(),
),
(
"2014-11-28T21:00:09+03:00[etc/gmt-3]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::get("Etc/GMT-3").unwrap())
.unwrap(),
),
(
"2014-11-28T21:00:09+00:00[UTC]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::get("UTC").unwrap())
.unwrap(),
),
(
"2014-11-28T21:00:09+02:00[+02:00]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::fixed(tz::offset(2)))
.unwrap(),
),
(
"2014-11-28T21:00:09-11:00[-11:00]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::fixed(tz::offset(-11)))
.unwrap(),
),
(
"2014-11-28T21:00:09.05+09:00[Asia/Tokyo]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 50_000_000)
.to_zoned(TimeZone::get("Asia/Tokyo").unwrap())
.unwrap(),
),
(
"2014-11-28 21:00:09.05+09:00[Asia/Tokyo]",
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 50_000_000)
.to_zoned(TimeZone::get("Asia/Tokyo").unwrap())
.unwrap(),
),
] {
let input: InputValue = graphql_input_value!((raw));
let parsed = ZonedDateTime::from_input_value(&input);
assert!(
parsed.is_ok(),
"failed to parse `{raw}`: {:?}",
parsed.unwrap_err(),
);
assert_eq!(parsed.unwrap(), expected, "input: {raw}");
}
}
#[test]
fn fails_on_invalid_input() {
for input in [
graphql_input_value!("12"),
graphql_input_value!("12:"),
graphql_input_value!("56:34:22"),
graphql_input_value!("56:34:22.000"),
graphql_input_value!("1996-12-1914:23:43"),
graphql_input_value!("1996-12-19Q14:23:43Z"),
graphql_input_value!("1996-12-19T14:23:43"),
graphql_input_value!("1996-12-19T14:23:43ZZ"),
graphql_input_value!("1996-12-19T14:23:43.543"),
graphql_input_value!("1996-12-19T14:23"),
graphql_input_value!("1996-12-19T14:23:1"),
graphql_input_value!("1996-12-19T14:23:"),
graphql_input_value!("1996-12-19T23:78:43Z"),
graphql_input_value!("1996-12-19T23:18:99Z"),
graphql_input_value!("1996-12-19T24:00:00Z"),
graphql_input_value!("1996-12-19T99:02:13Z"),
graphql_input_value!("1996-12-19T99:02:13Z"),
graphql_input_value!("1996-12-19T12:02:13+4444444"),
graphql_input_value!("i'm not even a datetime"),
graphql_input_value!("2014-11-28T21:00:09Z"),
graphql_input_value!("2014-11-28T21:00:09+09:00"),
graphql_input_value!("2014-11-28T21:00:09+09:00[InvTZ]"),
graphql_input_value!(2.32),
graphql_input_value!(1),
graphql_input_value!(null),
graphql_input_value!(false),
] {
let input: InputValue = input;
let parsed = ZonedDateTime::from_input_value(&input);
assert!(parsed.is_err(), "allows input: {input:?}");
}
}
#[test]
fn formats_correctly() {
for (val, expected) in [
(
civil::DateTime::constant(1996, 12, 19, 0, 0, 0, 0)
.to_zoned(TimeZone::get("America/New_York").unwrap())
.unwrap(),
graphql_input_value!("1996-12-19T00:00:00-05:00[America/New_York]"),
),
(
civil::DateTime::constant(1964, 7, 30, 5, 0, 0, 123_000_000)
.to_zoned(TimeZone::get("America/New_York").unwrap())
.unwrap(),
graphql_input_value!("1964-07-30T05:00:00.123-04:00[America/New_York]"),
),
(
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::get("GMT+0").unwrap())
.unwrap(),
graphql_input_value!("2014-11-28T21:00:09+00:00[GMT+0]"),
),
(
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::get("Etc/GMT+3").unwrap())
.unwrap(),
graphql_input_value!("2014-11-28T21:00:09-03:00[Etc/GMT+3]"),
),
(
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::get("UTC").unwrap())
.unwrap(),
graphql_input_value!("2014-11-28T21:00:09+00:00[UTC]"),
),
(
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::UTC)
.unwrap(),
graphql_input_value!("2014-11-28T21:00:09+00:00[UTC]"),
),
(
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::fixed(tz::offset(0)))
.unwrap(),
graphql_input_value!("2014-11-28T21:00:09+00:00[UTC]"),
),
(
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::fixed(tz::offset(2)))
.unwrap(),
graphql_input_value!("2014-11-28T21:00:09+02:00[+02:00]"),
),
(
civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
.to_zoned(TimeZone::fixed(tz::offset(-11)))
.unwrap(),
graphql_input_value!("2014-11-28T21:00:09-11:00[-11:00]"),
),
] {
let actual: InputValue = val.to_input_value();
assert_eq!(actual, expected, "on value: {val}");
}
}
}
#[cfg(test)] #[cfg(test)]
mod duration_test { mod duration_test {
use jiff::ToSpan as _; use jiff::ToSpan as _;
@ -735,9 +1212,300 @@ mod duration_test {
} }
} }
#[cfg(test)]
mod time_zone_or_utc_offset_test {
use jiff::tz;
use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
use super::TimeZoneOrUtcOffset;
#[test]
fn parses_correct_input() {
for (raw, expected) in [
(
"Europe/London",
TimeZoneOrUtcOffset::get("Europe/London").unwrap(),
),
("Etc/GMT-3", TimeZoneOrUtcOffset::get("Etc/GMT-3").unwrap()),
(
"etc/gmt+11",
TimeZoneOrUtcOffset::get("Etc/GMT+11").unwrap(),
),
("factory", TimeZoneOrUtcOffset::get("Factory").unwrap()),
("zULU", TimeZoneOrUtcOffset::get("Zulu").unwrap()),
("UTC", TimeZoneOrUtcOffset::get("UTC").unwrap()),
(
"+00:00",
TimeZoneOrUtcOffset::try_from(tz::TimeZone::fixed(tz::offset(0))).unwrap(),
),
(
"+03:00",
TimeZoneOrUtcOffset::try_from(tz::TimeZone::fixed(tz::offset(3))).unwrap(),
),
(
"-09:00",
TimeZoneOrUtcOffset::try_from(tz::TimeZone::fixed(tz::offset(-9))).unwrap(),
),
] {
let input: InputValue = graphql_input_value!((raw));
let parsed = TimeZoneOrUtcOffset::from_input_value(&input);
assert!(
parsed.is_ok(),
"failed to parse `{raw}`: {:?}",
parsed.unwrap_err(),
);
assert_eq!(parsed.unwrap(), expected, "input: {raw}");
}
}
#[test]
fn fails_on_invalid_input() {
for input in [
graphql_input_value!("Abc/Xyz"),
graphql_input_value!("8086"),
graphql_input_value!("AbcXyz"),
graphql_input_value!("Z"),
graphql_input_value!("i'm not even a time zone"),
graphql_input_value!(2.32),
graphql_input_value!(1),
graphql_input_value!(null),
graphql_input_value!(false),
] {
let input: InputValue = input;
let parsed = TimeZoneOrUtcOffset::from_input_value(&input);
assert!(parsed.is_err(), "allows input: {input:?}");
}
}
#[test]
fn formats_correctly() {
for (val, expected) in [
(
TimeZoneOrUtcOffset::get("Europe/London").unwrap(),
graphql_input_value!("Europe/London"),
),
(
TimeZoneOrUtcOffset::get("Etc/GMT-3").unwrap(),
graphql_input_value!("Etc/GMT-3"),
),
(
TimeZoneOrUtcOffset::get("etc/gmt+11").unwrap(),
graphql_input_value!("Etc/GMT+11"),
),
(
TimeZoneOrUtcOffset::get("Factory").unwrap(),
graphql_input_value!("Factory"),
),
(
TimeZoneOrUtcOffset::get("zulu").unwrap(),
graphql_input_value!("Zulu"),
),
(
TimeZoneOrUtcOffset::fixed(tz::offset(0)),
graphql_input_value!("UTC"),
),
(
TimeZoneOrUtcOffset::get("UTC").unwrap(),
graphql_input_value!("UTC"),
),
(TimeZoneOrUtcOffset::UTC, graphql_input_value!("UTC")),
(
TimeZoneOrUtcOffset::fixed(tz::offset(2)),
graphql_input_value!("+02:00"),
),
(
TimeZoneOrUtcOffset::fixed(tz::offset(-11)),
graphql_input_value!("-11:00"),
),
] {
let actual: InputValue = val.to_input_value();
assert_eq!(actual, expected, "on value: {val:?}");
}
}
}
#[cfg(test)]
mod time_zone_test {
use jiff::tz;
use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
use super::TimeZone;
#[test]
fn parses_correct_input() {
for (raw, expected) in [
(
"Europe/London",
TimeZone::try_from(tz::TimeZone::get("Europe/London").unwrap()).unwrap(),
),
(
"Etc/GMT-3",
TimeZone::try_from(tz::TimeZone::get("Etc/GMT-3").unwrap()).unwrap(),
),
(
"etc/gmt+11",
TimeZone::try_from(tz::TimeZone::get("Etc/GMT+11").unwrap()).unwrap(),
),
(
"factory",
TimeZone::try_from(tz::TimeZone::get("Factory").unwrap()).unwrap(),
),
(
"zULU",
TimeZone::try_from(tz::TimeZone::get("Zulu").unwrap()).unwrap(),
),
(
"UTC",
TimeZone::try_from(tz::TimeZone::get("UTC").unwrap()).unwrap(),
),
] {
let input: InputValue = graphql_input_value!((raw));
let parsed = TimeZone::from_input_value(&input);
assert!(
parsed.is_ok(),
"failed to parse `{raw}`: {:?}",
parsed.unwrap_err(),
);
assert_eq!(parsed.unwrap(), expected, "input: {raw}");
}
}
#[test]
fn fails_on_invalid_input() {
for input in [
graphql_input_value!("Abc/Xyz"),
graphql_input_value!("8086"),
graphql_input_value!("AbcXyz"),
graphql_input_value!("-02:00"),
graphql_input_value!("+11:00"),
graphql_input_value!("Z"),
graphql_input_value!("i'm not even a time zone"),
graphql_input_value!(2.32),
graphql_input_value!(1),
graphql_input_value!(null),
graphql_input_value!(false),
] {
let input: InputValue = input;
let parsed = TimeZone::from_input_value(&input);
assert!(parsed.is_err(), "allows input: {input:?}");
}
}
#[test]
fn formats_correctly() {
for (val, expected) in [
(
TimeZone::try_from(tz::TimeZone::get("Europe/London").unwrap()).unwrap(),
graphql_input_value!("Europe/London"),
),
(
TimeZone::try_from(tz::TimeZone::get("Etc/GMT-3").unwrap()).unwrap(),
graphql_input_value!("Etc/GMT-3"),
),
(
TimeZone::try_from(tz::TimeZone::get("etc/gmt+11").unwrap()).unwrap(),
graphql_input_value!("Etc/GMT+11"),
),
(
TimeZone::try_from(tz::TimeZone::get("Factory").unwrap()).unwrap(),
graphql_input_value!("Factory"),
),
(
TimeZone::try_from(tz::TimeZone::get("zulu").unwrap()).unwrap(),
graphql_input_value!("Zulu"),
),
(
TimeZone::try_from(tz::TimeZone::fixed(tz::offset(0))).unwrap(),
graphql_input_value!("UTC"),
),
(
TimeZone::try_from(tz::TimeZone::get("UTC").unwrap()).unwrap(),
graphql_input_value!("UTC"),
),
(
TimeZone::try_from(tz::TimeZone::UTC).unwrap(),
graphql_input_value!("UTC"),
),
] {
let actual: InputValue = val.to_input_value();
assert_eq!(actual, expected, "on value: {val:?}");
}
}
}
#[cfg(test)]
mod utc_offset_test {
use jiff::tz;
use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
use super::UtcOffset;
#[test]
fn parses_correct_input() {
for (raw, expected) in [
("+00:00", tz::offset(0)),
("+03:00", tz::offset(3)),
("-09:00", tz::offset(-9)),
] {
let input: InputValue = graphql_input_value!((raw));
let parsed = UtcOffset::from_input_value(&input);
assert!(
parsed.is_ok(),
"failed to parse `{raw}`: {:?}",
parsed.unwrap_err(),
);
assert_eq!(parsed.unwrap(), expected, "input: {raw}");
}
}
#[test]
fn fails_on_invalid_input() {
for input in [
graphql_input_value!("Europe/London"),
graphql_input_value!("Abc/Xyz"),
graphql_input_value!("8086"),
graphql_input_value!("AbcXyz"),
graphql_input_value!("Z"),
graphql_input_value!("i'm not even a time zone"),
graphql_input_value!(2.32),
graphql_input_value!(1),
graphql_input_value!(null),
graphql_input_value!(false),
] {
let input: InputValue = input;
let parsed = UtcOffset::from_input_value(&input);
assert!(parsed.is_err(), "allows input: {input:?}");
}
}
#[test]
fn formats_correctly() {
for (val, expected) in [
(tz::offset(0), graphql_input_value!("+00:00")),
(tz::offset(2), graphql_input_value!("+02:00")),
(tz::offset(-11), graphql_input_value!("-11:00")),
] {
let actual: InputValue = val.to_input_value();
assert_eq!(actual, expected, "on value: {val:?}");
}
}
}
#[cfg(test)] #[cfg(test)]
mod integration_test { mod integration_test {
use jiff::{civil, tz::TimeZone, ToSpan as _}; use jiff::{civil, tz, ToSpan as _};
use crate::{ use crate::{
execute, graphql_object, graphql_value, graphql_vars, execute, graphql_object, graphql_value, graphql_vars,
@ -745,7 +1513,9 @@ mod integration_test {
types::scalars::{EmptyMutation, EmptySubscription}, types::scalars::{EmptyMutation, EmptySubscription},
}; };
use super::{DateTime, Duration, LocalDate, LocalDateTime, LocalTime}; use super::{
DateTime, Duration, LocalDate, LocalDateTime, LocalTime, TimeZone, UtcOffset, ZonedDateTime,
};
#[tokio::test] #[tokio::test]
async fn serializes() { async fn serializes() {
@ -767,11 +1537,25 @@ mod integration_test {
fn date_time() -> DateTime { fn date_time() -> DateTime {
civil::DateTime::constant(2014, 11, 28, 12, 0, 9, 50_000_000) civil::DateTime::constant(2014, 11, 28, 12, 0, 9, 50_000_000)
.to_zoned(TimeZone::UTC) .to_zoned(tz::TimeZone::UTC)
.unwrap() .unwrap()
.timestamp() .timestamp()
} }
fn zoned_date_time() -> ZonedDateTime {
civil::DateTime::constant(2014, 11, 28, 12, 0, 9, 50_000_000)
.to_zoned(tz::TimeZone::get("America/New_York").unwrap())
.unwrap()
}
fn time_zone() -> TimeZone {
tz::TimeZone::get("Asia/Tokyo").unwrap().try_into().unwrap()
}
fn utc_offset() -> UtcOffset {
tz::offset(10)
}
fn duration() -> Duration { fn duration() -> Duration {
1.year() 1.year()
.months(1) .months(1)
@ -788,6 +1572,9 @@ mod integration_test {
localTime localTime
localDateTime localDateTime
dateTime, dateTime,
zonedDateTime,
timeZone,
utcOffset,
duration, duration,
}"#; }"#;
@ -805,6 +1592,9 @@ mod integration_test {
"localTime": "16:07:08", "localTime": "16:07:08",
"localDateTime": "2016-07-08T09:10:11", "localDateTime": "2016-07-08T09:10:11",
"dateTime": "2014-11-28T12:00:09.05Z", "dateTime": "2014-11-28T12:00:09.05Z",
"zonedDateTime": "2014-11-28T12:00:09.05-05:00[America/New_York]",
"timeZone": "Asia/Tokyo",
"utcOffset": "+10:00",
"duration": "P1y1m1dT1h1m1.1s", "duration": "P1y1m1dT1h1m1.1s",
}), }),
vec![], vec![],