From ea391b7a246d930c9d58c86b05c69616b2403da4 Mon Sep 17 00:00:00 2001 From: Gaylor Bosson Date: Sat, 16 Dec 2023 19:51:05 +0100 Subject: [PATCH] Support years --- README.md | 1 + cron.go | 8 ++------ cron_test.go | 40 ++++++++++++++++++++++++++++++++++++---- error.go | 3 ++- examples_test.go | 14 ++++++++++++++ parser.go | 13 ++++++++++++- parser_test.go | 8 ++++++++ time-unit.go | 25 +++++++++++++++++++++++-- 8 files changed, 98 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c9c646f..180f667 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,4 @@ This package implements the specification found in [Wikipedia](https://en.wikipe | Day of month | 1-31 | , - * ? / L | | Month | 1-12 | , - * / | | Day of week | 0-6 or SUN-SAT | , - * ? / L # | +| Years | 1-9999 | , - * / | diff --git a/cron.go b/cron.go index 8e40d37..3125c39 100644 --- a/cron.go +++ b/cron.go @@ -6,10 +6,6 @@ import ( "time" ) -const ( - maxYearAttempts = 100 -) - var defaultParser = cronParser{} // TimeUnit represents a single part of a Cron expression. @@ -51,7 +47,7 @@ func (s Schedule) Next(after time.Time) (next time.Time) { var ok bool for !ok { - if next.Year()-after.Year() > maxYearAttempts { + if next.IsZero() || next.Year() > rangeMaxYear { // Return a zero time when the expression is unable to find a proper // time after a given number of years. return time.Time{} @@ -84,7 +80,7 @@ func (s Schedule) Previous(before time.Time) (prev time.Time) { var ok bool for !ok { - if before.Year()-prev.Year() > maxYearAttempts { + if prev.IsZero() || prev.Year() > rangeMaxYear { return time.Time{} } prev, ok = s.prevBefore(prev) diff --git a/cron_test.go b/cron_test.go index 7f1404e..5b36223 100644 --- a/cron_test.go +++ b/cron_test.go @@ -106,6 +106,17 @@ func TestSchedule_Upcoming_returnsNextNthWeekdayOfMonth(t *testing.T) { testIterator(t, iter, expects) } +func TestSchedule_Upcoming_returnsNextYear(t *testing.T) { + iter := MustParse("0 0 0 1 6 ? 2010-2012").Upcoming(time.Date(2000, time.March, 15, 12, 5, 1, 0, time.UTC)) + expects := []string{ + "2010-06-01 00:00:00 +0000 UTC", + "2011-06-01 00:00:00 +0000 UTC", + "2012-06-01 00:00:00 +0000 UTC", + } + + testIterator(t, iter, expects) +} + func TestSchedule_Next_abortsExpressionWhichIsImpossible(t *testing.T) { schedule := MustParse("* * * 31 2 ?") @@ -149,11 +160,11 @@ func TestSchedule_Preceding_returnsPreviousHours(t *testing.T) { } func TestSchedule_Preceding_returnsPreviousDays(t *testing.T) { - iter := MustParse("0 0 0 28,31 * *").Preceding(time.Date(2000, time.March, 15, 12, 30, 0, 0, time.UTC)) + iter := MustParse("30 30 12 28,31 * *").Preceding(time.Date(2000, time.March, 15, 12, 30, 0, 0, time.UTC)) expects := []string{ - "2000-02-28 00:00:00 +0000 UTC", - "2000-01-31 00:00:00 +0000 UTC", - "2000-01-28 00:00:00 +0000 UTC", + "2000-02-28 12:30:30 +0000 UTC", + "2000-01-31 12:30:30 +0000 UTC", + "2000-01-28 12:30:30 +0000 UTC", } testIterator(t, iter, expects) @@ -179,6 +190,27 @@ func TestSchedule_Preceding_returnsPreviousWeekDays(t *testing.T) { testIterator(t, iter, expects) } +func TestSchedule_Preceding_returnsPreviousYear(t *testing.T) { + iter := MustParse("0 0 0 1 6 ? 1900/5").Preceding(time.Date(2003, time.March, 15, 12, 5, 1, 0, time.UTC)) + expects := []string{ + "2000-06-01 00:00:00 +0000 UTC", + "1995-06-01 00:00:00 +0000 UTC", + "1990-06-01 00:00:00 +0000 UTC", + } + + testIterator(t, iter, expects) +} + +func TestSchedule_Preceding_returnsPreviousYearInList(t *testing.T) { + iter := MustParse("30 30 12 1 6 ? 1901,1902").Preceding(time.Date(2003, time.March, 15, 12, 5, 1, 0, time.UTC)) + expects := []string{ + "1902-06-01 12:30:30 +0000 UTC", + "1901-06-01 12:30:30 +0000 UTC", + } + + testIterator(t, iter, expects) +} + func TestSchedule_Preceding_returnsPreviousInRange(t *testing.T) { iter := MustParse("0-1 * * * * *").Preceding(time.Date(2000, time.March, 15, 12, 30, 0, 0, time.UTC)) expects := []string{ diff --git a/error.go b/error.go index 2b06f66..3d11fcf 100644 --- a/error.go +++ b/error.go @@ -49,9 +49,10 @@ const ( Days Months WeekDays + Years ) -var kinds = []string{"seconds", "minutes", "hours", "days", "months", "week days"} +var kinds = []string{"seconds", "minutes", "hours", "days", "months", "week days", "years"} func (k TimeUnitKind) String() string { return kinds[int(k)%len(kinds)] diff --git a/examples_test.go b/examples_test.go index 878894e..22fa1a7 100644 --- a/examples_test.go +++ b/examples_test.go @@ -21,6 +21,20 @@ func ExampleSchedule_Next_everyFifteenSeconds() { // 2023-06-04 00:00:30 +0000 UTC } +func ExampleSchedule_Next_usingTimezone() { + schedule := gocron.MustParse("*/15 * * * * *") + + next := schedule.Next(time.Date(2023, time.June, 4, 0, 0, 0, 0, time.FixedZone("CEST", 120))) + fmt.Println(next) + + next = schedule.Next(next) + fmt.Println(next) + + // Output: + // 2023-06-04 00:00:15 +0002 CEST + // 2023-06-04 00:00:30 +0002 CEST +} + func ExampleSchedule_Next_everyLastFridayOfTheMonthAtMidnight() { schedule := gocron.MustParse("0 0 0 ? * 5L") diff --git a/parser.go b/parser.go index d52ecdb..998687d 100644 --- a/parser.go +++ b/parser.go @@ -8,11 +8,13 @@ import ( const ( minExprMatches = 6 - maxExprMatches = 6 + maxExprMatches = 7 rangeSplitSize = 2 intervalSplitSize = 2 nthSplitSize = 2 + rangeMinYear = 1 + rangeMaxYear = 9999 rangeMinWeekday = 0 rangeMaxWeekday = 6 rangeMinMonth = 1 @@ -74,6 +76,15 @@ func (p cronParser) Parse(expression string) (schedule Schedule, err error) { secTimeUnit(seconds), } + if len(matches) == maxExprMatches { + years, err := p.parse(matches[maxExprMatches-1], convertUnit, rangeMinYear, rangeMaxYear) + if err != nil { + return schedule, newTimeUnitErr(Years, err) + } + + schedule.timeUnits = append([]TimeUnit{yearTimeUnit(years)}, schedule.timeUnits...) + } + return schedule, err } diff --git a/parser_test.go b/parser_test.go index 99fc35a..c423ecf 100644 --- a/parser_test.go +++ b/parser_test.go @@ -85,6 +85,14 @@ func TestParser_Parse_abortsOnMalformedWeekDays(t *testing.T) { requireSameKind(t, e.kind, WeekDays) } +func TestParser_Parse_abortsOnMalformedYears(t *testing.T) { + _, err := defaultParser.Parse("* * * * * * a") + + var e TimeUnitError + requireErrorAs(t, err, &e) + requireSameKind(t, e.kind, Years) +} + func TestParser_Parse_abortsOnTooBigLastValue(t *testing.T) { _, err := defaultParser.Parse("* * * L-32 * *") requireErrorIs(t, err, ErrValueOutsideRange) diff --git a/time-unit.go b/time-unit.go index 8d9f350..e1e303a 100644 --- a/time-unit.go +++ b/time-unit.go @@ -171,6 +171,26 @@ func (u weekdayTimeUnit) Previous(before time.Time) (time.Time, bool) { return setDays(before, before.Day()-1), false } +type yearTimeUnit []timeSet + +func (u yearTimeUnit) Next(next time.Time) (time.Time, bool) { + candidate, ok := searchNextCandidate(u, next, time.Time.Year, setYears) + if ok { + return candidate, true + } + + return setYears(next, rangeMaxYear+1), false +} + +func (u yearTimeUnit) Previous(before time.Time) (time.Time, bool) { + candidate, ok := searchPrevCandidate(u, before, time.Time.Year, setYears) + if ok { + return candidate, true + } + + return setYears(before, rangeMinYear), false +} + type getterFunc[T int | time.Month] func(time.Time) T type setterFunc[T int | time.Month] func(time.Time, T) time.Time @@ -225,8 +245,9 @@ func searchCandidate[T int | time.Month]( return setter(t, T(slices.Min(candidates))), true } - // When iterating backwards, it uses the biggest candidate. - return setter(t, T(slices.Max(candidates))), true + // When iterating backwards, it uses the biggest candidate annd sets all + // the lower fields too their max value. + return setter(t, T(slices.Max(candidates))+1).Add(-time.Second), true } return time.Time{}, false