Skip to content

Commit

Permalink
feat: implement optimized Serialize and Deserialize for Schedule (
Browse files Browse the repository at this point in the history
#129)

* Add Serde compatiblity for type Schedule
* fix: impl Deserialize behind feature gate
* feat: implement optimized `Serialize` and `Deserialize` for `Schedule`

Serialization:

Serialization uses a direct reference into the
`Schedule::source`.

Previously, the blanket implementation of
`ToString::to_string` had been used,
which unnecessarily allocated a string
and leveraged `format!` machinery.

Deserialization:

Deserialization suggests the `Deserializer`
to provide an owned `String` to the visitor,
as the `Schedule` will take ownership of the
`String` after successful decoding.

`Deserializer`s not incapable of providing
owned `String`s may pass a `&str`, which is
decoded, then cloned into an owned `String`
for storage in `Schedule::source`.

Public API changes:

Beside implementing `Serialize` and `Deserialize`
behind the `"serde"` feature flag,
this changeset also adds a new public method
`Schedule::source(&self) -> &str`.

This new public method is generally useful
and therefore not guarded by the `"serde"`
feature flag.

* feat(ci): run tests for default features and all features separately

* style: normalize serde tests

- normalize test names,
  indicating token verification
- [lowercase][C-GOOD-ERR] failure messages

[C-GOOD-ERR]: https://rust-lang.github.io/api-guidelines/interoperability.html?highlight=lowercase#error-types-are-meaningful-and-well-behaved-c-good-err

* test: e2e test deserialized schedules with binary format

This changeset implements serde serialization
and deserialization tests beyond
the token representation tests provided
by `serde_test`.

Schedules are serialized to a binary format
([`postcard`][postcard]), then deserialized
and used to yield events following a given date
and time. These events are compared to a
limited set of restricted upcoming events.

The tests cover shorthand as well as
ranged period cron schedule notation.

[postcard]: https://docs.rs/postcard/

---------

Co-authored-by: Max Huster <maxekinge@googlemail.com>
  • Loading branch information
LeoniePhiline and max-huster authored Oct 29, 2024
1 parent bc07787 commit c5d5589
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 4 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ jobs:
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run tests
- name: Run tests (default features)
run: cargo test --verbose
- name: Run tests (all features)
run: cargo test --verbose --all-features
- name: Build docs
run: cargo doc --no-deps --verbose
12 changes: 12 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ name = "cron"
chrono = { version = "~0.4", default-features = false, features = ["clock"] }
nom = "~7"
once_cell = "1.10"
serde = {version = "1.0.164", optional = true }

[dev-dependencies]
chrono-tz = "~0.6"
serde_test = "1.0.164"

# Dev-dependency for feature "serde".
# Optional dev-dependencies are not supported yet.
# Cargo feature request is available at https://github.com/rust-lang/cargo/issues/1596
postcard = { version = "1.0.10", default-features = false, features = ["use-std"] }

[features]
serde = ["dep:serde"]


147 changes: 144 additions & 3 deletions src/schedule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ use chrono::{DateTime, Datelike, Timelike, Utc};
use std::fmt::{Display, Formatter, Result as FmtResult};
use std::ops::Bound::{Included, Unbounded};

#[cfg(feature = "serde")]
use core::fmt;
#[cfg(feature = "serde")]
use serde::{
de::{self, Visitor},
Deserialize, Serialize, Serializer,
};

use crate::ordinal::*;
use crate::queries::*;
use crate::time_unit::*;
Expand Down Expand Up @@ -351,6 +359,11 @@ impl Schedule {
pub fn timeunitspec_eq(&self, other: &Schedule) -> bool {
self.fields == other.fields
}

/// Returns a reference to the source cron expression.
pub fn source(&self) -> &str {
&self.source
}
}

impl Display for Schedule {
Expand Down Expand Up @@ -522,13 +535,143 @@ fn days_in_month(month: Ordinal, year: Ordinal) -> u32 {
}
}

#[cfg(feature = "serde")]
struct ScheduleVisitor;

#[cfg(feature = "serde")]
impl<'de> Visitor<'de> for ScheduleVisitor {
type Value = Schedule;

fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a valid cron expression")
}

// Supporting `Deserializer`s shall provide an owned `String`.
//
// The `Schedule` will decode from a `&str` to it,
// then store the owned `String` as `Schedule::source`.
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where
E: de::Error,
{
Schedule::try_from(v).map_err(de::Error::custom)
}

// `Deserializer`s not providing an owned `String`
// shall provide a `&str`.
//
// The `Schedule` will decode from the `&str`,
// then clone into the heap to store as an owned `String`
// as `Schedule::source`.
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Schedule::try_from(v).map_err(de::Error::custom)
}
}

