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

timeutil: preserve field/type information when parsing ISODuration #3272

Merged
merged 1 commit into from
Sep 11, 2023
Merged
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
6 changes: 4 additions & 2 deletions graphql2/graphqlapp/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ func (a *AlertLogEntry) ID(ctx context.Context, obj *alertlog.Entry) (int, error
}

func (a *AlertMetric) TimeToAck(ctx context.Context, obj *alertmetrics.Metric) (*timeutil.ISODuration, error) {
return &timeutil.ISODuration{TimePart: obj.TimeToAck}, nil
dur := timeutil.ISODurationFromTime(obj.TimeToAck)
return &dur, nil
}

func (a *AlertMetric) TimeToClose(ctx context.Context, obj *alertmetrics.Metric) (*timeutil.ISODuration, error) {
return &timeutil.ISODuration{TimePart: obj.TimeToClose}, nil
dur := timeutil.ISODurationFromTime(obj.TimeToClose)
return &dur, nil
}

func (a *AlertLogEntry) Timestamp(ctx context.Context, obj *alertlog.Entry) (*time.Time, error) {
Expand Down
8 changes: 4 additions & 4 deletions graphql2/graphqlapp/messagelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,10 @@ func (q *MessageLogConnectionStats) TimeSeries(ctx context.Context, opts *notifi
opts = &notification.SearchOptions{}
}

dur := input.BucketDuration.TimePart
dur += time.Duration(input.BucketDuration.Days) * 24 * time.Hour
dur += time.Duration(input.BucketDuration.Months) * 30 * 24 * time.Hour
dur += time.Duration(input.BucketDuration.Years) * 365 * 24 * time.Hour
dur := input.BucketDuration.TimePart()
dur += time.Duration(input.BucketDuration.Days()) * 24 * time.Hour
dur += time.Duration(input.BucketDuration.MonthPart) * 30 * 24 * time.Hour
dur += time.Duration(input.BucketDuration.YearPart) * 365 * 24 * time.Hour

var origin time.Time
if input.BucketOrigin != nil {
Expand Down
93 changes: 60 additions & 33 deletions util/timeutil/isoduration.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,41 @@ import (
)

// ISODuration represents an ISO duration string.
// The time components are combined, and the weeks component
// is interpreted as a shorthand for 7 days.
// It is a subset of the ISO 8601 Durations format (https://en.wikipedia.org/wiki/ISO_8601#Durations).
//
// Notably, it does not support negative values, and fractional values are only supported for seconds.
type ISODuration struct {
Years, Months, Days int
TimePart time.Duration
YearPart int
MonthPart int
WeekPart int
DayPart int
HourPart int
MinutePart int
SecondPart float64
}

// ISODurationFromTime returns an ISODuration with the given time.Duration as the time part.
func ISODurationFromTime(t time.Duration) ISODuration {
var dur ISODuration
dur.SetTimePart(t)
return dur
}

// Days returns the total number of days in the duration (adds DayPart and WeekPart appropriately).
func (dur ISODuration) Days() int {
return dur.DayPart + (dur.WeekPart * 7)
}

// TimePart returns the time portion of the duration as a time.Duration.
func (dur ISODuration) TimePart() time.Duration {
return time.Duration(dur.HourPart)*time.Hour + time.Duration(dur.MinutePart)*time.Minute + time.Duration(dur.SecondPart*float64(time.Second))
}

// SetTimePart sets the time portion of the duration from a time.Duration.
func (dur *ISODuration) SetTimePart(timeDur time.Duration) {
dur.HourPart = int(timeDur.Hours())
dur.MinutePart = int(timeDur.Minutes()) % 60
dur.SecondPart = timeDur.Seconds() - float64(dur.HourPart*60*60+dur.MinutePart*60)
}

var zeroDur ISODuration
Expand All @@ -29,7 +59,7 @@ func (dur ISODuration) IsZero() bool {

// AddTo adds the duration to the given time.
func (dur ISODuration) AddTo(t time.Time) time.Time {
return t.AddDate(dur.Years, dur.Months, dur.Days).Add(dur.TimePart)
return t.AddDate(dur.YearPart, dur.MonthPart, dur.Days()).Add(dur.TimePart())
}

// LessThan returns true if the duration is less than the other duration from the given reference time.
Expand All @@ -51,38 +81,35 @@ func (dur ISODuration) String() string {
var b strings.Builder
b.WriteRune('P')

if dur.Years > 0 {
fmt.Fprintf(&b, "%dY", dur.Years)
if dur.YearPart > 0 {
fmt.Fprintf(&b, "%dY", dur.YearPart)
}
if dur.Months > 0 {
fmt.Fprintf(&b, "%dM", dur.Months)
if dur.MonthPart > 0 {
fmt.Fprintf(&b, "%dM", dur.MonthPart)
}
if dur.Days/7 > 0 {
fmt.Fprintf(&b, "%dW", dur.Days/7)
dur.Days %= 7
if dur.WeekPart > 0 {
fmt.Fprintf(&b, "%dW", dur.WeekPart)
}
if dur.Days > 0 {
fmt.Fprintf(&b, "%dD", dur.Days)
if dur.DayPart > 0 {
fmt.Fprintf(&b, "%dD", dur.DayPart)
}

if dur.TimePart == 0 {
if dur.SecondPart <= 0 && dur.MinutePart <= 0 && dur.HourPart <= 0 {
return b.String()
}

b.WriteRune('T')

if dur.TimePart/time.Hour > 0 {
fmt.Fprintf(&b, "%dH", dur.TimePart/time.Hour)
dur.TimePart %= time.Hour
if dur.HourPart > 0 {
fmt.Fprintf(&b, "%dH", dur.HourPart)
}

if dur.TimePart/time.Minute > 0 {
fmt.Fprintf(&b, "%dM", dur.TimePart/time.Minute)
dur.TimePart %= time.Minute
if dur.MinutePart > 0 {
fmt.Fprintf(&b, "%dM", dur.MinutePart)
}

if dur.TimePart.Seconds() > 0 {
sec := dur.TimePart.Seconds()
if dur.SecondPart > 0 {
sec := dur.SecondPart
// round to microseconds
sec = math.Round(sec*1e6) / 1e6
fmt.Fprintf(&b, "%gS", sec)
Expand Down Expand Up @@ -117,43 +144,43 @@ func ParseISODuration(s string) (d ISODuration, err error) {
if err != nil {
return zeroDur, err
}
d.Years += val
d.YearPart = val
case "month":
val, err := strconv.Atoi(m)
if err != nil {
return zeroDur, err
}
d.Months += val
d.MonthPart = val
case "week":
val, err := strconv.Atoi(m)
if err != nil {
return zeroDur, err
}
d.Days += (val * 7)
d.WeekPart = val
case "day":
val, err := strconv.Atoi(m)
if err != nil {
return zeroDur, err
}
d.Days += val
d.DayPart = val
case "hour":
val, err := time.ParseDuration(m + "h")
val, err := strconv.Atoi(m)
if err != nil {
return zeroDur, err
}
d.TimePart += val
d.HourPart = val
case "minute":
val, err := time.ParseDuration(m + "m")
val, err := strconv.Atoi(m)
if err != nil {
return zeroDur, err
}
d.TimePart += val
d.MinutePart = val
case "second":
val, err := time.ParseDuration(strings.ReplaceAll(m, ",", ".") + "s")
val, err := strconv.ParseFloat(strings.ReplaceAll(m, ",", "."), 64)
if err != nil {
return zeroDur, err
}
d.TimePart += val
d.SecondPart = val
default:
return zeroDur, fmt.Errorf("unknown field %s", name)
}
Expand Down
112 changes: 70 additions & 42 deletions util/timeutil/isoduration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,23 @@ func TestISODuration_String(t *testing.T) {
assert.Equal(t, exp, dur.String())
}

check("P1Y", ISODuration{Years: 1})
check("P1Y4M", ISODuration{Years: 1, Months: 4})
check("P1D", ISODuration{Days: 1})
check("PT1H", ISODuration{TimePart: time.Hour})
check("P1YT0.1S", ISODuration{Years: 1, TimePart: time.Millisecond * 100})
check("P1Y", ISODuration{YearPart: 1})
check("P1Y4M", ISODuration{YearPart: 1, MonthPart: 4})
check("P1D", ISODuration{DayPart: 1})
check("PT1H", ISODuration{HourPart: 1})
check("P1YT0.1S", ISODuration{YearPart: 1, SecondPart: 0.1})

check("P1Y2M3W4DT5H6M7S", ISODuration{
Years: 1,
Months: 2,
Days: 25,
YearPart: 1,
MonthPart: 2,
WeekPart: 3,
DayPart: 4,

TimePart: 5*time.Hour + 6*time.Minute + 7*time.Second,
HourPart: 5,
MinutePart: 6,
SecondPart: 7,
})
check("P1Y2W1D", ISODuration{Years: 1, Days: 15})
check("P1Y15D", ISODuration{YearPart: 1, DayPart: 15})
check("P0D", ISODuration{}) // must contain at least one element
}

Expand All @@ -45,88 +48,113 @@ func TestParseISODuration(t *testing.T) {
assert.Equal(t, res, exp, desc)
}

dur := func(s string) time.Duration {
res, _ := time.ParseDuration(s)
return res
}

check("year only", "P12345Y", ISODuration{
Years: 12345,
YearPart: 12345,
})

check("one month", "P1M", ISODuration{
Months: 1,
MonthPart: 1,
})

check("one minute", "PT1M", ISODuration{
TimePart: dur("60s"),
MinutePart: 1,
})

check("one month and 1 minute", "P1MT1M", ISODuration{
Months: 1,
TimePart: dur("60s"),
MonthPart: 1,
MinutePart: 1,
})

check("two days with leading zeros", "P0002D", ISODuration{
// If a time element in a defined representation has a defined length, then leading zeros shall be used as required
Days: 2,
DayPart: 2,
})

check("mixed", "P3Y6M14DT12H30M5S", ISODuration{
Years: 3,
Months: 6,
Days: 14,
TimePart: dur("12h30m5s"),
YearPart: 3,
MonthPart: 6,
DayPart: 14,
HourPart: 12,
MinutePart: 30,
SecondPart: 5,
})

check("mixed with week", "P3Y6M2W14DT12H30M5S", ISODuration{
Years: 3,
Months: 6,
Days: 2*7 + 14,
TimePart: dur("12h30m5s"),
YearPart: 3,
MonthPart: 6,
WeekPart: 2,
DayPart: 14,
HourPart: 12,
MinutePart: 30,
SecondPart: 5,
})

check("time without seconds", "PT1H22M", ISODuration{
// The lowest order components may be omitted to represent duration with reduced accuracy.
TimePart: dur("1h22m"),
HourPart: 1,
MinutePart: 22,
})

check("time without minutes", "PT1H22S", ISODuration{
TimePart: dur("1h22s"),
HourPart: 1,
SecondPart: 22,
})

check("date only", "P1997Y11M26D", ISODuration{
// The designator [T] shall be absent if all of the time components are absent.
Years: 1997,
Months: 11,
Days: 26,
YearPart: 1997,
MonthPart: 11,
DayPart: 26,
})

check("week only", "P12W", ISODuration{
Days: 12 * 7,
WeekPart: 12,
})

check("fractional seconds", "PT0.1S", ISODuration{
TimePart: dur("100ms"),
SecondPart: 0.1,
})

check("fractional seconds with comma", "PT0,1S", ISODuration{
// comma [,] is preferred over full stop [.]
TimePart: dur("100ms"),
SecondPart: 0.1,
})

check("one and a half seconds", "PT1,5S", ISODuration{
TimePart: dur("1.5s"),
SecondPart: 1.5,
})

check("full fractional", "P23Y0M2W012DT1H1M0123.0522S", ISODuration{
Years: 23,
Months: 0,
Days: 2*7 + 12,
TimePart: dur("1h1m123.0522s"),
YearPart: 23,
WeekPart: 2,
DayPart: 12,

HourPart: 1,
MinutePart: 1,
SecondPart: 123.0522,
})
}

func TestISODurationFromTime(t *testing.T) {
check := func(desc string, dur time.Duration, exp string) {
t.Helper()

isoDur := ISODurationFromTime(dur)
assert.Equal(t, dur, isoDur.TimePart(), "TimePart(): "+desc)
assert.Equal(t, exp, isoDur.String(), desc)
}

check("1 second", time.Second, "PT1S")
check("1 minute", time.Minute, "PT1M")
check("1 hour", time.Hour, "PT1H")
check("24 hours", 24*time.Hour, "PT24H")

// fractional
check("1.5 seconds", 1500*time.Millisecond, "PT1.5S")
check("1.5 minutes", 90*time.Second, "PT1M30S")
check("1.5 hours", 90*time.Minute, "PT1H30M")
}

func TestParseISODurationErrors(t *testing.T) {
check := func(desc string, iso string) {
t.Helper()
Expand Down
Loading