Skip to content

Commit

Permalink
[query] ParseTime function supports 95%+ of from / until time forma…
Browse files Browse the repository at this point in the history
…ts (#2621)
  • Loading branch information
teddywahle authored Oct 5, 2020
1 parent 89f6fcc commit db4e9a3
Show file tree
Hide file tree
Showing 2 changed files with 318 additions and 1 deletion.
216 changes: 215 additions & 1 deletion src/query/graphite/graphite/timespec.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ import (
"github.com/m3db/m3/src/query/graphite/errors"
)

var reRelativeTime = regexp.MustCompile(`(?i)^\-([0-9]+)(s|min|h|d|w|mon|y)$`)
var reRelativeTime = regexp.MustCompile(`(?i)^\-([0-9]+)(s|min|h|d|w|mon|y)$`) // allows -3min, -4d, etc.
var reTimeOffset = regexp.MustCompile(`(?i)^(\-|\+)([0-9]+)(s|min|h|d|w|mon|y)$`) // -3min, +3min, -4d, +4d
var reMonthAndDay = regexp.MustCompile(`(?i)^(january|february|march|april|may|june|july|august|september|october|november|december)([0-9]{1,2}?)$`)
var reDayOfWeek = regexp.MustCompile(`(?i)^(sunday|monday|tuesday|wednesday|thursday|friday|saturday)$`)
var reDayOfWeekOffset = regexp.MustCompile(`(?i)^(\-|\+)(sunday|monday|tuesday|wednesday|thursday|friday|saturday)$`) // +monday, +thursday, etc
var rePM = regexp.MustCompile(`(?i)^(([0-1]?)([0-9])pm)([[:ascii:]])*$`) // 8pm, 12pm, 8pm monday
var reAM = regexp.MustCompile(`(?i)^(([0-1]?)([0-9])am)([[:ascii:]])*$`) // 2am, 11am, 7am yesterday
var reTimeOfDayWithColon = regexp.MustCompile(`(?i)^(([0-1]?)([0-9]):([0-5])([0-9])((am|pm)?))([[:ascii:]])*$`) // 8:12pm, 11:20am, 2:00am

var periods = map[string]time.Duration{
"s": time.Second,
Expand All @@ -42,6 +49,31 @@ var periods = map[string]time.Duration{
"y": time.Hour * 24 * 365,
}

var weekdays = map[string]int{
"sunday": 0,
"monday": 1,
"tuesday": 2,
"wednesday": 3,
"thursday": 4,
"friday": 5,
"saturday": 6,
}

var months = map[string]int{
"january": 1,
"february": 2,
"march": 3,
"april": 4,
"may": 5,
"june": 6,
"july": 7,
"august": 8,
"september": 9,
"october": 10,
"november": 11,
"december": 12,
}

// on Jan 2 15:04:05 -0700 MST 2006
var formats = []string{
"15:04_060102",
Expand All @@ -50,6 +82,7 @@ var formats = []string{
"15:04_02.01.06",
"02.01.06",
"01/02/06",
"01/02/2006",
"060102",
"20060102",
}
Expand All @@ -74,6 +107,17 @@ func FormatTime(t time.Time) string {
return t.Format(formats[0])
}

func getWeekDayOffset(weekday string, now time.Time) time.Duration {
expectedDay := weekdays[weekday]
today := int(now.Weekday())
dayOffset := today - expectedDay
if dayOffset < 0 {
dayOffset += 7
}

return time.Duration(dayOffset) * time.Hour * -24
}

// ParseTime translates a Graphite from/until string into a timestamp relative to the provide time
func ParseTime(s string, now time.Time, absoluteOffset time.Duration) (time.Time, error) {
if len(s) == 0 {
Expand Down Expand Up @@ -108,9 +152,157 @@ func ParseTime(s string, now time.Time, absoluteOffset time.Duration) (time.Time
return time.Unix(n, 0).UTC(), nil
}

s = strings.Replace(strings.Replace(strings.ToLower(s), ",", "", -1), " ", "", -1)
ref, offset := s, ""
if strings.Contains(s, "+") {
stringSlice := strings.Split(s, "+")
if len(stringSlice) == 2 {
ref, offset = stringSlice[0], stringSlice[1]
offset = "+" + offset
} else {
return now, errors.NewInvalidParamsError(fmt.Errorf("unknown time string %s", s))
}
} else if strings.Contains(s, "-") {
stringSlice := strings.Split(s, "-")
if len(stringSlice) == 2 {
ref, offset = stringSlice[0], stringSlice[1]
offset = "-" + offset
} else {
return now, errors.NewInvalidParamsError(fmt.Errorf("unknown time string %s", s))
}
}
parsedReference, err := ParseTimeReference(ref, now)
if err == nil {
parsedOffset, err := ParseOffset(offset, now)
if err == nil {
return parsedReference.Add(parsedOffset), nil
}
}

return now, err
}

// ParseTimeReference takes a Graphite time reference ("8am", "today", "monday") and returns a time.Time
func ParseTimeReference(ref string, now time.Time) (time.Time, error) {
var (
hour = now.Hour()
minute = now.Minute()
refDate time.Time
)

if ref == "" || ref == "now" {
return now, nil
}

// check if the time ref fits an absolute time format
for _, format := range formats {
t, err := time.Parse(format, ref)
if err == nil {
return t, nil
}
}

rawRef := ref

// Time of Day Reference (8:12pm, 11:20am, 2:00am, etc.)
if reTimeOfDayWithColon.MatchString(rawRef) {
i := strings.Index(ref, ":")
newHour, err := strconv.ParseInt(ref[:i], 10, 0)
if err != nil {
return time.Time{}, err
}
hour = int(newHour)
if len(ref) >= i+3 {
newMinute, err := strconv.ParseInt(ref[i+1:i+3], 10, 32)
if err != nil {
return time.Time{}, err
}
minute = int(newMinute)
if minute > 59 {
return time.Time{}, errors.NewInvalidParamsError(fmt.Errorf("unknown time reference %s", rawRef))
}
ref = ref[i+3:]
}

if len(ref) >= 2 {
if ref[:2] == "am" {
ref = ref[2:]
} else if ref[:2] == "pm" {
hour = (hour + 12) % 24
ref = ref[2:]
}
}
}

// Xam or XXam
if reAM.MatchString(rawRef) {
i := strings.Index(ref, "am")
newHour, err := strconv.ParseInt(ref[:i], 10, 32)
if err != nil {
return time.Time{}, err
}
hour = int(newHour)
minute = 0
ref = ref[i+2:]
}

// Xpm or XXpm
if rePM.MatchString(rawRef) {
i := strings.Index(ref, "pm")
newHour, err := strconv.ParseInt(ref[:i], 10, 32)
if err != nil {
return time.Time{}, err
}
if newHour > 24 {
return time.Time{}, errors.NewInvalidParamsError(fmt.Errorf("unknown time reference %s", rawRef))
}
hour = int((newHour + 12) % 24)
minute = 0
ref = ref[i+2:]
}

if strings.HasPrefix(ref, "noon") {
hour, minute = 12, 0
ref = ref[4:]
} else if strings.HasPrefix(ref, "midnight") {
hour, minute = 0, 0
ref = ref[8:]
} else if strings.HasPrefix(ref, "teatime") {
hour, minute = 16, 0
ref = ref[7:]
}

refDate = time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())

// Day reference
if ref == "yesterday" {
refDate = refDate.Add(time.Hour * -24)
} else if ref == "tomorrow" {
refDate = refDate.Add(time.Hour * 24)
} else if ref == "today" {
return refDate, nil
} else if reMonthAndDay.MatchString(ref) { // monthDay (january10, may6, may06 etc.)
day := 0
monthString := ""
if val, err := strconv.ParseInt(ref[len(ref)-2:], 10, 64); err == nil {
day = int(val)
monthString = ref[:len(ref)-2]
} else if val, err := strconv.ParseInt(ref[len(ref)-1:], 10, 64); err == nil {
day = int(val)
monthString = ref[:len(ref)-1]
} else {
return refDate, errors.NewInvalidParamsError(fmt.Errorf("day of month required after month name"))
}
refDate = time.Date(refDate.Year(), time.Month(months[monthString]), day, hour, minute, 0, 0, refDate.Location())
} else if reDayOfWeek.MatchString(ref) { // DayOfWeek (Monday, etc)
refDate = refDate.Add(getWeekDayOffset(ref, refDate))
} else if ref != "" {
return time.Time{}, errors.NewInvalidParamsError(fmt.Errorf("unknown time reference %s", rawRef))
}

return refDate, nil
}

// ParseDuration parses a duration
func ParseDuration(s string) (time.Duration, error) {
if m := reRelativeTime.FindStringSubmatch(s); len(m) != 0 {
Expand All @@ -125,3 +317,25 @@ func ParseDuration(s string) (time.Duration, error) {

return 0, errors.NewInvalidParamsError(fmt.Errorf("invalid relative time %s", s))
}

// ParseOffset parses a time offset (like a duration, but can be 0 or positive)
func ParseOffset(s string, now time.Time) (time.Duration, error) {
if s == "" {
return time.Duration(0), nil
}

if m := reTimeOffset.FindStringSubmatch(s); len(m) != 0 {
parity := 1
if m[1] == "-" {
parity = -1
}
timeInteger, err := strconv.ParseInt(m[2], 10, 32)
if err != nil {
return 0, errors.NewInvalidParamsError(fmt.Errorf("invalid time offset %v", err))
}
period := periods[strings.ToLower(m[3])]
return period * time.Duration(timeInteger) * time.Duration(parity), nil
}

return 0, errors.NewInvalidParamsError(fmt.Errorf("invalid time offset %s", s))
}
103 changes: 103 additions & 0 deletions src/query/graphite/graphite/timespec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ func TestParseTime(t *testing.T) {
{"20140307", time.Date(2014, time.March, 7, 0, 0, 0, 0, time.UTC)},
{"140307", time.Date(2014, time.March, 7, 0, 0, 0, 0, time.UTC)},
{"1432581620", time.Date(2015, time.May, 25, 19, 20, 20, 0, time.UTC)},
{"now", time.Date(2013, time.April, 3, 4, 5, 0, 0, time.UTC)},
{"midnight", time.Date(2013, time.April, 3, 0, 0, 0, 0, time.UTC)},
{"midnight+1h", time.Date(2013, time.April, 3, 1, 0, 0, 0, time.UTC)},
{"april08+1d", time.Date(2013, time.April, 9, 4, 5, 0, 0, time.UTC)},
{"monday", time.Date(2013, time.April, 1, 4, 5, 0, 0, time.UTC)},
{"9am monday", time.Date(2013, time.April, 1, 9, 0, 0, 0, time.UTC)},
{"9am monday +5min", time.Date(2013, time.April, 1, 9, 5, 0, 0, time.UTC)},
{"9:00am monday +5min", time.Date(2013, time.April, 1, 9, 5, 0, 0, time.UTC)},
}

for _, test := range tests {
Expand Down Expand Up @@ -74,6 +82,27 @@ func TestParseDuration(t *testing.T) {
}
}

func TestParseOffset(t *testing.T) {
tests := []struct {
timespec string
expectedDuration time.Duration
}{
{"-4h", -4 * time.Hour},
{"-35MIN", -35 * time.Minute},
{"-10s", -10 * time.Second},
{"+4h", 4 * time.Hour},
{"+35MIN", 35 * time.Minute},
{"+10s", 10 * time.Second},
}

for _, test := range tests {
s := test.timespec
parsed, err := ParseOffset(s, relativeTo)
assert.Nil(t, err, "error parsing %s", s)
assert.Equal(t, test.expectedDuration, parsed, "incorrect parsed value for %s", s)
}
}

func TestParseDurationErrors(t *testing.T) {
tests := []string{
"10s",
Expand Down Expand Up @@ -104,3 +133,77 @@ func TestAbsoluteOffset(t *testing.T) {
assert.Equal(t, test.expectedTime, parsed, "incorrect parsed value for %s", s)
}
}

// April 3 2013, 4:05
func TestParseTimeReference(t *testing.T) {
tests := []struct {
ref string
expectedTime time.Time
}{
{"", relativeTo},
{"now", relativeTo},
{"8:50", relativeTo.Add((time.Hour * 4) + (time.Minute * 45))},
{"8:50am", relativeTo.Add((time.Hour * 4) + (time.Minute * 45))},
{"8:50pm", relativeTo.Add((time.Hour * 16) + (time.Minute * 45))},
{"8am", relativeTo.Add((time.Hour * 3) + (time.Minute * 55))},
{"10pm", relativeTo.Add((time.Hour * 17) + (time.Minute * 55))},
{"noon", relativeTo.Add((time.Hour * 7) + (time.Minute * 55))},
{"midnight", relativeTo.Add((time.Hour * -4) + (time.Minute * -5))},
{"teatime", relativeTo.Add((time.Hour * 12) + (time.Minute * -5))},
{"yesterday", relativeTo.Add(time.Hour * 24 * -1)},
{"today", relativeTo},
{"tomorrow", relativeTo.Add(time.Hour * 24)},
{"04/24/13", relativeTo.Add((time.Hour * 24 * 21) + (time.Hour * -4) + (time.Minute * -5))},
{"04/24/2013", relativeTo.Add((time.Hour * 24 * 21) + (time.Hour * -4) + (time.Minute * -5))},
{"20130424", relativeTo.Add((time.Hour * 24 * 21) + (time.Hour * -4) + (time.Minute * -5))},
{"may6", relativeTo.Add(time.Hour * 24 * 33)},
{"may06", relativeTo.Add(time.Hour * 24 * 33)},
{"december17", relativeTo.Add(time.Hour * 24 * 258)},
{"monday", relativeTo.Add(time.Hour * 24 * -2)},
// strings have whitespace removed before being passed into ParseTimeReference
{"8ammonday", relativeTo.Add((time.Hour * 24 * -2) + (time.Hour * 3) + (time.Minute * 55))},
{"10pmyesterday", relativeTo.Add((time.Hour * 17) + (time.Minute * 55) + (time.Hour * 24 * -1))},
}

for _, test := range tests {
ref := test.ref
parsed, err := ParseTimeReference(ref, relativeTo)
assert.Nil(t, err, "error parsing %s", ref)
assert.Equal(t, test.expectedTime, parsed, "incorrect parsed value for %s", ref)
}
}

func TestParseTimeReferenceErrors(t *testing.T) {
tests := []string{
"january800",
"january",
"random",
":",
"8:5",
"99pm",
"12:77pm",
"23:00pm",
"10:00pm6am",
}

for _, test := range tests {
parsed, err := ParseTimeReference(test, relativeTo)
assert.Error(t, err)
assert.Equal(t, time.Time{}, parsed)
}
}

func TestParseOffsetErrors(t *testing.T) {
tests := []string{
"something",
"1m",
"10",
"month",
}

for _, test := range tests {
parsed, err := ParseOffset(test, relativeTo)
assert.Error(t, err)
assert.Equal(t, time.Duration(0), parsed)
}
}

0 comments on commit db4e9a3

Please sign in to comment.