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

feat(cron): add support for day-of-month special characters #151

Merged
merged 1 commit into from
Feb 2, 2025
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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- | , - * / |
Expand Down
2 changes: 1 addition & 1 deletion internal/csm/common_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
78 changes: 70 additions & 8 deletions internal/csm/day_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package csm

import "time"

const (
NLastDayOfMonth = 1
NWeekday = 2
)

type DayNode struct {
c CommonNode
weekdayValues []int
Expand All @@ -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,
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions internal/csm/util.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,50 @@
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
}
}
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)
}
56 changes: 43 additions & 13 deletions quartz/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions quartz/cron_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion quartz/csm.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ 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)
var day *CSM.DayNode
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)
Expand Down
Loading
Loading