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

Use time.Duration & Allow custom cron.Parser #18

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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: 2 additions & 0 deletions common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ var (
crThirdDayEachMonthHonolulu, _ = New("0 0 3 * *", timeZoneHonolulu, 1440)
crFirstHourFeb29, _ = New("0 0 29 2 *", "", 60)
crFirstHourFeb28OrSun, _ = New("0 0 28 2 0", "", 60)
crEvery1MinV2, _ = Create(exprEveryMin, emptyString, time.Minute*1, cronParser)
crEveryNewYearsDayTokyoV2, _ = Create(exprEveryNewYear, timeZoneTokyo, time.Hour*24, cronParser)
)

type tempTestWithPointer struct {
Expand Down
72 changes: 47 additions & 25 deletions cronrange.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,18 @@ var (
errZeroDuration = errors.New("duration should be positive")
)

const (
Version1 = 0
Version2 = 1
)

// CronRange consists of cron expression along with time zone and duration info.
type CronRange struct {
cronExpression string
timeZone string
duration time.Duration
schedule cron.Schedule
version int
}

// TimeRange represents a time range between starting time and ending time.
Expand All @@ -34,52 +40,68 @@ type TimeRange struct {
//
// It returns an error if duration is not positive number, or cron expression is invalid, or time zone doesn't exist.
func New(cronExpr, timeZone string, durationMin uint64) (cr *CronRange, err error) {
cr, err = internalNew(cronExpr, timeZone, time.Duration(durationMin)*time.Minute, cronParser)
return
}

// Duration returns the duration of the CronRange.
func (cr *CronRange) Duration() time.Duration {
cr.checkPrecondition()
return cr.duration
}

// TimeZone returns the time zone string of the CronRange.
func (cr *CronRange) TimeZone() string {
cr.checkPrecondition()
return cr.timeZone
}

// CronExpression returns the Cron expression of the CronRange.
func (cr *CronRange) CronExpression() string {
cr.checkPrecondition()
return cr.cronExpression
}

func internalNew(cronExpr, tz string, td time.Duration, cp cron.Parser) (cr *CronRange, err error) {
// Precondition check
if durationMin == 0 {
if td <= 0 {
err = errZeroDuration
return
}

// Clean up string parameters
cronExpr, timeZone = strings.TrimSpace(cronExpr), strings.TrimSpace(timeZone)
cronExpr, tz = strings.TrimSpace(cronExpr), strings.TrimSpace(tz)

// Append time zone into cron spec if necessary
cronSpec := cronExpr
if strings.ToLower(timeZone) == "local" {
timeZone = ""
} else if len(timeZone) > 0 {
cronSpec = fmt.Sprintf("CRON_TZ=%s %s", timeZone, cronExpr)
if strings.ToLower(tz) == "local" {
tz = ""
} else if len(tz) > 0 {
cronSpec = fmt.Sprintf("CRON_TZ=%s %s", tz, cronExpr)
}

// Validate & retrieve crontab schedule
var schedule cron.Schedule
if schedule, err = cronParser.Parse(cronSpec); err != nil {
if schedule, err = cp.Parse(cronSpec); err != nil {
return
}

cr = &CronRange{
cronExpression: cronExpr,
timeZone: timeZone,
duration: time.Minute * time.Duration(durationMin),
timeZone: tz,
duration: td,
schedule: schedule,
}
return
}

// Duration returns the duration of the CronRange.
func (cr *CronRange) Duration() time.Duration {
cr.checkPrecondition()
return cr.duration
}

// TimeZone returns the time zone string of the CronRange.
func (cr *CronRange) TimeZone() string {
cr.checkPrecondition()
return cr.timeZone
}

// CronExpression returns the Cron expression of the CronRange.
func (cr *CronRange) CronExpression() string {
cr.checkPrecondition()
return cr.cronExpression
// Create returns a CronRange instance with given config, time zone can be empty for local time zone.
//
// It returns an error if duration is not positive number, or cron expression is invalid, or time zone doesn't exist.
func Create(cronExpr, timeZone string, duration time.Duration, cp cron.Parser) (cr *CronRange, err error) {
cr, err = internalNew(cronExpr, timeZone, duration, cp)
if err == nil {
cr.version = Version2
}
return
}
42 changes: 42 additions & 0 deletions cronrange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cronrange
import (
"testing"
"time"

"github.com/robfig/cron/v3"
)

func TestNew(t *testing.T) {
Expand Down Expand Up @@ -43,6 +45,46 @@ func TestNew(t *testing.T) {
}
}

func TestCreate(t *testing.T) {
type args struct {
cronExpr string
timeZone string
durationMin uint64
duration time.Duration
cp cron.Parser
}
tests := []struct {
name string
args args
wantCr bool
wantErr bool
}{
{"Empty cronExpr", args{emptyString, emptyString, 5, time.Minute * 5, cronParser}, false, true},
{"Invalid cronExpr", args{"h e l l o", emptyString, 5, time.Minute * 5, cronParser}, false, true},
{"Incomplete cronExpr", args{"* * * *", emptyString, 5, time.Minute * 5, cronParser}, false, true},
{"Nonexistent time zone", args{exprEveryMin, "Mars", 5, time.Minute * 5, cronParser}, false, true},
{"Zero durationMin", args{exprEveryMin, emptyString, 0, time.Minute * 0, cronParser}, false, true},
{"Normal without time zone", args{exprEveryMin, emptyString, 5, time.Minute * 5, cronParser}, true, false},
{"Normal with local time zone", args{exprEveryMin, " Local ", 5, time.Minute * 5, cronParser}, true, false},
{"Normal with 5 min in Bangkok", args{exprEveryMin, timeZoneBangkok, 5, time.Minute * 5, cronParser}, true, false},
{"Normal with 1 day in Tokyo", args{exprEveryNewYear, timeZoneTokyo, 1440, time.Minute * 1440, cronParser}, true, false},
{"Normal with large duration", args{exprEveryMin, timeZoneBangkok, 5259000, time.Minute * 5259000, cronParser}, true, false},
{"Normal with complicated cron expression", args{exprVeryComplicated, timeZoneHonolulu, 5258765, time.Minute * 5258765, cronParser}, true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotCr, err := Create(tt.args.cronExpr, tt.args.timeZone, tt.args.duration, tt.args.cp)
if (err != nil) != tt.wantErr {
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
return
}
if (gotCr != nil) != tt.wantCr {
t.Errorf("New() gotCr = %v, wantCr %v", gotCr, tt.wantCr)
}
})
}
}

func BenchmarkNew(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = New(exprEveryMin, timeZoneBangkok, 10)
Expand Down
2 changes: 1 addition & 1 deletion function.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func (cr *CronRange) IsWithin(t time.Time) (within bool) {
cr.checkPrecondition()

within = false
searchStart := t.Add(-(cr.duration + 1*time.Second))
searchStart := t.Add(-(cr.duration + 1*time.Second - 1*time.Nanosecond))
rangeStart := cr.schedule.Next(searchStart)
rangeEnd := rangeStart.Add(cr.duration)

Expand Down
36 changes: 33 additions & 3 deletions serialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"time"

"github.com/robfig/cron/v3"
)

var (
Expand All @@ -28,7 +30,11 @@ func (cr CronRange) String() string {
sb.Grow(36)
if cr.duration > 0 {
sb.WriteString(strMarkDuration)
sb.WriteString(strconv.FormatUint(uint64(cr.duration/time.Minute), 10))
if cr.version == Version2 {
sb.WriteString(cr.duration.String())
} else {
sb.WriteString(strconv.FormatUint(uint64(cr.duration/time.Minute), 10))
}
sb.WriteString(strSemicolon)
sb.WriteString(strSingleWhitespace)
}
Expand All @@ -44,6 +50,11 @@ func (cr CronRange) String() string {

// ParseString attempts to deserialize the given expression or return failure if any parsing errors occur.
func ParseString(s string) (cr *CronRange, err error) {
cr, err = internalParseString(s, cronParser)
return
}

func internalParseString(s string, cp cron.Parser) (cr *CronRange, err error) {
if s == "" {
err = errEmptyExpr
return
Expand All @@ -54,6 +65,8 @@ func ParseString(s string) (cr *CronRange, err error) {
durMin uint64
parts = strings.Split(s, strSemicolon)
idxExpr = len(parts) - 1
version int
duration time.Duration
)
if idxExpr == 0 {
err = errIncompleteExpr
Expand All @@ -74,8 +87,16 @@ PL:
cronExpr = part
case strings.HasPrefix(part, strMarkDuration):
durStr = part[len(strMarkDuration):]
if duration, err = time.ParseDuration(durStr); err == nil {
if duration > 0 {
version = Version2
}
}
if durMin, err = strconv.ParseUint(durStr, 10, 64); err != nil {
break PL
if version != Version2 {
break PL
}
err = nil
}
case strings.HasPrefix(part, strMarkTimeZone):
timeZone = part[len(strMarkTimeZone):]
Expand All @@ -86,14 +107,23 @@ PL:

if err == nil {
if len(durStr) > 0 {
cr, err = New(cronExpr, timeZone, durMin)
if version == Version2 {
cr, err = Create(cronExpr, timeZone, duration, cp)
} else {
cr, err = internalNew(cronExpr, timeZone, time.Duration(durMin)*time.Minute, cp)
}
} else {
err = errMissDurationExpr
}
}
return
}

func ParseStringWithCronParser(s string, cp cron.Parser) (cr *CronRange, err error) {
cr, err = internalParseString(s, cp)
return
}

// MarshalJSON implements the encoding/json.Marshaler interface for serialization of CronRange.
func (cr CronRange) MarshalJSON() ([]byte, error) {
expr := cr.String()
Expand Down
50 changes: 50 additions & 0 deletions serialize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"strings"
"testing"
"time"

"github.com/robfig/cron/v3"
)

func TestCronRange_String(t *testing.T) {
Expand All @@ -19,12 +21,14 @@ func TestCronRange_String(t *testing.T) {
{"Use string() instead of sprintf", crEvery1Min, "DR=1; * * * * *"},
{"Use instance instead of pointer", crEvery1Min, "DR=1; * * * * *"},
{"1min duration without time zone", crEvery1Min, "DR=1; * * * * *"},
{"1min duration without time zone V2", crEvery1MinV2, "DR=1m0s; * * * * *"},
{"5min duration without time zone", crEvery5Min, "DR=5; */5 * * * *"},
{"10min duration with local time zone", crEvery10MinLocal, "DR=10; */10 * * * *"},
{"10min duration with time zone", crEvery10MinBangkok, "DR=10; TZ=Asia/Bangkok; */10 * * * *"},
{"Every Xmas morning in NYC", crEveryXmasMorningNYC, "DR=240; TZ=America/New_York; 0 8 25 12 *"},
{"Every New Year's Day in Bangkok", crEveryNewYearsDayBangkok, "DR=1440; TZ=Asia/Bangkok; 0 0 1 1 *"},
{"Every New Year's Day in Tokyo", crEveryNewYearsDayTokyo, "DR=1440; TZ=Asia/Tokyo; 0 0 1 1 *"},
{"Every New Year's Day in Tokyo V2", crEveryNewYearsDayTokyoV2, "DR=24h0m0s; TZ=Asia/Tokyo; 0 0 1 1 *"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -97,6 +101,52 @@ func TestParseString(t *testing.T) {
}
}

var deserializeV2TestCases = []struct {
name string
inputS string
wantS string
wantErr bool
cp cron.Parser
}{
{"Empty string", emptyString, emptyString, true, cronParser},
{"Invalid expression", "hello", emptyString, true, cronParser},
{"Missing duration", "; * * * * *", emptyString, true, cronParser},
{"Invalid duration=0", "DR=0m;* * * * *", emptyString, true, cronParser},
{"Invalid duration=-5", "DR=-5m;* * * * *", emptyString, true, cronParser},
{"Invalid with Mars time zone", "DR=5m;TZ=Mars;* * * * *", emptyString, true, cronParser},
{"Invalid with unknown part", "DR=10m; TZ=Pacific/Honolulu; SET=1; * * * * *", emptyString, true, cronParser},
{"Invalid with lower case", "dr=5m;* * * * *", emptyString, true, cronParser},
{"Invalid with wrong order", "* * * * *; DR=5m;", emptyString, true, cronParser},
{"Normal without timezone", "DR=5m;* * * * *", "DR=5m0s; * * * * *", false, cronParser},
{"Normal with extra whitespaces", " DR=6m ; * * * * * ", "DR=6m0s; * * * * *", false, cronParser},
{"Normal with empty parts", "; DR=7m;;; ;; ;; ;* * * * * ", "DR=7m0s; * * * * *", false, cronParser},
{"Normal with different order", "TZ=Asia/Tokyo; DR=1440m; 0 0 1 1 *", "DR=24h0m0s; TZ=Asia/Tokyo; 0 0 1 1 *", false, cronParser},
{"Normal with local time zone", "DR=8m;TZ=Local;* * * * *", "DR=8m0s; * * * * *", false, cronParser},
{"Normal with UTC time zone", "DR=9m;TZ=Etc/UTC;* * * * *", "DR=9m0s; TZ=Etc/UTC; * * * * *", false, cronParser},
{"Normal with Honolulu time zone", "DR=10m;TZ=Pacific/Honolulu;* * * * *", "DR=10m0s; TZ=Pacific/Honolulu; * * * * *", false, cronParser},
{"Normal with Honolulu time zone in different order", "TZ=Pacific/Honolulu; DR=10m; * * * * *", "DR=10m0s; TZ=Pacific/Honolulu; * * * * *", false, cronParser},
{"Normal with complicated expression", "DR=5258765m; TZ=Pacific/Honolulu; 4,8,22,27,33,38,47,50 3,11,14-16,19,21,22 */10 1,3,5,6,9-11 1-5", "DR=87646h5m0s; TZ=Pacific/Honolulu; 4,8,22,27,33,38,47,50 3,11,14-16,19,21,22 */10 1,3,5,6,9-11 1-5", false, cronParser},
}

func TestParseStringWithCronParser(t *testing.T) {
for _, tt := range deserializeV2TestCases {
t.Run(tt.name, func(t *testing.T) {
gotCr, err := ParseStringWithCronParser(tt.inputS, tt.cp)
if (err != nil) != tt.wantErr {
t.Errorf("ParseString() error: %v, wantErr: %v", err, tt.wantErr)
return
}
if !tt.wantErr && (gotCr == nil || gotCr.schedule == nil || gotCr.duration == 0) {
t.Errorf("ParseString() incomplete gotCr: %v", gotCr)
return
}
if !tt.wantErr && gotCr.String() != tt.wantS {
t.Errorf("ParseString() gotCr: %s, want: %s", gotCr.String(), tt.wantS)
}
})
}
}

func BenchmarkParseString(b *testing.B) {
rs := "DR=10;TZ=Pacific/Honolulu;;* * * * *"
b.ResetTimer()
Expand Down