Nextime is a library for Scala that helps you figure out the "next time" something should occur by providing an easy and type safe way to work with cron expressions.
In your build.sbt
file, add the following:
libraryDependencies += "io.kleisli" %% "nextime" % "version"
And then simply import nextime into your project:
import nextime._
import nextime._
val minute = Minute(0)
val hour = Hour(3)
val dayOfMonth = DayOfMonth(11)
val month = Month(4)
val dayOfWeek = DayOfWeek(?)
Cron(minute, hour, dayOfMonth, month, dayOfWeek) // Either[nextime.Error, Cron]
You can think of cron expressions as being made up of several multipart expressions (ie. second, minute, hour, etc) which in turn are made up of 1 or more part expressions.
The Cron
type uses smart constructors to create instances of Either[nextime.Error, Cron]
to account for the
possibility of errors in the cron expression:
val cron: Either[nextime.Error, Cron] = Cron(minute, hour, dayOfMonth, month, dayOfWeek)
Only 3 permutations of multipart expressions are supported in a cron expression:
Permutation | Second | Minute | Hour | Day of Month | Month | Day of Week | Year |
---|---|---|---|---|---|---|---|
#1 | ✓ | ✓ | ✓ | ✓ | ✓ | ||
#2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
#3 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
You can also build a Cron
expression directly from a string representation using a constructor:
val cron: Either[nextime.Error, Cron] = Cron("0 3 11 4 ?")
As well as the cron
string interpolator:
val cron: Either[nextime.Error, Cron] = cron"0 3 11 4 ?"
And you can get back the string representation of a cron expression using mkString
:
cron.map(_.mkString) // Right("0 3 11 4 ?")
If you're feeling adventurous, you can use the unsafe
version of these constructors which will return a Cron
value
directly:
val cron1: Cron = Cron.unsafe(minute, hour, dayOfMonth, month, dayOfWeek)
val cron2: Cron = Cron.unsafe("0 3 11 4 ?")
val cron3: Cron = ucron"0 3 11 4 ?"
Just be careful as the unsafe
constructors will throw exceptions when a cron expression is invalid:
scala> Cron.unsafe("0 3 -11 4 ?")
nextime.Error$UniqueError:
{
"message" : "Invalid cron expression",
"cause" : {
"message" : "Invalid day of month expression",
"cause" : {
"message" : "Numeric values must be between 1 and 31",
"cause" : "-11 is out of bounds"
}
}
}
at nextime.Error$.apply(Error.scala:53)
at nextime.Error$.apply(Error.scala:45)
...
Read more about cron errors in the section below.
A multipart expression is one of Second
, Minute
, Hour
, DayOfMonth
, Month
, DayOfWeek
, and Year
. When you
use them together in one of the predefined permutations, you can create a Cron
expression:
Cron(Minute(...), Hour(...), DayOfMonth(...), Month(...), DayOfWeek(...))
Multipart expressions are made up of at least 1 or more part expressions, hence why they are "multipart". And just like
with Cron
expressions, multipart expressions use smart constructors to encapsulate the possibility of having errors:
val second: Either[nextime.Error, Second] = Second(*)
val minute: Either[nextime.Error, Minute] = Minute(3, 21, 56)
val hour: Either[nextime.Error, Hour] = Hour(17)
In addition, you can build multipart expressions from their string representation using a constructor:
val second: Either[nextime.Error, Second] = Second("*")
val minute: Either[nextime.Error, Minute] = Minute("3,21,56")
val hour: Either[nextime.Error, Hour] = Hour("17")
As well as their respective string interpolators (sec
, min
, hr
, dom
, mon
, dow
, yr
):
val second: Either[nextime.Error, Second] = sec"*"
val minute: Either[nextime.Error, Minute] = min"3,21,56"
val hour: Either[nextime.Error, Hour] = hr"17"
The parts that you can use in a multipart expression vary. Every multipart expression has a lower and upper bounds on the values that you're allowed to use and not all of them support the same part expressions:
Multipart Expression | Allowed Values | Allowed Parts (where X is a numeric value) |
---|---|---|
Second | 0-59 |
* - / |
Minute | 0-59 |
* - / |
Hour | 0-23 |
* - / |
Day of Month | 1-31 |
* - / ? L L-X XW LW |
Month | 1-12 or JAN-DEC |
* - / |
Day of Week | 1-7 or SUN-SAT |
* - / ? L XL X#X |
Year | 1970-2099 |
* - / |
Much like Cron
, multipart expressions provide unsafe
constructors to build instances directly:
val second: Second = Second.unsafe(*)
val minute: Minute = Minute.unsafe("3,21,56")
val hour: Hour = Hour.unsafe(17)
val dayOfMonth: DayOfMonth = udom"11"
And if used on an invalid multipart expression, an exception will be thrown:
scala> Hour.unsafe(-3)
nextime.Error$UniqueError:
{
"message" : "Invalid hour expression",
"cause" : {
"message" : "Numeric values must be between 0 and 23",
"cause" : "-3 is out of bounds"
}
}
at nextime.Error$.apply(Error.scala:53)
at nextime.Error$.apply(Error.scala:45)
...
Part expressions are the lowest level building block for cron expressions. Nextime supports all of the same expressions as the Java Quartz library through a series of custom types. It also provides shorthand versions of these types for convenience.
Symbol | Type | Shorthand | English |
---|---|---|---|
* | All |
* |
All possible values for the given field |
3 | Value(3) |
3 |
The value 3 |
1-3 | Range(1, 3) |
1 ~- 3 |
The values 1, 2, and 3 |
1/3 /3 |
Increment(1, 3) Increment(3) |
1 ~/ 3 ~/(3) |
The values 1, 4, 7, ... up to the upper bound of the given field. The values 0, 3, 6, ... up to the upper bound of the given field |
? | NoValue |
? |
Ignore the day-of-month OR the day-of-week but NOT both |
L | Last |
L |
The last day of the month OR the last day of the week (Saturday) |
3L | LastDayOfMonth(3) |
3.L |
The last Tuesday of the month |
L-3 | LastOffset(3) |
L-3 |
The third to last day of the month |
3W | Weekday(3) |
3.W |
The nearest weekday to the 3rd of the month |
LW | LastWeekday |
LW |
The last weekday of the month |
1#3 | NthXDayOfMonth(1, 3) |
1 ~# 3 |
The third Sunday (1 = Sunday) of the month |
Nextime supports a couple of ways to work with time using cron expressions.
Given a Cron
expression and a particular point in time:
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import nextime._
val cron = Cron("0 0 3 11 4 ? *")
val dateTime = new DateTime(2018, 4, 11, 3, 0, DateTimeZone.forID("America/Los_Angeles"))
You can use the next
method to find the next time it will be triggered:
cron.map(_.next(dateTime)) // Right(Some(2019-04-11T03:00:00.000-07:00))
Similarly, you can use the previous
method to find the last time that a cron expression was triggered:
cron.map(_.previous(dateTime)) // Right(Some(2017-04-11T03:00:00.000-07:00))
You'll notice that the return type for both was an Option[DateTime]
which represents the possibility that there are no
past or future date times that match the cron expression:
val cron = Cron("0 0 3 11 4 ? 2018")
cron.map(_.next(dateTime)) // Right(None)
cron.map(_.previous(dateTime)) // Right(None)
Note that currently Nextime only supports DateTime
values from the Joda Time library. Future enhancements will extend
this to support other date types.
Most of the time you're able to use types to effectively figure out which multipart expression permutations are
supported by a Cron
expression as well as which part expressions are supported by a particular multipart expression.
For example, only DayOfMonth
supports the LastWeekday
part expression and using LastWeekday
for any other
multipart expression results in a compilation error:
scala> DayOfMonth(LW)
res0: Either[nextime.Error,nextime.DayOfMonth] = Right(DayOfMonth(List(LastWeekday)))
scala> Hour(LW)
<console>:15: error: overloaded method value apply with alternatives:
(parts: List[nextime.HourPart])Either[nextime.Error,nextime.Hour] <and>
(head: nextime.HourPart,tail: nextime.HourPart*)Either[nextime.Error,nextime.Hour] <and>
(hourExpression: String)Either[nextime.Error,nextime.Hour]
cannot be applied to (nextime.LastWeekday.type)
Hour(LW)
^
For the times when compilation checks aren't enough, Nextime provides an intricate error system that is used to describe issues that your cron expression and its sub-expressions may have.
You can think of nextime.Error
as a recursive structure consisting of a main message describing the error along with 1
or more causes of the error. Nextime uses the Circe library to print the errors in JSON format for easier inspection.
As an example, let's take a look at an error generated from an invalid Minute
expression:
scala> Minute(-3)
res0: Either[nextime.Error,nextime.Minute] =
Left(nextime.Error$UniqueError:
{
"message" : "Invalid minute expression",
"cause" : {
"message" : "Numeric values must be between 0 and 59",
"cause" : "-3 is out of bounds"
}
})
Here we see that the top-level error is that we tried to create an invalid minute expression. If you look the cause of
the error, you will notice that it was due to an invalid numeric value (-3
) which is out of bounds for the range of
values supported by the minute expression (0-59
).
It's possible to have several errors in our expression. When this happens, all of the causes for the same type of error are aggregated together.
Here we have a Minute
expression containing 2 numeric values and 1 range value:
scala> Minute(-3, -46, 5 ~- 78)
res0: Either[nextime.Error,nextime.Minute] =
Left(nextime.Error$AggregateError:
{
"message" : "Invalid minute expression",
"cause" : [
{
"message" : "Numeric values must be between 0 and 59",
"cause" : [
"-46 is out of bounds",
"-3 is out of bounds"
]
},
{
"message" : "Range lower and upper values must be between 0 and 59",
"cause" : "78 is out of bounds"
}
]
})
Both numeric values are negative which makes them invalid. Since both are the same type of error, in this case an invalid numeric expression, their causes are aggregated together into a list. The range expression is also invalid because it's out of bounds. However, since this error is different from the first one (ie. it's an error having to do with an invalid range expression), it is treated as a separate type of error entirely and is thus not aggregated with the rest.
When building a Cron
expression, it's possible for 1 or more of the multipart expressions that make up the cron
expression to have errors in them. If this happens, Nextime returns all of the multipart errors together:
scala> Cron(Minute(-3, -46, 5 ~- 78), Hour(1, 2, 3), DayOfMonth(-11), Month(0, 4, 15), DayOfWeek(?))
res0: Either[nextime.Error,nextime.Cron] =
Left(nextime.Error$AggregateError:
{
"message" : "Invalid cron expression",
"cause" : [
{
"message" : "Invalid day of month expression",
"cause" : {
"message" : "Numeric values must be between 1 and 31",
"cause" : "-11 is out of bounds"
}
},
{
"message" : "Invalid month expression",
"cause" : {
"message" : "Numeric values must be between 1 and 12",
"cause" : [
"0 is out of bounds",
"15 is out of bounds"
]
}
},
{
"message" : "Invalid minute expression",
"cause" : [
{
"message" : "Numeric values must be between 0 and 59",
"cause" : [
"-46 is out of bounds",
"-3 is out of bounds"
]
},
{
"message" : "Range lower and upper values must be between 0 and 59",
"cause" : "78 is out of bounds"
}
]
}
]
})
Nextime does this by using a Semigroupal
to group all of the errors together which makes it much more convenient than
having to constantly fix and recompile each individual error until there are no more.