Skip to content

Commit 9d050dc

Browse files
committed
feat(cron): add support for day-of-month special characters
1 parent f817e56 commit 9d050dc

File tree

8 files changed

+217
-31
lines changed

8 files changed

+217
-31
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ Several common Job implementations can be found in the [job](./job) package.
108108
| Seconds | YES | 0-59 | , - * / |
109109
| Minutes | YES | 0-59 | , - * / |
110110
| Hours | YES | 0-23 | , - * / |
111-
| Day of month | YES | 1-31 | , - * ? / |
111+
| Day of month | YES | 1-31 | , - * ? / L W |
112112
| Month | YES | 1-12 or JAN-DEC | , - * / |
113113
| Day of week | YES | 1-7 or SUN-SAT | , - * ? / L # |
114114
| Year | NO | empty, 1970- | , - * / |

internal/csm/common_node.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func (n *CommonNode) nextInRange() bool {
7070
func (n *CommonNode) isValid() bool {
7171
withinLimits := n.value >= n.min && n.value <= n.max
7272
if n.hasRange() {
73-
withinLimits = withinLimits && contained(n.value, n.values)
73+
withinLimits = withinLimits && contains(n.values, n.value)
7474
}
7575
return withinLimits
7676
}

internal/csm/day_node.go

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ package csm
22

33
import "time"
44

5+
const (
6+
NLastDayOfMonth = 1
7+
NWeekday = 2
8+
)
9+
510
type DayNode struct {
611
c CommonNode
712
weekdayValues []int
@@ -12,10 +17,11 @@ type DayNode struct {
1217

1318
var _ csmNode = (*DayNode)(nil)
1419

15-
func NewMonthDayNode(value, min, max int, dayOfMonthValues []int, month, year csmNode) *DayNode {
20+
func NewMonthDayNode(value, min, max, n int, dayOfMonthValues []int, month, year csmNode) *DayNode {
1621
return &DayNode{
1722
c: CommonNode{value, min, max, dayOfMonthValues},
1823
weekdayValues: make([]int, 0),
24+
n: n,
1925
month: month,
2026
year: year,
2127
}
@@ -47,7 +53,10 @@ func (n *DayNode) Next() (overflowed bool) {
4753
}
4854
return n.nextWeekdayN()
4955
}
50-
return n.nextDay()
56+
if n.n == 0 {
57+
return n.nextDay()
58+
}
59+
return n.nextDayN()
5160
}
5261

5362
func (n *DayNode) nextWeekday() (overflowed bool) {
@@ -91,7 +100,7 @@ func (n *DayNode) isValid() bool {
91100
}
92101

93102
func (n *DayNode) isValidWeekday() bool {
94-
return contained(n.getWeekday(), n.weekdayValues)
103+
return contains(n.weekdayValues, n.getWeekday())
95104
}
96105

97106
func (n *DayNode) isValidDay() bool {
@@ -103,13 +112,13 @@ func (n *DayNode) isWeekday() bool {
103112
}
104113

105114
func (n *DayNode) getWeekday() int {
106-
date := time.Date(n.year.Value(), time.Month(n.month.Value()), n.c.value, 0, 0, 0, 0, time.UTC)
115+
date := makeDateTime(n.year.Value(), n.month.Value(), n.c.value)
107116
return int(date.Weekday())
108117
}
109118

110119
func (n *DayNode) addDays(offset int) (overflowed bool) {
111120
overflowed = n.Value()+offset > n.max()
112-
today := time.Date(n.year.Value(), time.Month(n.month.Value()), n.c.value, 0, 0, 0, 0, time.UTC)
121+
today := makeDateTime(n.year.Value(), n.month.Value(), n.c.value)
113122
newDate := today.AddDate(0, 0, offset)
114123
n.c.value = newDate.Day()
115124
return
@@ -126,10 +135,63 @@ func (n *DayNode) max() int {
126135
month++
127136
}
128137

129-
date := time.Date(year, month, 0, 0, 0, 0, 0, time.UTC)
138+
date := makeDateTime(year, int(month), 0)
130139
return date.Day()
131140
}
132141

142+
func (n *DayNode) nextDayN() (overflowed bool) {
143+
switch n.n {
144+
case NWeekday:
145+
n.nextWeekdayOfMonth()
146+
default:
147+
n.nextLastDayOfMonth()
148+
}
149+
return
150+
}
151+
152+
func (n *DayNode) nextWeekdayOfMonth() {
153+
year := n.year.Value()
154+
month := n.month.Value()
155+
156+
monthLastDate := lastDayOfMonth(year, month)
157+
date := n.c.values[0]
158+
if date > monthLastDate {
159+
date = monthLastDate
160+
}
161+
162+
monthDate := makeDateTime(year, month, date)
163+
closest := closestWeekday(monthDate)
164+
if n.c.value >= closest {
165+
n.c.value = 0
166+
n.advanceMonth()
167+
n.nextWeekdayOfMonth()
168+
return
169+
}
170+
171+
n.c.value = closest
172+
}
173+
174+
func (n *DayNode) nextLastDayOfMonth() {
175+
year := n.year.Value()
176+
month := n.month.Value()
177+
178+
firstDayOfMonth := makeDateTime(year, month, 1)
179+
offset := n.n
180+
if offset == NLastDayOfMonth {
181+
offset = 0
182+
}
183+
dayOfMonth := firstDayOfMonth.AddDate(0, 1, offset-1)
184+
185+
if n.c.value >= dayOfMonth.Day() {
186+
n.c.value = 0
187+
n.advanceMonth()
188+
n.nextLastDayOfMonth()
189+
return
190+
}
191+
192+
n.c.value = dayOfMonth.Day()
193+
}
194+
133195
func (n *DayNode) nextWeekdayN() (overflowed bool) {
134196
n.c.value = n.getDayInMonth(n.daysOfWeekInMonth())
135197
return
@@ -170,10 +232,10 @@ func (n *DayNode) daysOfWeekInMonth() []int {
170232
// the day of week specified for the node
171233
weekday := n.weekdayValues[0]
172234

173-
var dates []int
235+
dates := make([]int, 0, 5)
174236
// iterate through all the days of the month
175237
for day := 1; ; day++ {
176-
currentDate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
238+
currentDate := makeDateTime(year, month, day)
177239
// stop if we have reached the next month
178240
if currentDate.Month() != time.Month(month) {
179241
break

internal/csm/util.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,50 @@
11
package csm
22

3-
// Returns true if the element is included in the slice.
4-
func contained[T comparable](element T, slice []T) bool {
3+
import (
4+
"time"
5+
)
6+
7+
// contains returns true if the element is included in the slice.
8+
func contains[T comparable](slice []T, element T) bool {
59
for _, e := range slice {
610
if element == e {
711
return true
812
}
913
}
1014
return false
1115
}
16+
17+
// closestWeekday returns the day of the closest weekday within the month of
18+
// the given time t.
19+
func closestWeekday(t time.Time) int {
20+
if isWeekday(t) {
21+
return t.Day()
22+
}
23+
24+
for i := 1; i <= 7; i++ {
25+
prevDay := t.AddDate(0, 0, -i)
26+
if prevDay.Month() == t.Month() && isWeekday(prevDay) {
27+
return prevDay.Day()
28+
}
29+
30+
nextDay := t.AddDate(0, 0, i)
31+
if nextDay.Month() == t.Month() && isWeekday(nextDay) {
32+
return nextDay.Day()
33+
}
34+
}
35+
36+
return t.Day()
37+
}
38+
39+
func isWeekday(t time.Time) bool {
40+
return t.Weekday() != time.Saturday && t.Weekday() != time.Sunday
41+
}
42+
43+
func lastDayOfMonth(year, month int) int {
44+
firstDayOfMonth := makeDateTime(year, month, 1)
45+
return firstDayOfMonth.AddDate(0, 1, -1).Day()
46+
}
47+
48+
func makeDateTime(year, month, day int) time.Time {
49+
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
50+
}

quartz/cron.go

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import (
2929
// "0 15 10 15 * ?" Fire at 10:15am on the 15th day of every month
3030
// "0 15 10 ? * 6L" Fire at 10:15am on the last Friday of every month
3131
// "0 15 10 ? * 6#3" Fire at 10:15am on the third Friday of every month
32+
// "0 15 10 L * ?" Fire at 10:15am on the last day of every month
33+
// "0 15 10 L-2 * ?" Fire at 10:15am on the 2nd-to-last last day of every month
3234
type CronTrigger struct {
3335
expression string
3436
fields []*cronField
@@ -94,14 +96,8 @@ func (ct *CronTrigger) Description() string {
9496
type cronField struct {
9597
// stores the parsed and sorted numeric values for the field
9698
values []int
97-
// n specifies the occurrence of the day of week within a
98-
// month when '#' is used in the day-of-week field.
99-
// When 'L' (last) is used, it will be set to -1.
100-
//
101-
// Examples:
102-
//
103-
// - For "5#3" (third Thursday of the month), n will be 3.
104-
// - For "2L" (last Sunday of the month), n will be -1.
99+
// n is used to store special values for the day-of-month
100+
// and day-of-week fields
105101
n int
106102
}
107103

@@ -200,7 +196,7 @@ func buildCronField(tokens []string) ([]*cronField, error) {
200196
return nil, err
201197
}
202198
// day-of-month field
203-
fields[3], err = parseField(tokens[3], 1, 31)
199+
fields[3], err = parseDayOfMonthField(tokens[3], 1, 31)
204200
if err != nil {
205201
return nil, err
206202
}
@@ -269,12 +265,46 @@ func parseField(field string, min, max int, translate ...[]string) (*cronField,
269265
}
270266

271267
var (
272-
cronLastCharacterRegex = regexp.MustCompile(`^[0-9]*L$`)
273-
cronHashCharacterRegex = regexp.MustCompile(`^[0-9]+#[0-9]+$`)
268+
cronLastMonthDayRegex = regexp.MustCompile(`^L(-[0-9]+)?$`)
269+
cronWeekdayRegex = regexp.MustCompile(`^[0-9]+W$`)
270+
271+
cronLastWeekdayRegex = regexp.MustCompile(`^[0-9]*L$`)
272+
cronHashRegex = regexp.MustCompile(`^[0-9]+#[0-9]+$`)
274273
)
275274

275+
func parseDayOfMonthField(field string, min, max int, translate ...[]string) (*cronField, error) {
276+
if strings.ContainsRune(field, lastRune) && cronLastMonthDayRegex.MatchString(field) {
277+
if field == string(lastRune) {
278+
return newCronFieldN([]int{}, cronLastDayOfMonthN), nil
279+
}
280+
values := strings.Split(field, string(rangeRune))
281+
if len(values) != 2 {
282+
return nil, newInvalidCronFieldError("last", field)
283+
}
284+
n, err := strconv.Atoi(values[1])
285+
if err != nil || !inScope(n, 1, 30) {
286+
return nil, newInvalidCronFieldError("last", field)
287+
}
288+
return newCronFieldN([]int{}, -n), nil
289+
}
290+
291+
if strings.ContainsRune(field, weekdayRune) && cronWeekdayRegex.MatchString(field) {
292+
day := strings.TrimSuffix(field, string(weekdayRune))
293+
if day == "" {
294+
return nil, newInvalidCronFieldError("weekday", field)
295+
}
296+
dayOfMonth, err := strconv.Atoi(day)
297+
if err != nil || !inScope(dayOfMonth, min, max) {
298+
return nil, newInvalidCronFieldError("weekday", field)
299+
}
300+
return newCronFieldN([]int{dayOfMonth}, cronWeekdayN), nil
301+
}
302+
303+
return parseField(field, min, max, translate...)
304+
}
305+
276306
func parseDayOfWeekField(field string, min, max int, translate ...[]string) (*cronField, error) {
277-
if strings.ContainsRune(field, lastRune) && cronLastCharacterRegex.MatchString(field) {
307+
if strings.ContainsRune(field, lastRune) && cronLastWeekdayRegex.MatchString(field) {
278308
day := strings.TrimSuffix(field, string(lastRune))
279309
if day == "" { // Saturday
280310
return newCronFieldN([]int{7}, -1), nil
@@ -286,7 +316,7 @@ func parseDayOfWeekField(field string, min, max int, translate ...[]string) (*cr
286316
return newCronFieldN([]int{dayOfWeek}, -1), nil
287317
}
288318

289-
if strings.ContainsRune(field, hashRune) && cronHashCharacterRegex.MatchString(field) {
319+
if strings.ContainsRune(field, hashRune) && cronHashRegex.MatchString(field) {
290320
values := strings.Split(field, string(hashRune))
291321
if len(values) != 2 {
292322
return nil, newInvalidCronFieldError("hash", field)

quartz/cron_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,47 @@ func TestCronExpressionSpecial(t *testing.T) {
238238
}
239239
}
240240

241+
func TestCronExpressionDayOfMonth(t *testing.T) {
242+
t.Parallel()
243+
tests := []struct {
244+
expression string
245+
expected string
246+
}{
247+
{
248+
expression: "0 15 10 L * ?",
249+
expected: "Mon Mar 31 10:15:00 2025",
250+
},
251+
{
252+
expression: "0 15 10 L-5 * ?",
253+
expected: "Wed Mar 26 10:15:00 2025",
254+
},
255+
{
256+
expression: "0 15 10 15W * ?",
257+
expected: "Fri Mar 14 10:15:00 2025",
258+
},
259+
{
260+
expression: "0 15 10 1W 1/2 ?",
261+
expected: "Wed Jul 1 10:15:00 2026",
262+
},
263+
{
264+
expression: "0 15 10 31W * ?",
265+
expected: "Mon Mar 31 10:15:00 2025",
266+
},
267+
}
268+
269+
prev := time.Date(2024, 1, 1, 12, 00, 00, 00, time.UTC).UnixNano()
270+
for _, tt := range tests {
271+
test := tt
272+
t.Run(test.expression, func(t *testing.T) {
273+
t.Parallel()
274+
cronTrigger, err := quartz.NewCronTrigger(test.expression)
275+
assert.IsNil(t, err)
276+
result, _ := iterate(prev, cronTrigger, 15)
277+
assert.Equal(t, result, test.expected)
278+
})
279+
}
280+
}
281+
241282
func TestCronExpressionDayOfWeek(t *testing.T) {
242283
t.Parallel()
243284
tests := []struct {
@@ -370,6 +411,14 @@ func TestCronExpressionParseError(t *testing.T) {
370411
"0 0 0 * * 50#2",
371412
"0 5,7 14 ? * 8L *",
372413
"0 5,7 14 ? * -1L *",
414+
"0 5,7 14 ? * 0L *",
415+
"0 15 10 W * ?",
416+
"0 15 10 0W * ?",
417+
"0 15 10 32W * ?",
418+
"0 15 10 W15 * ?",
419+
"0 15 10 L- * ?",
420+
"0 15 10 L-a * ?",
421+
"0 15 10 L-32 * ?",
373422
}
374423

375424
for _, tt := range tests {

quartz/csm.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@ import (
66
CSM "github.com/reugn/go-quartz/internal/csm"
77
)
88

9+
const (
10+
cronLastDayOfMonthN = CSM.NLastDayOfMonth
11+
cronWeekdayN = CSM.NWeekday
12+
)
13+
914
func newCSMFromFields(prev time.Time, fields []*cronField) *CSM.CronStateMachine {
1015
year := CSM.NewCommonNode(prev.Year(), 0, 999999, fields[6].values)
1116
month := CSM.NewCommonNode(int(prev.Month()), 1, 12, fields[4].values)
1217
var day *CSM.DayNode
1318
if len(fields[5].values) != 0 {
1419
day = CSM.NewWeekDayNode(prev.Day(), 1, 31, fields[5].n, fields[5].values, month, year)
1520
} else {
16-
day = CSM.NewMonthDayNode(prev.Day(), 1, 31, fields[3].values, month, year)
21+
day = CSM.NewMonthDayNode(prev.Day(), 1, 31, fields[3].n, fields[3].values, month, year)
1722
}
1823
hour := CSM.NewCommonNode(prev.Hour(), 0, 59, fields[2].values)
1924
minute := CSM.NewCommonNode(prev.Minute(), 0, 59, fields[1].values)

0 commit comments

Comments
 (0)