Skip to content

Commit

Permalink
Date helper datetime support (#69) (#129)
Browse files Browse the repository at this point in the history
* DateHelper DateTime Support

* Add mix_test_watch as dev dep

* Simplify date_helper with extracted add/3 and days_in_year/1

* Handle daylight savings start and end calculations

* Fix formatting issues

* Use DateTime.from_naive/2 to determine ambiguity

* Remove private as dep

* Reinstate date type in DateChecker

* Replace all DateHelper.date with DateChecker.date in date_checker

* Change to using if, instead of case, in date_helper.add/3

* Fix inc_year/1 and dec_year/1 in date_helper

* Move time zone database setup to config.exs

* Replace all :calendar.day_of_the_week with Date.day_of_week in date_helper

* Simplify DateHelper.dec_month/1

* Address lingering issues from code review

---------

Co-authored-by: Jonatan Männchen <jonatan@maennchen.ch>
  • Loading branch information
shaolang and maennchen committed Feb 13, 2024
1 parent 9122b11 commit ad3d7e4
Show file tree
Hide file tree
Showing 5 changed files with 304 additions and 87 deletions.
3 changes: 3 additions & 0 deletions config/config.exs
@@ -0,0 +1,3 @@
import Config

config :elixir, :time_zone_database, Tz.TimeZoneDatabase
8 changes: 6 additions & 2 deletions lib/crontab/date_checker.ex
Expand Up @@ -27,7 +27,8 @@ defmodule Crontab.DateChecker do
true
"""
@spec matches_date?(cron_expression :: CronExpression.t(), date :: date) :: boolean | no_return
@spec matches_date?(cron_expression :: CronExpression.t(), date :: date) ::
boolean | no_return
def matches_date?(cron_expression_or_condition_list, date)

def matches_date?(%CronExpression{reboot: true}, _),
Expand All @@ -39,7 +40,10 @@ defmodule Crontab.DateChecker do
|> matches_date?(execution_date)
end

@spec matches_date?(condition_list :: CronExpression.condition_list(), date :: date) :: boolean
@spec matches_date?(
condition_list :: CronExpression.condition_list(),
date :: date
) :: boolean
def matches_date?([], _), do: true

def matches_date?([{interval, conditions} | tail], execution_date) do
Expand Down
258 changes: 176 additions & 82 deletions lib/crontab/date_helper.ex
Expand Up @@ -3,10 +3,12 @@ defmodule Crontab.DateHelper do

@type unit :: :year | :month | :day | :hour | :minute | :second | :microsecond

@type date :: NaiveDateTime.t() | DateTime.t()

@units [
{:year, {nil, nil}},
{:month, {1, 12}},
{:day, {1, :end_onf_month}},
{:day, {1, :end_of_month}},
{:hour, {0, 23}},
{:minute, {0, 59}},
{:second, {0, 59}},
Expand All @@ -21,8 +23,11 @@ defmodule Crontab.DateHelper do
iex> Crontab.DateHelper.beginning_of(~N[2016-03-14 01:45:45.123], :year)
~N[2016-01-01 00:00:00]
iex> Crontab.DateHelper.beginning_of(~U[2016-03-14 01:45:45.123Z], :year)
~U[2016-01-01 00:00:00Z]
"""
@spec beginning_of(NaiveDateTime.t(), unit) :: NaiveDateTime.t()
@spec beginning_of(date, unit :: unit) :: date when date: date
def beginning_of(date, unit) do
_beginning_of(date, proceeding_units(unit))
end
Expand All @@ -35,120 +40,194 @@ defmodule Crontab.DateHelper do
iex> Crontab.DateHelper.end_of(~N[2016-03-14 01:45:45.123], :year)
~N[2016-12-31 23:59:59.999999]
iex> Crontab.DateHelper.end_of(~U[2016-03-14 01:45:45.123Z], :year)
~U[2016-12-31 23:59:59.999999Z]
"""
@spec end_of(NaiveDateTime.t(), unit) :: NaiveDateTime.t()
@spec end_of(date, unit :: unit) :: date when date: date
def end_of(date, unit) do
_end_of(date, proceeding_units(unit))
end

@doc """
Find the last occurrence of weekday in month.
Find last occurrence of weekday in month
### Examples:
iex> Crontab.DateHelper.last_weekday(~N[2016-03-14 01:45:45.123], 6)
26
iex> Crontab.DateHelper.last_weekday(~U[2016-03-14 01:45:45.123Z], 6)
26
"""
@spec last_weekday(NaiveDateTime.t(), Calendar.day_of_week()) :: Calendar.day()
@spec last_weekday(date :: date, day_of_week :: Calendar.day_of_week()) :: Calendar.day()
def last_weekday(date, weekday) do
date
|> end_of(:month)
|> last_weekday(weekday, :end)
end

@doc """
Find the nth weekday of month.
Find nth weekday of month
### Examples:
iex> Crontab.DateHelper.nth_weekday(~N[2016-03-14 01:45:45.123], 6, 2)
12
iex> Crontab.DateHelper.nth_weekday(~U[2016-03-14 01:45:45.123Z], 6, 2)
12
"""
@spec nth_weekday(NaiveDateTime.t(), Calendar.day_of_week(), integer) :: Calendar.day()
@spec nth_weekday(date :: date, weekday :: Calendar.day_of_week(), n :: pos_integer) ::
Calendar.day()
def nth_weekday(date, weekday, n) do
date
|> beginning_of(:month)
|> nth_weekday(weekday, n, :start)
end

@doc """
Find the last occurrence of weekday in month.
Find last occurrence of weekday in month
### Examples:
iex> Crontab.DateHelper.last_weekday_of_month(~N[2016-03-14 01:45:45.123])
31
iex> Crontab.DateHelper.last_weekday_of_month(~U[2016-03-14 01:45:45.123Z])
31
"""
@spec last_weekday_of_month(NaiveDateTime.t()) :: Calendar.day()
def last_weekday_of_month(date) do
last_weekday_of_month(end_of(date, :month), :end)
end
@spec last_weekday_of_month(date :: date()) :: Calendar.day()
def last_weekday_of_month(date), do: last_weekday_of_month(end_of(date, :month), :end)

@doc """
Find the next occurrence of weekday relative to date.
Find next occurrence of weekday relative to date
### Examples:
iex> Crontab.DateHelper.next_weekday_to(~N[2016-03-14 01:45:45.123])
14
iex> Crontab.DateHelper.next_weekday_to(~U[2016-03-14 01:45:45.123Z])
14
"""
@spec next_weekday_to(NaiveDateTime.t()) :: Calendar.day()
def next_weekday_to(date = %NaiveDateTime{year: year, month: month, day: day}) do
weekday = :calendar.day_of_the_week(year, month, day)
next_day = NaiveDateTime.add(date, 1, :day)
previous_day = NaiveDateTime.add(date, -1, :day)
@spec next_weekday_to(date :: date) :: Calendar.day()
def next_weekday_to(date) do
weekday = Date.day_of_week(date)
next_day = add(date, 1, :day)
previous_day = add(date, -1, :day)

cond do
weekday == 7 && next_day.month == date.month -> next_day.day
weekday == 7 -> NaiveDateTime.add(date, -2, :day).day
weekday == 7 -> add(date, -2, :day).day
weekday == 6 && previous_day.month == date.month -> previous_day.day
weekday == 6 -> NaiveDateTime.add(date, 2, :day).day
weekday == 6 -> add(date, 2, :day).day
true -> date.day
end
end

@spec inc_year(NaiveDateTime.t()) :: NaiveDateTime.t()
def inc_year(date) do
leap_year? =
date
|> NaiveDateTime.to_date()
|> Date.leap_year?()
@doc """
Increment Year
if leap_year? do
NaiveDateTime.add(date, 366, :day)
else
NaiveDateTime.add(date, 365, :day)
end
### Examples:
iex> Crontab.DateHelper.inc_year(~N[2016-03-14 01:45:45.123])
~N[2017-03-14 01:45:45.123]
iex> Crontab.DateHelper.inc_year(~U[2016-03-14 01:45:45.123Z])
~U[2017-03-14 01:45:45.123Z]
"""
@spec inc_year(date) :: date when date: date
def inc_year(date = %{month: 2, day: 29}), do: add(date, 365, :day)

def inc_year(date = %{month: month}) do
candidate = add(date, 365, :day)
date_leap_year_before_mar? = Date.leap_year?(date) and month < 3
candidate_leap_year_after_feb? = Date.leap_year?(candidate) and month > 2
adjustment = if candidate_leap_year_after_feb? or date_leap_year_before_mar?, do: 1, else: 0
add(candidate, adjustment, :day)
end

@spec dec_year(NaiveDateTime.t()) :: NaiveDateTime.t()
def dec_year(date) do
leap_year? =
date
|> NaiveDateTime.to_date()
|> Date.leap_year?()
@doc """
Decrement Year
if leap_year? do
NaiveDateTime.add(date, -366, :day)
else
NaiveDateTime.add(date, -365, :day)
end
### Examples:
iex> Crontab.DateHelper.dec_year(~N[2016-03-14 01:45:45.123])
~N[2015-03-14 01:45:45.123]
iex> Crontab.DateHelper.dec_year(~U[2016-03-14 01:45:45.123Z])
~U[2015-03-14 01:45:45.123Z]
"""
@spec dec_year(date) :: date when date: date
def dec_year(date = %{month: 2, day: 29}), do: add(date, -366, :day)

def dec_year(date = %{month: month}) do
candidate = add(date, -365, :day)
date_leap_year_after_mar? = Date.leap_year?(date) and month > 2
candidate_leap_year_before_feb? = Date.leap_year?(candidate) and month < 3
adjustment = if date_leap_year_after_mar? or candidate_leap_year_before_feb?, do: -1, else: 0
add(candidate, adjustment, :day)
end

@spec inc_month(NaiveDateTime.t()) :: NaiveDateTime.t()
def inc_month(date = %NaiveDateTime{day: day}) do
@doc """
Increment Month
### Examples:
iex> Crontab.DateHelper.inc_month(~N[2016-03-14 01:45:45.123])
~N[2016-04-01 01:45:45.123]
iex> Crontab.DateHelper.inc_month(~U[2016-03-14 01:45:45.123Z])
~U[2016-04-01 01:45:45.123Z]
"""
@spec inc_month(date) :: date when date: date
def inc_month(date = %{year: year, month: month, day: day}) do
days =
date
|> NaiveDateTime.to_date()
Date.new!(year, month, day)
|> Date.days_in_month()

NaiveDateTime.add(date, days + 1 - day, :day)
add(date, days + 1 - day, :day)
end

@spec dec_month(NaiveDateTime.t()) :: NaiveDateTime.t()
def dec_month(date) do
days =
date
|> NaiveDateTime.to_date()
|> Date.days_in_month()
@doc """
Decrement Month
### Examples:
iex> Crontab.DateHelper.dec_month(~N[2016-03-14 01:45:45.123])
~N[2016-02-14 01:45:45.123]
NaiveDateTime.add(date, -days, :day)
iex> Crontab.DateHelper.dec_month(~U[2016-03-14 01:45:45.123Z])
~U[2016-02-14 01:45:45.123Z]
iex> Crontab.DateHelper.dec_month(~N[2011-05-31 23:59:59])
~N[2011-04-30 23:59:59]
"""
@spec dec_month(date) :: date when date: date
def dec_month(date = %{year: year, month: month, day: day}) do
days_in_last_month = Date.new!(year, month, 1) |> Date.add(-1) |> Date.days_in_month()
add(date, -(day + max(days_in_last_month - day, 0)), :day)
end

@spec _beginning_of(NaiveDateTime.t(), [{unit, {any, any}}]) :: NaiveDateTime.t()
@spec _beginning_of(date, [{unit, {any, any}}]) :: date when date: date
defp _beginning_of(date, [{unit, {lower, _}} | tail]) do
_beginning_of(Map.put(date, unit, lower), tail)
end

defp _beginning_of(date, []), do: date

@spec _end_of(NaiveDateTime.t(), [{unit, {any, any}}]) :: NaiveDateTime.t()
defp _end_of(date, [{unit, {_, :end_onf_month}} | tail]) do
upper =
date
|> NaiveDateTime.to_date()
|> Date.days_in_month()

@spec _end_of(date, [{unit, {any, any}}]) :: date when date: date
defp _end_of(date, [{unit, {_, :end_of_month}} | tail]) do
upper = Date.days_in_month(date)
_end_of(Map.put(date, unit, upper), tail)
end

Expand All @@ -165,7 +244,7 @@ defmodule Crontab.DateHelper do
|> Enum.reduce([], fn {key, value}, acc ->
cond do
Enum.count(acc) > 0 ->
Enum.concat(acc, [{key, value}])
[{key, value} | acc]

key == unit ->
[{key, value}]
Expand All @@ -174,39 +253,54 @@ defmodule Crontab.DateHelper do
[]
end
end)
|> Enum.reverse()

units
end

@spec nth_weekday(NaiveDateTime.t(), Calendar.day_of_week(), :start) :: boolean
defp nth_weekday(date = %NaiveDateTime{}, _, 0, :start),
do: NaiveDateTime.add(date, -1, :day).day
@spec nth_weekday(date :: date, weekday :: Calendar.day_of_week(), position :: :start) ::
boolean
defp nth_weekday(date, _, 0, :start), do: add(date, -1, :day).day

defp nth_weekday(date = %NaiveDateTime{year: year, month: month, day: day}, weekday, n, :start) do
if :calendar.day_of_the_week(year, month, day) == weekday do
nth_weekday(NaiveDateTime.add(date, 1, :day), weekday, n - 1, :start)
else
nth_weekday(NaiveDateTime.add(date, 1, :day), weekday, n, :start)
end
defp nth_weekday(date, weekday, n, :start) do
modifier = if Date.day_of_week(date) == weekday, do: n - 1, else: n
nth_weekday(add(date, 1, :day), weekday, modifier, :start)
end

@spec last_weekday_of_month(NaiveDateTime.t(), :end) :: Calendar.day()
defp last_weekday_of_month(date = %NaiveDateTime{year: year, month: month, day: day}, :end) do
weekday = :calendar.day_of_the_week(year, month, day)

if weekday > 5 do
last_weekday_of_month(NaiveDateTime.add(date, -1, :day), :end)
@spec last_weekday_of_month(date :: date(), position :: :end) :: Calendar.day()
defp last_weekday_of_month(date = %{day: day}, :end) do
if Date.day_of_week(date) > 5 do
last_weekday_of_month(add(date, -1, :day), :end)
else
day
end
end

@spec last_weekday(NaiveDateTime.t(), non_neg_integer, :end) :: Calendar.day()
defp last_weekday(date = %NaiveDateTime{year: year, month: month, day: day}, weekday, :end) do
if :calendar.day_of_the_week(year, month, day) == weekday do
@spec last_weekday(date :: date, weekday :: Calendar.day_of_week(), position :: :end) ::
Calendar.day()
defp last_weekday(date = %{day: day}, weekday, :end) do
if Date.day_of_week(date) == weekday do
day
else
last_weekday(NaiveDateTime.add(date, -1, :day), weekday, :end)
last_weekday(add(date, -1, :day), weekday, :end)
end
end

@doc false
def add(datetime = %NaiveDateTime{}, amt, unit), do: NaiveDateTime.add(datetime, amt, unit)

def add(datetime = %DateTime{}, amt, unit) do
candidate = DateTime.add(datetime, amt, unit)
adjustment = datetime.std_offset - candidate.std_offset
adjusted = DateTime.add(candidate, adjustment, :second)

if adjusted.std_offset != candidate.std_offset do
candidate
else
case DateTime.from_naive(DateTime.to_naive(adjusted), adjusted.time_zone) do
{:ambiguous, _, target} -> target
{:ok, target} -> target
end
end
end
end

0 comments on commit ad3d7e4

Please sign in to comment.