Skip to content

Commit

Permalink
fix: parsing compound cron expression fields (#115)
Browse files Browse the repository at this point in the history
  • Loading branch information
reugn committed Mar 6, 2024
1 parent 8a940a4 commit 51879fa
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 73 deletions.
114 changes: 74 additions & 40 deletions quartz/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,9 @@ func buildCronField(tokens []string) ([]*cronField, error) {
}

func parseField(field string, min, max int, translate ...[]string) (*cronField, error) {
var dict []string
var glossary []string
if len(translate) > 0 {
dict = translate[0]
glossary = translate[0]
}

// any value
Expand All @@ -221,50 +221,56 @@ func parseField(field string, min, max int, translate ...[]string) (*cronField,
if inScope(i, min, max) {
return &cronField{[]int{i}}, nil
}
return nil, cronParseError("simple field min/max validation")
return nil, invalidCronFieldError("simple", field)
}

// list values
if strings.Contains(field, ",") {
return parseListField(field, min, max, dict)
}

// range values
if strings.Contains(field, "-") {
return parseRangeField(field, min, max, dict)
return parseListField(field, min, max, glossary)
}

// step values
if strings.Contains(field, "/") {
return parseStepField(field, min, max, dict)
return parseStepField(field, min, max, glossary)
}

// range values
if strings.Contains(field, "-") {
return parseRangeField(field, min, max, glossary)
}

// simple literal value
if dict != nil {
i := intVal(dict, field)
if i >= 0 {
if inScope(i, min, max) {
return &cronField{[]int{i}}, nil
}
return nil, cronParseError("simple literal min/max validation")
if glossary != nil {
intVal, err := translateLiteral(glossary, field)
if err != nil {
return nil, err
}
if inScope(intVal, min, max) {
return &cronField{[]int{intVal}}, nil
}
return nil, invalidCronFieldError("literal", field)
}

return nil, cronParseError("parse error")
}

func parseListField(field string, min, max int, translate []string) (*cronField, error) {
func parseListField(field string, min, max int, glossary []string) (*cronField, error) {
t := strings.Split(field, ",")
values, rangeValues := extractRangeValues(t)
listValues, err := sliceAtoi(values)
values, stepValues := extractStepValues(t)
values, rangeValues := extractRangeValues(values)
listValues, err := translateLiterals(glossary, values)
if err != nil {
listValues, err = indexes(values, translate)
return nil, err
}
for _, v := range stepValues {
stepField, err := parseStepField(v, min, max, glossary)
if err != nil {
return nil, err
}
listValues = append(listValues, stepField.values...)
}
for _, v := range rangeValues {
rangeField, err := parseRangeField(v, min, max, translate)
rangeField, err := parseRangeField(v, min, max, glossary)
if err != nil {
return nil, err
}
Expand All @@ -275,18 +281,22 @@ func parseListField(field string, min, max int, translate []string) (*cronField,
return &cronField{listValues}, nil
}

func parseRangeField(field string, min, max int, translate []string) (*cronField, error) {
func parseRangeField(field string, min, max int, glossary []string) (*cronField, error) {
t := strings.Split(field, "-")
if len(t) != 2 {
return nil, cronParseError(fmt.Sprintf("invalid range field %s", field))
return nil, invalidCronFieldError("range", field)
}
from, err := normalize(t[0], glossary)
if err != nil {
return nil, err
}
to, err := normalize(t[1], glossary)
if err != nil {
return nil, err
}

from := normalize(t[0], translate)
to := normalize(t[1], translate)
if !inScope(from, min, max) || !inScope(to, min, max) {
return nil, cronParseError(fmt.Sprintf("range field min/max validation %d-%d", from, to))
return nil, invalidCronFieldError("range", field)
}

rangeValues, err := fillRangeValues(from, to)
if err != nil {
return nil, err
Expand All @@ -295,22 +305,46 @@ func parseRangeField(field string, min, max int, translate []string) (*cronField
return &cronField{rangeValues}, nil
}

func parseStepField(field string, min, max int, translate []string) (*cronField, error) {
func parseStepField(field string, min, max int, glossary []string) (*cronField, error) {
t := strings.Split(field, "/")
if len(t) != 2 {
return nil, cronParseError(fmt.Sprintf("invalid step field %s", field))
return nil, invalidCronFieldError("step", field)
}
to := max
var (
from int
err error
)
switch {
case t[0] == "*":
from = min
case strings.Contains(t[0], "-"):
trange := strings.Split(t[0], "-")
if len(trange) != 2 {
return nil, invalidCronFieldError("step", field)
}
from, err = normalize(trange[0], glossary)
if err != nil {
return nil, err
}
to, err = normalize(trange[1], glossary)
if err != nil {
return nil, err
}
default:
from, err = normalize(t[0], glossary)
if err != nil {
return nil, err
}
}
if t[0] == "*" {
t[0] = strconv.Itoa(min)
step, err := strconv.Atoi(t[1])
if err != nil {
return nil, invalidCronFieldError("step", field)
}

from := normalize(t[0], translate)
step := atoi(t[1])
if !inScope(from, min, max) {
return nil, cronParseError("step field min/max validation")
if !inScope(from, min, max) || !inScope(step, 1, max) || !inScope(to, min, max) {
return nil, invalidCronFieldError("step", field)
}

stepValues, err := fillStepValues(from, step, max)
stepValues, err := fillStepValues(from, step, to)
if err != nil {
return nil, err
}
Expand Down
13 changes: 13 additions & 0 deletions quartz/cron_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,19 @@ func TestCronExpressionMixedStringRange(t *testing.T) {
assert.Equal(t, result, "Sat May 6 00:00:00 2023")
}

func TestCronExpressionStepWithRange(t *testing.T) {
prev := time.Date(2024, 1, 1, 12, 00, 00, 00, time.UTC).UnixNano()
cronTrigger, err := quartz.NewCronTrigger("0 0 5-11/2 * * *")
assert.IsNil(t, err)
result, _ := iterate(prev, cronTrigger, 10)
assert.Equal(t, result, "Thu Jan 4 07:00:00 2024")

cronTrigger, err = quartz.NewCronTrigger("0 0 1,5-11/3 * * *")
assert.IsNil(t, err)
result, _ = iterate(prev, cronTrigger, 10)
assert.Equal(t, result, "Thu Jan 4 05:00:00 2024")
}

func TestCronExpressionExpired(t *testing.T) {
prev := time.Date(2023, 4, 22, 12, 00, 00, 00, time.UTC).UnixNano()
cronTrigger, err := quartz.NewCronTrigger("0 0 0 1 1 ? 2023")
Expand Down
65 changes: 32 additions & 33 deletions quartz/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ import (
// Sep is the serialization delimiter; the default is a double colon.
var Sep = "::"

func indexes(search []string, target []string) ([]int, error) {
searchIndexes := make([]int, 0, len(search))
for _, a := range search {
index := intVal(target, a)
if index == -1 {
return nil, cronParseError(fmt.Sprintf("invalid cron field %s", a))
func translateLiterals(glossary, literals []string) ([]int, error) {
intValues := make([]int, 0, len(literals))
for _, literal := range literals {
index, err := normalize(literal, glossary)
if err != nil {
return nil, err
}
searchIndexes = append(searchIndexes, index)
intValues = append(intValues, index)
}
return searchIndexes, nil
return intValues, nil
}

func extractRangeValues(parsed []string) ([]string, []string) {
Expand All @@ -35,16 +35,17 @@ func extractRangeValues(parsed []string) ([]string, []string) {
return values, rangeValues
}

func sliceAtoi(sa []string) ([]int, error) {
si := make([]int, 0, len(sa))
for _, a := range sa {
i, err := strconv.Atoi(a)
if err != nil {
return si, err
func extractStepValues(parsed []string) ([]string, []string) {
values := make([]string, 0, len(parsed))
stepValues := make([]string, 0)
for _, v := range parsed {
if strings.Contains(v, "/") { // step value
stepValues = append(stepValues, v)
} else {
values = append(values, v)
}
si = append(si, i)
}
return si, nil
return values, stepValues
}

func fillRangeValues(from, to int) ([]int, error) {
Expand Down Expand Up @@ -73,35 +74,33 @@ func fillStepValues(from, step, max int) ([]int, error) {
return stepValues, nil
}

func normalize(field string, dict []string) int {
i, err := strconv.Atoi(field)
if err == nil {
return i
func normalize(field string, glossary []string) (int, error) {
intVal, err := strconv.Atoi(field)
if err != nil {
return translateLiteral(glossary, field)
}
return intVal(dict, field)
return intVal, nil
}

func inScope(i, min, max int) bool {
if i >= min && i <= max {
func inScope(value, min, max int) bool {
if value >= min && value <= max {
return true
}
return false
}

func intVal(source []string, target string) int {
upperCaseTarget := strings.ToUpper(target)
for i, v := range source {
if v == upperCaseTarget {
return i
func translateLiteral(glossary []string, literal string) (int, error) {
upperCaseLiteral := strings.ToUpper(literal)
for i, value := range glossary {
if value == upperCaseLiteral {
return i, nil
}
}
return -1 // TODO: return error
return 0, cronParseError(fmt.Sprintf("unknown literal %s", literal))
}

// atoi implements an unsafe strconv.Atoi.
func atoi(str string) int {
i, _ := strconv.Atoi(str)
return i
func invalidCronFieldError(t, field string) error {
return cronParseError(fmt.Sprintf("invalid %s field %s", t, field))
}

// NowNano returns the current Unix time in nanoseconds.
Expand Down

0 comments on commit 51879fa

Please sign in to comment.