Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Last Day of Month ('L') #397

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ A cron expression represents a set of times, using 5 space-separated fields.
---------- | ---------- | -------------- | --------------------------
Minutes | Yes | 0-59 | * / , -
Hours | Yes | 0-23 | * / , -
Day of month | Yes | 1-31 | * / , - ?
Day of month | Yes | 1-31 | * / , - ? L
Month | Yes | 1-12 or JAN-DEC | * / , -
Day of week | Yes | 0-6 or SUN-SAT | * / , - ?

Expand Down Expand Up @@ -105,6 +105,9 @@ Question mark ( ? )
Question mark may be used instead of '*' for leaving either day-of-month or
day-of-week blank.

'L' stands for "last". When used in the day-of-month field, it specifies
the last day of the month.

Predefined schedules

You may use one of several pre-defined schedules in place of a cron expression.
Expand Down
12 changes: 12 additions & 0 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ func (p Parser) Parse(spec string) (Schedule, error) {
return nil, err
}

if fields[3] == "L" {
now := time.Now().In(loc)
fields[3] = strconv.Itoa(ldom(now))
}

field := func(field string, r bounds) uint64 {
if err != nil {
return 0
Expand Down Expand Up @@ -149,6 +154,7 @@ func (p Parser) Parse(spec string) (Schedule, error) {
Month: month,
Dow: dayofweek,
Location: loc,
CronExpr: spec,
}, nil
}

Expand Down Expand Up @@ -373,6 +379,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {
Month: 1 << months.min,
Dow: all(dow),
Location: loc,
CronExpr: descriptor,
}, nil

case "@monthly":
Expand All @@ -384,6 +391,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {
Month: all(months),
Dow: all(dow),
Location: loc,
CronExpr: descriptor,
}, nil

case "@weekly":
Expand All @@ -395,6 +403,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {
Month: all(months),
Dow: 1 << dow.min,
Location: loc,
CronExpr: descriptor,
}, nil

case "@daily", "@midnight":
Expand All @@ -406,6 +415,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {
Month: all(months),
Dow: all(dow),
Location: loc,
CronExpr: descriptor,
}, nil

case "@hourly":
Expand All @@ -417,6 +427,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {
Month: all(months),
Dow: all(dow),
Location: loc,
CronExpr: descriptor,
}, nil

}
Expand All @@ -432,3 +443,4 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {

return nil, fmt.Errorf("unrecognized descriptor: %s", descriptor)
}

107 changes: 86 additions & 21 deletions parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,17 +144,17 @@ func TestParseSchedule(t *testing.T) {
expr string
expected Schedule
}{
{secondParser, "0 5 * * * *", every5min(time.Local)},
{standardParser, "5 * * * *", every5min(time.Local)},
{secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC)},
{standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC)},
{secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)},
{secondParser, "0 5 * * * *", every5min(time.Local, "0 5 * * * *")},
{standardParser, "5 * * * *", every5min(time.Local, "5 * * * *")},
{secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC, "0 5 * * * *")},
{standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC, "5 * * * *")},
{secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo, "0 5 * * * *")},
{secondParser, "@every 5m", ConstantDelaySchedule{5 * time.Minute}},
{secondParser, "@midnight", midnight(time.Local)},
{secondParser, "TZ=UTC @midnight", midnight(time.UTC)},
{secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo)},
{secondParser, "@yearly", annual(time.Local)},
{secondParser, "@annually", annual(time.Local)},
{secondParser, "@midnight", midnight(time.Local, "@midnight")},
{secondParser, "TZ=UTC @midnight", midnight(time.UTC, "@midnight")},
{secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo, "@midnight")},
{secondParser, "@yearly", annual(time.Local, "@yearly")},
{secondParser, "@annually", annual(time.Local, "@annually")},
{
parser: secondParser,
expr: "* 5 * * * *",
Expand All @@ -166,6 +166,7 @@ func TestParseSchedule(t *testing.T) {
Month: all(months),
Dow: all(dow),
Location: time.Local,
CronExpr: "* 5 * * * *",
},
},
}
Expand All @@ -187,9 +188,9 @@ func TestOptionalSecondSchedule(t *testing.T) {
expr string
expected Schedule
}{
{"0 5 * * * *", every5min(time.Local)},
{"5 5 * * * *", every5min5s(time.Local)},
{"5 * * * *", every5min(time.Local)},
{"0 5 * * * *", every5min(time.Local, "0 5 * * * *")},
{"5 5 * * * *", every5min5s(time.Local, "5 5 * * * *")},
{"5 * * * *", every5min(time.Local, "5 * * * *")},
}

for _, c := range entries {
Expand Down Expand Up @@ -320,7 +321,7 @@ func TestStandardSpecSchedule(t *testing.T) {
}{
{
expr: "5 * * * *",
expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow), time.Local},
expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow), time.Local, "5 * * * *"},
},
{
expr: "@every 5m",
Expand Down Expand Up @@ -358,19 +359,19 @@ func TestNoDescriptorParser(t *testing.T) {
}
}

func every5min(loc *time.Location) *SpecSchedule {
return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
func every5min(loc *time.Location, spec string) *SpecSchedule {
return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc, spec}
}

