diff --git a/README.md b/README.md index 5d3f48a..eb990c8 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Several common Job implementations can be found in the [job](./job) package. | Seconds | YES | 0-59 | , - * / | | Minutes | YES | 0-59 | , - * / | | Hours | YES | 0-23 | , - * / | -| Day of month | YES | 1-31 | , - * ? / | +| Day of month | YES | 1-31 | , - * ? / L W | | Month | YES | 1-12 or JAN-DEC | , - * / | | Day of week | YES | 1-7 or SUN-SAT | , - * ? / L # | | Year | NO | empty, 1970- | , - * / | diff --git a/internal/csm/common_node.go b/internal/csm/common_node.go index 5a6f37f..9f8e3a0 100644 --- a/internal/csm/common_node.go +++ b/internal/csm/common_node.go @@ -70,7 +70,7 @@ func (n *CommonNode) nextInRange() bool { func (n *CommonNode) isValid() bool { withinLimits := n.value >= n.min && n.value <= n.max if n.hasRange() { - withinLimits = withinLimits && contained(n.value, n.values) + withinLimits = withinLimits && contains(n.values, n.value) } return withinLimits } diff --git a/internal/csm/day_node.go b/internal/csm/day_node.go index 9c8097b..8ba682c 100644 --- a/internal/csm/day_node.go +++ b/internal/csm/day_node.go @@ -2,6 +2,11 @@ package csm import "time" +const ( + NLastDayOfMonth = 1 + NWeekday = 2 +) + type DayNode struct { c CommonNode weekdayValues []int @@ -12,10 +17,11 @@ type DayNode struct { var _ csmNode = (*DayNode)(nil) -func NewMonthDayNode(value, min, max int, dayOfMonthValues []int, month, year csmNode) *DayNode { +func NewMonthDayNode(value, min, max, n int, dayOfMonthValues []int, month, year csmNode) *DayNode { return &DayNode{ c: CommonNode{value, min, max, dayOfMonthValues}, weekdayValues: make([]int, 0), + n: n, month: month, year: year, } @@ -47,7 +53,10 @@ func (n *DayNode) Next() (overflowed bool) { } return n.nextWeekdayN() } - return n.nextDay() + if n.n == 0 { + return n.nextDay() + } + return n.nextDayN() } func (n *DayNode) nextWeekday() (overflowed bool) { @@ -91,7 +100,7 @@ func (n *DayNode) isValid() bool { } func (n *DayNode) isValidWeekday() bool { - return contained(n.getWeekday(), n.weekdayValues) + return contains(n.weekdayValues, n.getWeekday()) } func (n *DayNode) isValidDay() bool { @@ -103,13 +112,13 @@ func (n *DayNode) isWeekday() bool { } func (n *DayNode) getWeekday() int { - date := time.Date(n.year.Value(), time.Month(n.month.Value()), n.c.value, 0, 0, 0, 0, time.UTC) + date := makeDateTime(n.year.Value(), n.month.Value(), n.c.value) return int(date.Weekday()) } func (n *DayNode) addDays(offset int) (overflowed bool) { overflowed = n.Value()+offset > n.max() - today := time.Date(n.year.Value(), time.Month(n.month.Value()), n.c.value, 0, 0, 0, 0, time.UTC) + today := makeDateTime(n.year.Value(), n.month.Value(), n.c.value) newDate := today.AddDate(0, 0, offset) n.c.value = newDate.Day() return @@ -126,10 +135,63 @@ func (n *DayNode) max() int { month++ } - date := time.Date(year, month, 0, 0, 0, 0, 0, time.UTC) + date := makeDateTime(year, int(month), 0) return date.Day() } +func (n *DayNode) nextDayN() (overflowed bool) { + switch n.n { + case NWeekday: + n.nextWeekdayOfMonth() + default: + n.nextLastDayOfMonth() + } + return +} + +func (n *DayNode) nextWeekdayOfMonth() { + year := n.year.Value() + month := n.month.Value() + + monthLastDate := lastDayOfMonth(year, month) + date := n.c.values[0] + if date > monthLastDate { + date = monthLastDate + } + + monthDate := makeDateTime(year, month, date) + closest := closestWeekday(monthDate) + if n.c.value >= closest { + n.c.value = 0 + n.advanceMonth() + n.nextWeekdayOfMonth() + return + } + + n.c.value = closest +} + +func (n *DayNode) nextLastDayOfMonth() { + year := n.year.Value() + month := n.month.Value() + + firstDayOfMonth := makeDateTime(year, month, 1) + offset := n.n + if offset == NLastDayOfMonth { + offset = 0 + } + dayOfMonth := firstDayOfMonth.AddDate(0, 1, offset-1) + + if n.c.value >= dayOfMonth.Day() { + n.c.value = 0 + n.advanceMonth() + n.nextLastDayOfMonth() + return + } + + n.c.value = dayOfMonth.Day() +} + func (n *DayNode) nextWeekdayN() (overflowed bool) { n.c.value = n.getDayInMonth(n.daysOfWeekInMonth()) return @@ -170,10 +232,10 @@ func (n *DayNode) daysOfWeekInMonth() []int { // the day of week specified for the node weekday := n.weekdayValues[0] - var dates []int + dates := make([]int, 0, 5) // iterate through all the days of the month for day := 1; ; day++ { - currentDate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) + currentDate := makeDateTime(year, month, day) // stop if we have reached the next month if currentDate.Month() != time.Month(month) { break diff --git a/internal/csm/util.go b/internal/csm/util.go index ab26f44..01cc7b5 100644 --- a/internal/csm/util.go +++ b/internal/csm/util.go @@ -1,7 +1,11 @@ package csm -// Returns true if the element is included in the slice. -func contained[T comparable](element T, slice []T) bool { +import ( + "time" +) + +// contains returns true if the element is included in the slice. +func contains[T comparable](slice []T, element T) bool { for _, e := range slice { if element == e { return true @@ -9,3 +13,38 @@ func contained[T comparable](element T, slice []T) bool { } return false } + +// closestWeekday returns the day of the closest weekday within the month of +// the given time t. +func closestWeekday(t time.Time) int { + if isWeekday(t) { + return t.Day() + } + + for i := 1; i <= 7; i++ { + prevDay := t.AddDate(0, 0, -i) + if prevDay.Month() == t.Month() && isWeekday(prevDay) { + return prevDay.Day() + } + + nextDay := t.AddDate(0, 0, i) + if nextDay.Month() == t.Month() && isWeekday(nextDay) { + return nextDay.Day() + } + } + + return t.Day() +} + +func isWeekday(t time.Time) bool { + return t.Weekday() != time.Saturday && t.Weekday() != time.Sunday +} + +func lastDayOfMonth(year, month int) int { + firstDayOfMonth := makeDateTime(year, month, 1) + return firstDayOfMonth.AddDate(0, 1, -1).Day() +} + +func makeDateTime(year, month, day int) time.Time { + return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) +} diff --git a/quartz/cron.go b/quartz/cron.go index bad473e..8a216ea 100644 --- a/quartz/cron.go +++ b/quartz/cron.go @@ -29,6 +29,8 @@ import ( // "0 15 10 15 * ?" Fire at 10:15am on the 15th day of every month // "0 15 10 ? * 6L" Fire at 10:15am on the last Friday of every month // "0 15 10 ? * 6#3" Fire at 10:15am on the third Friday of every month +// "0 15 10 L * ?" Fire at 10:15am on the last day of every month +// "0 15 10 L-2 * ?" Fire at 10:15am on the 2nd-to-last last day of every month type CronTrigger struct { expression string fields []*cronField @@ -94,14 +96,8 @@ func (ct *CronTrigger) Description() string { type cronField struct { // stores the parsed and sorted numeric values for the field values []int - // n specifies the occurrence of the day of week within a - // month when '#' is used in the day-of-week field. - // When 'L' (last) is used, it will be set to -1. - // - // Examples: - // - // - For "5#3" (third Thursday of the month), n will be 3. - // - For "2L" (last Sunday of the month), n will be -1. + // n is used to store special values for the day-of-month + // and day-of-week fields n int } @@ -200,7 +196,7 @@ func buildCronField(tokens []string) ([]*cronField, error) { return nil, err } // day-of-month field - fields[3], err = parseField(tokens[3], 1, 31) + fields[3], err = parseDayOfMonthField(tokens[3], 1, 31) if err != nil { return nil, err } @@ -269,12 +265,46 @@ func parseField(field string, min, max int, translate ...[]string) (*cronField, } var ( - cronLastCharacterRegex = regexp.MustCompile(`^[0-9]*L$`) - cronHashCharacterRegex = regexp.MustCompile(`^[0-9]+#[0-9]+$`) + cronLastMonthDayRegex = regexp.MustCompile(`^L(-[0-9]+)?$`) + cronWeekdayRegex = regexp.MustCompile(`^[0-9]+W$`) + + cronLastWeekdayRegex = regexp.MustCompile(`^[0-9]*L$`) + cronHashRegex = regexp.MustCompile(`^[0-9]+#[0-9]+$`) ) +func parseDayOfMonthField(field string, min, max int, translate ...[]string) (*cronField, error) { + if strings.ContainsRune(field, lastRune) && cronLastMonthDayRegex.MatchString(field) { + if field == string(lastRune) { + return newCronFieldN([]int{}, cronLastDayOfMonthN), nil + } + values := strings.Split(field, string(rangeRune)) + if len(values) != 2 { + return nil, newInvalidCronFieldError("last", field) + } + n, err := strconv.Atoi(values[1]) + if err != nil || !inScope(n, 1, 30) { + return nil, newInvalidCronFieldError("last", field) + } + return newCronFieldN([]int{}, -n), nil + } + + if strings.ContainsRune(field, weekdayRune) && cronWeekdayRegex.MatchString(field) { + day := strings.TrimSuffix(field, string(weekdayRune)) + if day == "" { + return nil, newInvalidCronFieldError("weekday", field) + } + dayOfMonth, err := strconv.Atoi(day) + if err != nil || !inScope(dayOfMonth, min, max) { + return nil, newInvalidCronFieldError("weekday", field) + } + return newCronFieldN([]int{dayOfMonth}, cronWeekdayN), nil + } + + return parseField(field, min, max, translate...) +} + func parseDayOfWeekField(field string, min, max int, translate ...[]string) (*cronField, error) { - if strings.ContainsRune(field, lastRune) && cronLastCharacterRegex.MatchString(field) { + if strings.ContainsRune(field, lastRune) && cronLastWeekdayRegex.MatchString(field) { day := strings.TrimSuffix(field, string(lastRune)) if day == "" { // Saturday return newCronFieldN([]int{7}, -1), nil @@ -286,7 +316,7 @@ func parseDayOfWeekField(field string, min, max int, translate ...[]string) (*cr return newCronFieldN([]int{dayOfWeek}, -1), nil } - if strings.ContainsRune(field, hashRune) && cronHashCharacterRegex.MatchString(field) { + if strings.ContainsRune(field, hashRune) && cronHashRegex.MatchString(field) { values := strings.Split(field, string(hashRune)) if len(values) != 2 { return nil, newInvalidCronFieldError("hash", field) diff --git a/quartz/cron_test.go b/quartz/cron_test.go index 3a6fc37..a29f8b4 100644 --- a/quartz/cron_test.go +++ b/quartz/cron_test.go @@ -238,6 +238,47 @@ func TestCronExpressionSpecial(t *testing.T) { } } +func TestCronExpressionDayOfMonth(t *testing.T) { + t.Parallel() + tests := []struct { + expression string + expected string + }{ + { + expression: "0 15 10 L * ?", + expected: "Mon Mar 31 10:15:00 2025", + }, + { + expression: "0 15 10 L-5 * ?", + expected: "Wed Mar 26 10:15:00 2025", + }, + { + expression: "0 15 10 15W * ?", + expected: "Fri Mar 14 10:15:00 2025", + }, + { + expression: "0 15 10 1W 1/2 ?", + expected: "Wed Jul 1 10:15:00 2026", + }, + { + expression: "0 15 10 31W * ?", + expected: "Mon Mar 31 10:15:00 2025", + }, + } + + prev := time.Date(2024, 1, 1, 12, 00, 00, 00, time.UTC).UnixNano() + for _, tt := range tests { + test := tt + t.Run(test.expression, func(t *testing.T) { + t.Parallel() + cronTrigger, err := quartz.NewCronTrigger(test.expression) + assert.IsNil(t, err) + result, _ := iterate(prev, cronTrigger, 15) + assert.Equal(t, result, test.expected) + }) + } +} + func TestCronExpressionDayOfWeek(t *testing.T) { t.Parallel() tests := []struct { @@ -370,6 +411,14 @@ func TestCronExpressionParseError(t *testing.T) { "0 0 0 * * 50#2", "0 5,7 14 ? * 8L *", "0 5,7 14 ? * -1L *", + "0 5,7 14 ? * 0L *", + "0 15 10 W * ?", + "0 15 10 0W * ?", + "0 15 10 32W * ?", + "0 15 10 W15 * ?", + "0 15 10 L- * ?", + "0 15 10 L-a * ?", + "0 15 10 L-32 * ?", } for _, tt := range tests { diff --git a/quartz/csm.go b/quartz/csm.go index 1b97218..370b3d2 100644 --- a/quartz/csm.go +++ b/quartz/csm.go @@ -6,6 +6,11 @@ import ( CSM "github.com/reugn/go-quartz/internal/csm" ) +const ( + cronLastDayOfMonthN = CSM.NLastDayOfMonth + cronWeekdayN = CSM.NWeekday +) + func newCSMFromFields(prev time.Time, fields []*cronField) *CSM.CronStateMachine { year := CSM.NewCommonNode(prev.Year(), 0, 999999, fields[6].values) month := CSM.NewCommonNode(int(prev.Month()), 1, 12, fields[4].values) @@ -13,7 +18,7 @@ func newCSMFromFields(prev time.Time, fields []*cronField) *CSM.CronStateMachine if len(fields[5].values) != 0 { day = CSM.NewWeekDayNode(prev.Day(), 1, 31, fields[5].n, fields[5].values, month, year) } else { - day = CSM.NewMonthDayNode(prev.Day(), 1, 31, fields[3].values, month, year) + day = CSM.NewMonthDayNode(prev.Day(), 1, 31, fields[3].n, fields[3].values, month, year) } hour := CSM.NewCommonNode(prev.Hour(), 0, 59, fields[2].values) minute := CSM.NewCommonNode(prev.Minute(), 0, 59, fields[1].values) diff --git a/quartz/util.go b/quartz/util.go index ca831be..9e467bc 100644 --- a/quartz/util.go +++ b/quartz/util.go @@ -8,11 +8,12 @@ import ( ) const ( - listRune = ',' - stepRune = '/' - rangeRune = '-' - lastRune = 'L' - hashRune = '#' + listRune = ',' + stepRune = '/' + rangeRune = '-' + weekdayRune = 'W' + lastRune = 'L' + hashRune = '#' ) // Sep is the serialization delimiter; the default is a double colon.