#[cfg(feature = "serde")]
impl Serialize for Schedule {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.source())
}
}

#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for Schedule {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
// Hint that the `Deserialize` type `Schedule`
// would benefit from taking ownership of
// buffered data owned by the `Deserializer`:
//
// The deserialization "happy path" decodes from a `&str`,
// then stores the source as owned `String`.
//
// Thus, the optimized happy path receives an owned `String`
// if the `Deserializer` in use supports providing one.
deserializer.deserialize_string(ScheduleVisitor)
}
}

#[cfg(test)]
mod test {
use chrono::Duration;
#[cfg(feature = "serde")]
use serde_test::{assert_tokens, Token};

use super::*;
use std::str::FromStr;

#[cfg(feature = "serde")]
#[test]
fn test_ser_de_schedule_tokens() {
let schedule = Schedule::from_str("* * * * * * *").expect("valid format");
assert_tokens(&schedule, &[Token::String("* * * * * * *")])
}

#[cfg(feature = "serde")]
#[test]
fn test_invalid_ser_de_schedule_tokens() {
use serde_test::assert_de_tokens_error;

assert_de_tokens_error::<Schedule>(
&[Token::String(
"definitively an invalid value for a cron schedule!",
)],
"Invalid expression: Invalid cron expression.",
);
}

#[cfg(feature = "serde")]
#[test]
fn test_ser_de_schedule_shorthand() {
let serialized = postcard::to_stdvec(&Schedule::try_from("@hourly").expect("valid format"))
.expect("serializable schedule");

let schedule: Schedule =
postcard::from_bytes(&serialized).expect("deserializable schedule");

let starting_date = Utc.with_ymd_and_hms(2017, 2, 25, 22, 29, 36).unwrap();
assert!([
Utc.with_ymd_and_hms(2017, 2, 25, 23, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2017, 2, 26, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2017, 2, 26, 1, 0, 0).unwrap(),
]
.into_iter()
.eq(schedule.after(&starting_date).take(3)));
}

#[cfg(feature = "serde")]
#[test]
fn test_ser_de_schedule_period_values_range() {
let serialized =
postcard::to_stdvec(&Schedule::try_from("0 0 0 1-31/10 * ?").expect("valid format"))
.expect("serializable schedule");

let schedule: Schedule =
postcard::from_bytes(&serialized).expect("deserializable schedule");

let starting_date = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
assert!([
Utc.with_ymd_and_hms(2020, 1, 11, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2020, 1, 21, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2020, 1, 31, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2020, 2, 1, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2020, 2, 11, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2020, 2, 21, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2020, 3, 1, 0, 0, 0).unwrap(),
]
.into_iter()
.eq(schedule.after(&starting_date).take(7)));
}

#[test]
fn test_next_and_prev_from() {
let expression = "0 5,13,40-42 17 1 Jan *";
Expand Down Expand Up @@ -556,9 +699,7 @@ mod test {
#[test]
fn test_next_after_past_date_next_year() {
// Schedule after 2021-10-27
let starting_point = Utc
.with_ymd_and_hms(2021, 10, 27, 0, 0, 0)
.unwrap();
let starting_point = Utc.with_ymd_and_hms(2021, 10, 27, 0, 0, 0).unwrap();

// Triggers on 2022-06-01. Note that the month and day are smaller than
// the month and day in `starting_point`.
Expand Down

0 comments on commit c5d5589

Please sign in to comment.