func every5min5s(loc *time.Location) *SpecSchedule {
return &SpecSchedule{1 << 5, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
func every5min5s(loc *time.Location, spec string) *SpecSchedule {
return &SpecSchedule{1 << 5, 1 << 5, all(hours), all(dom), all(months), all(dow), loc, spec}
}

func midnight(loc *time.Location) *SpecSchedule {
return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc}
func midnight(loc *time.Location, spec string) *SpecSchedule {
return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc, spec}
}

func annual(loc *time.Location) *SpecSchedule {
func annual(loc *time.Location, spec string) *SpecSchedule {
return &SpecSchedule{
Second: 1 << seconds.min,
Minute: 1 << minutes.min,
Expand All @@ -379,5 +380,69 @@ func annual(loc *time.Location) *SpecSchedule {
Month: 1 << months.min,
Dow: all(dow),
Location: loc,
CronExpr: spec,
}
}

// Test Last Day of Month 'L'
func TestLDOM(t *testing.T) {
parser := NewParser(SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor)
tests := []struct {
time, spec string
expected bool
}{
{"Tue Jul 31 01:10:30 2021", "30 10 1 L * ?", true},
{"Tue Jul 31 00:00:00 2021", "0 0 0 L * *", true},
{"Mon Jan 31 00:00:00 2022", "30 10 1 L 1 ?", false},
}

for _, test := range tests {
sched, err := parser.Parse(test.spec)
if err != nil {
t.Error(err)
continue
}
actual := sched.Next(getTime(test.time).Add(-1 * time.Second))
expected := getTime(test.time)
if test.expected && expected != actual || !test.expected && expected == actual {
t.Errorf("Fail evaluating %s on %s: (expected) %s != %s (actual)",
test.spec, test.time, expected, actual)
}
}
}

// Test Next activation time for Last Day of Month 'L'
func TestLDOMNext(t *testing.T) {
parser := NewParser(SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor)
runs := []struct {
time, spec string
expected string
}{
{"TZ=America/New_York 2012-11-04T03:00:00-0500", "0 0 0 L * ?", "2012-11-30T00:00:00-0500"},
{"TZ=Asia/Kolkata 2021-12-16T12:00:00+0530", "30 10 1 L * ?", "2021-12-31T01:10:30+0530"},
{"2021-01-01T12:00:00+0000", "30 10 1 L * ?", "2021-01-31T01:10:30+0000"},
{"2021-01-31T12:00:00+0000", "0 50 5 L * ?", "2021-02-28T05:50:00+0000"},
{"2021-02-27T11:00:00+0000", "0 0 1 L * ?", "2021-02-28T01:00:00+0000"},
{"2024-01-31T10:40:10+0530", "40 10 10 L * ?", "2024-02-29T10:10:40+0530"},
{"2024-02-15T10:40:10+0530", "40 10 10 L * ?", "2024-02-29T10:10:40+0530"},
{"TZ=UTC 2020-01-31T23:00:00+0000", "0 0 9 L * ?", "2020-02-29T09:00:00+0000"},
{"2021-01-31T10:30:00+0000", "0 0 18 L * ?", "2021-01-31T18:00:00+0000"},
{"2024-01-30T12:00:00+0530", "0 55 23 L * *", "2024-01-31T23:55:00+0530"},
{"2024-01-31T00:00:00+0530", "0 30 2 L * *", "2024-01-31T02:30:00+0530"},
{"2024-01-30T23:59:00+0530", "0 0 0 L * *", "2024-01-31T00:00:00+0530"},
{"2024-01-31T23:59:00+0530", "0 0 0 L * *", "2024-02-29T00:00:00+0530"},
}

for _, c := range runs {
sched, err := parser.Parse(c.spec)
if err != nil {
t.Error(err)
continue
}
actual := sched.Next(getTime(c.time))
expected := getTime(c.expected)
if !actual.Equal(expected) {
t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual)
}
}
}
52 changes: 51 additions & 1 deletion spec.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cron

import "time"
import (
"strings"
"time"
)

// SpecSchedule specifies a duty cycle (to the second granularity), based on a
// traditional crontab specification. It is computed initially and stored as bit sets.
Expand All @@ -9,6 +12,9 @@ type SpecSchedule struct {

// Override location for this schedule.
Location *time.Location

// Cron Expression for this schedule
CronExpr string
}

// bounds provides a range of acceptable values (plus a map of name to value).
Expand Down Expand Up @@ -87,6 +93,18 @@ func (s *SpecSchedule) Next(t time.Time) time.Time {
// If no time is found within five years, return zero.
yearLimit := t.Year() + 5

// check if last day of month is present in cron spec, if so, update the bits as per current month
edom := false
fields := strings.Fields(s.CronExpr)

for idx := range fields {
if fields[idx] == "L" {
edom = true
s.Dom = getBits(1, uint(ldom(t)), 1)
break
}
}

WRAP:
if t.Year() > yearLimit {
return time.Time{}
Expand Down Expand Up @@ -171,6 +189,13 @@ WRAP:
}
}

if edom {
year, month, _ := t.In(origLocation).Date()
h, m, s := t.In(origLocation).Clock()

return time.Date(year, month+1, 0, h, m, s, 0, origLocation)
}

return t.In(origLocation)
}

Expand All @@ -186,3 +211,28 @@ func dayMatches(s *SpecSchedule, t time.Time) bool {
}
return domMatch || dowMatch
}

// ldom returns the last day of the month in the specified time object
func ldom(t time.Time) int {

var (
eom int
leapYear int
)

year := t.Year()
if (year%4 == 0 && year%100 != 0) || year%400 == 0 {
leapYear = 1
}

switch t.Month() {
case time.April, time.June, time.September, time.November:
eom = 30
case time.February:
eom = 28 + leapYear
default:
eom = 31
}

return eom
}