From 51879fa1a08e465fa7978eb07178ef8d3570ebbe Mon Sep 17 00:00:00 2001 From: Eugene R Date: Wed, 6 Mar 2024 12:07:09 +0200 Subject: [PATCH] fix: parsing compound cron expression fields (#115) --- quartz/cron.go | 114 ++++++++++++++++++++++++++++---------------- quartz/cron_test.go | 13 +++++ quartz/util.go | 65 +++++++++++++------------ 3 files changed, 119 insertions(+), 73 deletions(-) diff --git a/quartz/cron.go b/quartz/cron.go index 3736b99..d957499 100644 --- a/quartz/cron.go +++ b/quartz/cron.go @@ -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 @@ -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 } @@ -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 @@ -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 } diff --git a/quartz/cron_test.go b/quartz/cron_test.go index ece91fd..759e504 100644 --- a/quartz/cron_test.go +++ b/quartz/cron_test.go @@ -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") diff --git a/quartz/util.go b/quartz/util.go index dee7e0f..ee2d491 100644 --- a/quartz/util.go +++ b/quartz/util.go @@ -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) { @@ -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) { @@ -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.