From 7bc83b092aeb548f59f88147ccdffb1f53c11472 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 23 Sep 2024 12:35:02 -0400 Subject: [PATCH 01/22] feat: Add Time configtype --- configtype/duration.go | 8 +- configtype/time.go | 107 ++++++++++++++++++++++++++ configtype/time_test.go | 161 ++++++++++++++++++++++++++++++++++++++++ configtype/util.go | 7 ++ 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 configtype/time.go create mode 100644 configtype/time_test.go create mode 100644 configtype/util.go diff --git a/configtype/duration.go b/configtype/duration.go index dc8789ce05..b9ea125730 100644 --- a/configtype/duration.go +++ b/configtype/duration.go @@ -2,11 +2,17 @@ package configtype import ( "encoding/json" + "regexp" "time" "github.com/invopop/jsonschema" ) +var ( + durationPattern = `^[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+$` // copied from time.ParseDuration + durationRegexp = regexp.MustCompile(durationPattern) +) + // Duration is a wrapper around time.Duration that should be used in config // when a duration type is required. We wrap the time.Duration type so that // the spec can be extended in the future to support other types of durations @@ -24,7 +30,7 @@ func NewDuration(d time.Duration) Duration { func (Duration) JSONSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "string", - Pattern: `^[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+$`, // copied from time.ParseDuration + Pattern: durationPattern, // copied from time.ParseDuration Title: "CloudQuery configtype.Duration", } } diff --git a/configtype/time.go b/configtype/time.go new file mode 100644 index 0000000000..163c2a7cf8 --- /dev/null +++ b/configtype/time.go @@ -0,0 +1,107 @@ +package configtype + +import ( + "encoding/json" + "fmt" + "regexp" + "time" + + "github.com/invopop/jsonschema" +) + +type NowFunc func() time.Time + +// Time is a wrapper around time.Time that should be used in config +// when a time type is required. We wrap the time.Time type so that +// the spec can be extended in the future to support other types of times +type Time struct { + time time.Time + duration time.Duration +} + +func NewTime(t time.Time) Time { + return Time{ + time: t, + } +} + +func NewRelativeTime(d time.Duration) Time { + return Time{ + duration: d, + } +} + +var ( + timeRFC3339Pattern = `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.(\d{3}|\d{6}|\d{9}))?(Z|((-|\+)\d{2}:\d{2}))$` + timeRFC3339Regexp = regexp.MustCompile(timeRFC3339Pattern) + + datePattern = `^\d{4}-\d{2}-\d{2}$` + dateRegexp = regexp.MustCompile(datePattern) + + timePattern = patternCases(timeRFC3339Pattern, datePattern, durationPattern) +) + +func (Time) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "string", + Pattern: timePattern, + Title: "CloudQuery configtype.Time", + } +} + +func (t *Time) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + var err error + switch { + case timeRFC3339Regexp.MatchString(s): + t.time, err = time.Parse(time.RFC3339, s) + case dateRegexp.MatchString(s): + t.time, err = time.Parse(time.DateOnly, s) + case durationRegexp.MatchString(s): + t.duration, err = time.ParseDuration(s) + default: + return fmt.Errorf("invalid time format: %s", s) + } + + if err != nil { + return err + } + + return nil +} + +func (d *Time) MarshalJSON() ([]byte, error) { + if !d.time.IsZero() { + return json.Marshal(d.time) + } + + return json.Marshal(d.duration.String()) +} + +func (t *Time) Time(nowFunc NowFunc) time.Time { + if !t.time.IsZero() { + return t.time + } + + return nowFunc().Add(t.duration) +} + +func (t Time) IsRelative() bool { + return t.time.IsZero() +} + +// Equal compares two Time structs. Note that relative and fixed times are never equal +func (t Time) Equal(other Time) bool { + return t.time.Equal(other.time) && t.duration == other.duration +} + +func (t Time) String() string { + if !t.time.IsZero() { + return t.time.String() + } + return t.duration.String() +} diff --git a/configtype/time_test.go b/configtype/time_test.go new file mode 100644 index 0000000000..66099fe0e3 --- /dev/null +++ b/configtype/time_test.go @@ -0,0 +1,161 @@ +package configtype_test + +import ( + "encoding/json" + "math/rand" + "testing" + "time" + + "github.com/cloudquery/plugin-sdk/v4/configtype" + "github.com/cloudquery/plugin-sdk/v4/plugin" + "github.com/google/go-cmp/cmp" + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/require" +) + +func TestTime(t *testing.T) { + nowTime, _ := time.Parse(time.RFC3339Nano, time.RFC3339Nano) + now := func() time.Time { + return nowTime + } + + cases := []struct { + give string + want time.Time + }{ + {"1ns", nowTime.Add(1 * time.Nanosecond)}, + {"20s", nowTime.Add(20 * time.Second)}, + {"-50m30s", nowTime.Add(-50*time.Minute - 30*time.Second)}, + {"2021-09-01T00:00:00Z", time.Date(2021, 9, 1, 0, 0, 0, 0, time.UTC)}, + {"2021-09-01T00:00:00.123Z", time.Date(2021, 9, 1, 0, 0, 0, 123000000, time.UTC)}, + {"2021-09-01T00:00:00.123456Z", time.Date(2021, 9, 1, 0, 0, 0, 123456000, time.UTC)}, + {"2021-09-01T00:00:00.123456789Z", time.Date(2021, 9, 1, 0, 0, 0, 123456789, time.UTC)}, + {"2021-09-01T00:00:00.123+02:00", time.Date(2021, 9, 1, 0, 0, 0, 123000000, time.FixedZone("CET", 2*60*60))}, + {"2021-09-01T00:00:00.123456+02:00", time.Date(2021, 9, 1, 0, 0, 0, 123456000, time.FixedZone("CET", 2*60*60))}, + {"2021-09-01T00:00:00.123456789+02:00", time.Date(2021, 9, 1, 0, 0, 0, 123456789, time.FixedZone("CET", 2*60*60))}, + {"2021-09-01", time.Date(2021, 9, 1, 0, 0, 0, 0, time.UTC)}, + } + for _, tc := range cases { + var d configtype.Time + err := json.Unmarshal([]byte(`"`+tc.give+`"`), &d) + if err != nil { + t.Fatalf("error calling Unmarshal(%q): %v", tc.give, err) + } + computedTime := d.Time(now) + if !computedTime.Equal(tc.want) { + t.Errorf("Unmarshal(%q) = %v, want %v", tc.give, computedTime, tc.want) + } + } +} + +func TestTime_Comparability(t *testing.T) { + tim1 := time.Now() + tim2 := tim1.Add(1 * time.Second) + + cases := []struct { + give configtype.Time + compare configtype.Time + equal bool + }{ + {configtype.NewRelativeTime(0), configtype.NewRelativeTime(0), true}, + {configtype.NewRelativeTime(0), configtype.NewRelativeTime(1), false}, + {configtype.NewTime(tim1), configtype.NewTime(tim1), false}, + {configtype.NewTime(tim1), configtype.NewTime(tim2), true}, + // relative and fixed times are never equal + {configtype.NewTime(tim1), configtype.NewRelativeTime(1), false}, + } + for _, tc := range cases { + if (tc.give == tc.compare) != tc.equal { + t.Errorf("comparing %v and %v should be %v", tc.give, tc.compare, tc.equal) + } + + diff := cmp.Diff(tc.give, tc.compare) + if tc.equal && diff != "" { + t.Errorf("comparing %v and %v should be equal, but diff is %s", tc.give, tc.compare, diff) + } else if !tc.equal && diff == "" { + t.Errorf("comparing %v and %v should not be equal, but diff is empty", tc.give, tc.compare) + } + } +} + +func TestTime_JSONSchema(t *testing.T) { + sc := (&jsonschema.Reflector{RequiredFromJSONSchemaTags: true}).Reflect(configtype.Time{}) + schema, err := json.MarshalIndent(sc, "", " ") + require.NoError(t, err) + + validator, err := plugin.JSONSchemaValidator(string(schema)) + require.NoError(t, err) + + type testCase struct { + Name string + Spec string + Err bool + } + + for _, tc := range append([]testCase{ + { + Name: "empty", + Err: true, + Spec: `""`, + }, + { + Name: "null", + Err: true, + Spec: `null`, + }, + { + Name: "bad type", + Err: true, + Spec: `false`, + }, + { + Name: "bad format", + Err: true, + Spec: `false`, + }, + }, + func() []testCase { + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + const ( + cases = 20 + maxDur = int64(100 * time.Hour) + maxDurHalf = maxDur / 2 + ) + now := time.Now() + var result []testCase + for i := 0; i < cases; i++ { + val := rnd.Int63n(maxDur) - maxDurHalf + dur := configtype.NewRelativeTime(time.Duration(val)) + + durationData, err := dur.MarshalJSON() + require.NoError(t, err) + result = append(result, testCase{ + Name: string(durationData), + Spec: string(durationData), + }) + + tim := configtype.NewTime(now.Add(time.Duration(val))) + + timeData, err := tim.MarshalJSON() + require.NoError(t, err) + result = append(result, testCase{ + Name: string(timeData), + Spec: string(timeData), + }) + } + + return result + }()..., + ) { + t.Run(tc.Name, func(t *testing.T) { + var val any + err := json.Unmarshal([]byte(tc.Spec), &val) + require.NoError(t, err) + if tc.Err { + require.Error(t, validator.Validate(val)) + } else { + require.NoError(t, validator.Validate(val)) + } + }) + } +} diff --git a/configtype/util.go b/configtype/util.go new file mode 100644 index 0000000000..0cd4444223 --- /dev/null +++ b/configtype/util.go @@ -0,0 +1,7 @@ +package configtype + +import "strings" + +func patternCases(cases ...string) string { + return "(" + strings.Join(cases, "|") + ")" +} From 6c6e1a6e9b09b48fafb09be6ca696b3d97e0d66d Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 23 Sep 2024 12:38:27 -0400 Subject: [PATCH 02/22] cleanup --- configtype/duration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configtype/duration.go b/configtype/duration.go index b9ea125730..e97dd4ab36 100644 --- a/configtype/duration.go +++ b/configtype/duration.go @@ -30,7 +30,7 @@ func NewDuration(d time.Duration) Duration { func (Duration) JSONSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "string", - Pattern: durationPattern, // copied from time.ParseDuration + Pattern: durationPattern, Title: "CloudQuery configtype.Duration", } } From 34a31d62e03157eaf2b965a9f489b9b612605d46 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 23 Sep 2024 12:41:21 -0400 Subject: [PATCH 03/22] return zero time on nil Time --- configtype/time.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/configtype/time.go b/configtype/time.go index 163c2a7cf8..0995271f57 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -83,6 +83,10 @@ func (d *Time) MarshalJSON() ([]byte, error) { } func (t *Time) Time(nowFunc NowFunc) time.Time { + if t == nil { + return time.Time{} + } + if !t.time.IsZero() { return t.time } From 93f24497a72dd26b3302142f8eca9a5c77ee5cf3 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 23 Sep 2024 12:55:30 -0400 Subject: [PATCH 04/22] add time type and fix zero value --- configtype/time.go | 66 ++++++++++++++++++++++++++++++----------- configtype/time_test.go | 8 +++-- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/configtype/time.go b/configtype/time.go index 0995271f57..a5ec294aef 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -11,28 +11,39 @@ import ( type NowFunc func() time.Time +type timeType int + +const ( + timeTypeZero timeType = iota + timeTypeFixed + timeTypeRelative +) + // Time is a wrapper around time.Time that should be used in config // when a time type is required. We wrap the time.Time type so that // the spec can be extended in the future to support other types of times type Time struct { + typ timeType time time.Time duration time.Duration } func NewTime(t time.Time) Time { return Time{ + typ: timeTypeFixed, time: t, } } func NewRelativeTime(d time.Duration) Time { return Time{ + typ: timeTypeRelative, duration: d, } } var ( - timeRFC3339Pattern = `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.(\d{3}|\d{6}|\d{9}))?(Z|((-|\+)\d{2}:\d{2}))$` + timeRFC3339Pattern = `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.(\d{1,9}))?(Z|((-|\+)\d{2}:\d{2}))$` timeRFC3339Regexp = regexp.MustCompile(timeRFC3339Pattern) datePattern = `^\d{4}-\d{2}-\d{2}$` @@ -59,9 +70,16 @@ func (t *Time) UnmarshalJSON(b []byte) error { switch { case timeRFC3339Regexp.MatchString(s): t.time, err = time.Parse(time.RFC3339, s) + if t.time == (time.Time{}) { + t.typ = timeTypeZero + } else { + t.typ = timeTypeFixed + } case dateRegexp.MatchString(s): + t.typ = timeTypeFixed t.time, err = time.Parse(time.DateOnly, s) case durationRegexp.MatchString(s): + t.typ = timeTypeRelative t.duration, err = time.ParseDuration(s) default: return fmt.Errorf("invalid time format: %s", s) @@ -74,38 +92,52 @@ func (t *Time) UnmarshalJSON(b []byte) error { return nil } -func (d *Time) MarshalJSON() ([]byte, error) { - if !d.time.IsZero() { - return json.Marshal(d.time) +func (t *Time) MarshalJSON() ([]byte, error) { + switch t.typ { + case timeTypeFixed: + return json.Marshal(t.time) + case timeTypeRelative: + return json.Marshal(t.duration.String()) + default: + return json.Marshal(time.Time{}) } - - return json.Marshal(d.duration.String()) } -func (t *Time) Time(nowFunc NowFunc) time.Time { - if t == nil { +func (t Time) Time(nowFunc NowFunc) time.Time { + switch t.typ { + case timeTypeFixed: + return t.time + case timeTypeRelative: + return nowFunc().Add(t.duration) + default: return time.Time{} } +} - if !t.time.IsZero() { - return t.time - } +func (t Time) IsRelative() bool { + return t.typ == timeTypeRelative +} - return nowFunc().Add(t.duration) +func (t Time) IsZero() bool { + return t.typ == timeTypeZero } -func (t Time) IsRelative() bool { - return t.time.IsZero() +func (t Time) IsFixed() bool { + return t.typ == timeTypeFixed } // Equal compares two Time structs. Note that relative and fixed times are never equal func (t Time) Equal(other Time) bool { - return t.time.Equal(other.time) && t.duration == other.duration + return t.typ == other.typ && t.time.Equal(other.time) && t.duration == other.duration } func (t Time) String() string { - if !t.time.IsZero() { + switch t.typ { + case timeTypeFixed: return t.time.String() + case timeTypeRelative: + return t.duration.String() + default: + return time.Time{}.String() } - return t.duration.String() } diff --git a/configtype/time_test.go b/configtype/time_test.go index 66099fe0e3..a7802e5b2c 100644 --- a/configtype/time_test.go +++ b/configtype/time_test.go @@ -52,6 +52,8 @@ func TestTime_Comparability(t *testing.T) { tim1 := time.Now() tim2 := tim1.Add(1 * time.Second) + var zeroTime configtype.Time + cases := []struct { give configtype.Time compare configtype.Time @@ -59,10 +61,12 @@ func TestTime_Comparability(t *testing.T) { }{ {configtype.NewRelativeTime(0), configtype.NewRelativeTime(0), true}, {configtype.NewRelativeTime(0), configtype.NewRelativeTime(1), false}, - {configtype.NewTime(tim1), configtype.NewTime(tim1), false}, - {configtype.NewTime(tim1), configtype.NewTime(tim2), true}, + {configtype.NewTime(tim1), configtype.NewTime(tim1), true}, + {configtype.NewTime(tim1), configtype.NewTime(tim2), false}, // relative and fixed times are never equal {configtype.NewTime(tim1), configtype.NewRelativeTime(1), false}, + {zeroTime, configtype.NewRelativeTime(0), false}, + {zeroTime, zeroTime, true}, } for _, tc := range cases { if (tc.give == tc.compare) != tc.equal { From 852db5a32b86ea8b403c0789522e4536d9af9fe3 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 23 Sep 2024 12:59:08 -0400 Subject: [PATCH 05/22] function typing --- configtype/time.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/configtype/time.go b/configtype/time.go index a5ec294aef..79a1a1a142 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -9,8 +9,6 @@ import ( "github.com/invopop/jsonschema" ) -type NowFunc func() time.Time - type timeType int const ( @@ -103,7 +101,7 @@ func (t *Time) MarshalJSON() ([]byte, error) { } } -func (t Time) Time(nowFunc NowFunc) time.Time { +func (t Time) Time(nowFunc func() time.Time) time.Time { switch t.typ { case timeTypeFixed: return t.time From 70a90a42c5b42537f19b442a927b3a8f61f8bed4 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 23 Sep 2024 13:21:38 -0400 Subject: [PATCH 06/22] zero handling --- configtype/time.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configtype/time.go b/configtype/time.go index 79a1a1a142..db57c23b0a 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -68,7 +68,7 @@ func (t *Time) UnmarshalJSON(b []byte) error { switch { case timeRFC3339Regexp.MatchString(s): t.time, err = time.Parse(time.RFC3339, s) - if t.time == (time.Time{}) { + if t.time.IsZero() { t.typ = timeTypeZero } else { t.typ = timeTypeFixed From 3111bb1fb1ee5c1c08966c1e4cb0376c0ec6f89b Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 23 Sep 2024 15:57:23 -0400 Subject: [PATCH 07/22] use time.Time instead of func() time.Time --- configtype/time.go | 4 ++-- configtype/time_test.go | 11 ++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/configtype/time.go b/configtype/time.go index db57c23b0a..500aa0809f 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -101,12 +101,12 @@ func (t *Time) MarshalJSON() ([]byte, error) { } } -func (t Time) Time(nowFunc func() time.Time) time.Time { +func (t Time) Time(now time.Time) time.Time { switch t.typ { case timeTypeFixed: return t.time case timeTypeRelative: - return nowFunc().Add(t.duration) + return now.Add(t.duration) default: return time.Time{} } diff --git a/configtype/time_test.go b/configtype/time_test.go index a7802e5b2c..8655d68033 100644 --- a/configtype/time_test.go +++ b/configtype/time_test.go @@ -14,18 +14,15 @@ import ( ) func TestTime(t *testing.T) { - nowTime, _ := time.Parse(time.RFC3339Nano, time.RFC3339Nano) - now := func() time.Time { - return nowTime - } + now, _ := time.Parse(time.RFC3339Nano, time.RFC3339Nano) cases := []struct { give string want time.Time }{ - {"1ns", nowTime.Add(1 * time.Nanosecond)}, - {"20s", nowTime.Add(20 * time.Second)}, - {"-50m30s", nowTime.Add(-50*time.Minute - 30*time.Second)}, + {"1ns", now.Add(1 * time.Nanosecond)}, + {"20s", now.Add(20 * time.Second)}, + {"-50m30s", now.Add(-50*time.Minute - 30*time.Second)}, {"2021-09-01T00:00:00Z", time.Date(2021, 9, 1, 0, 0, 0, 0, time.UTC)}, {"2021-09-01T00:00:00.123Z", time.Date(2021, 9, 1, 0, 0, 0, 123000000, time.UTC)}, {"2021-09-01T00:00:00.123456Z", time.Date(2021, 9, 1, 0, 0, 0, 123456000, time.UTC)}, From 3c5a1acc0a01bb3cf4aa112b9842d33eece482eb Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Tue, 24 Sep 2024 10:07:12 -0400 Subject: [PATCH 08/22] add ParseTime --- configtype/time.go | 47 ++++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/configtype/time.go b/configtype/time.go index 500aa0809f..279f6d6bce 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -40,6 +40,30 @@ func NewRelativeTime(d time.Duration) Time { } } +func ParseTime(s string) (Time, error) { + var t Time + var err error + switch { + case timeRFC3339Regexp.MatchString(s): + t.time, err = time.Parse(time.RFC3339, s) + if t.time.IsZero() { + t.typ = timeTypeZero + } else { + t.typ = timeTypeFixed + } + case dateRegexp.MatchString(s): + t.typ = timeTypeFixed + t.time, err = time.Parse(time.DateOnly, s) + case durationRegexp.MatchString(s): + t.typ = timeTypeRelative + t.duration, err = time.ParseDuration(s) + default: + return t, fmt.Errorf("invalid time format: %s", s) + } + + return t, err +} + var ( timeRFC3339Pattern = `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.(\d{1,9}))?(Z|((-|\+)\d{2}:\d{2}))$` timeRFC3339Regexp = regexp.MustCompile(timeRFC3339Pattern) @@ -60,29 +84,12 @@ func (Time) JSONSchema() *jsonschema.Schema { func (t *Time) UnmarshalJSON(b []byte) error { var s string - if err := json.Unmarshal(b, &s); err != nil { + err := json.Unmarshal(b, &s) + if err != nil { return err } - var err error - switch { - case timeRFC3339Regexp.MatchString(s): - t.time, err = time.Parse(time.RFC3339, s) - if t.time.IsZero() { - t.typ = timeTypeZero - } else { - t.typ = timeTypeFixed - } - case dateRegexp.MatchString(s): - t.typ = timeTypeFixed - t.time, err = time.Parse(time.DateOnly, s) - case durationRegexp.MatchString(s): - t.typ = timeTypeRelative - t.duration, err = time.ParseDuration(s) - default: - return fmt.Errorf("invalid time format: %s", s) - } - + *t, err = ParseTime(s) if err != nil { return err } From 26eea6b98c3614f29ea48a4ef46be03aec8aab06 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Tue, 24 Sep 2024 10:59:04 -0400 Subject: [PATCH 09/22] new duration parsing --- configtype/duration.go | 138 +++++++++++++++++++++++++++++++++--- configtype/duration_test.go | 3 + configtype/time.go | 22 +++--- 3 files changed, 143 insertions(+), 20 deletions(-) diff --git a/configtype/duration.go b/configtype/duration.go index e97dd4ab36..745a342456 100644 --- a/configtype/duration.go +++ b/configtype/duration.go @@ -2,15 +2,29 @@ package configtype import ( "encoding/json" + "fmt" "regexp" + "strconv" "time" "github.com/invopop/jsonschema" ) var ( - durationPattern = `^[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+$` // copied from time.ParseDuration - durationRegexp = regexp.MustCompile(durationPattern) + baseDurationPattern = `^[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+$` // copied from time.ParseDuration + baseDurationRegexp = regexp.MustCompile(baseDurationPattern) + + humanDurationUnits = "seconds?|minutes?|hours?|days?|months?|years?" + + humanDurationPattern = fmt.Sprintf(`^[0-9]+\s+(%s)$`, humanDurationUnits) + humanDurationRegexp = regexp.MustCompile(humanDurationPattern) + + humanRelativeDurationPattern = fmt.Sprintf(`^[0-9]+\s+(%s)\s+(ago|from\s+now)$`, humanDurationUnits) + humanRelativeDurationRegexp = regexp.MustCompile(humanRelativeDurationPattern) + + whitespaceRegexp = regexp.MustCompile(`\s+`) + + fromNowRegexp = regexp.MustCompile(`from\s+now`) ) // Duration is a wrapper around time.Duration that should be used in config @@ -19,6 +33,9 @@ var ( // (e.g. a duration that is specified in days). type Duration struct { duration time.Duration + days int + months int + years int } func NewDuration(d time.Duration) Duration { @@ -27,10 +44,103 @@ func NewDuration(d time.Duration) Duration { } } +func ParseDuration(s string) (Duration, error) { + var d Duration + var err error + switch { + case humanDurationRegexp.MatchString(s): + d, err = parseHumanDuration(s) + case humanRelativeDurationRegexp.MatchString(s): + d, err = parseHumanRelativeDuration(s) + case baseDurationRegexp.MatchString(s): + d.duration, err = time.ParseDuration(s) + default: + return d, fmt.Errorf("invalid duration format: %q", s) + } + + return d, err +} + +func parseHumanDuration(s string) (Duration, error) { + parts := whitespaceRegexp.Split(s, 2) + if len(parts) != 2 { + return Duration{}, fmt.Errorf("invalid duration format: %q", s) + } + + number, err := strconv.Atoi(parts[0]) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: invalid number: %q", s) + } + + d, err := parseHumanDurationUnit(parts[1], 1, number) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: %w", err) + } + + return d, nil +} + +func parseHumanRelativeDuration(s string) (Duration, error) { + parts := whitespaceRegexp.Split(s, 3) + if len(parts) != 3 { + return Duration{}, fmt.Errorf("invalid duration format: %q", s) + } + + number, err := strconv.Atoi(parts[0]) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: invalid number: %q", s) + } + + sign, err := parseHumanDurationSign(parts[2]) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: %w", err) + } + + d, err := parseHumanDurationUnit(parts[1], sign, number) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: %w", err) + } + + return d, nil +} + +func parseHumanDurationUnit(unit string, sign, number int) (Duration, error) { + var d Duration + switch unit { + case "second", "seconds": + d.duration = time.Second * time.Duration(sign) * time.Duration(number) + case "minute", "minutes": + d.duration = time.Minute * time.Duration(sign) * time.Duration(number) + case "hour", "hours": + d.duration = time.Hour * time.Duration(sign) * time.Duration(number) + case "day", "days": + d.days = sign * number + case "month", "months": + d.months = sign * number + case "year", "years": + d.years = sign * number + default: + return Duration{}, fmt.Errorf("invalid unit: %q", unit) + } + + return d, nil +} + +func parseHumanDurationSign(sign string) (int, error) { + switch { + case sign == "ago": + return -1, nil + case fromNowRegexp.MatchString(sign): + return 1, nil + default: + return 0, fmt.Errorf("invalid duration format: invalid sign specifier: %q", sign) + } +} + func (Duration) JSONSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "string", - Pattern: durationPattern, + Pattern: patternCases(baseDurationPattern, humanDurationPattern, humanRelativeDurationPattern), Title: "CloudQuery configtype.Duration", } } @@ -40,22 +150,32 @@ func (d *Duration) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(b, &s); err != nil { return err } - duration, err := time.ParseDuration(s) + + duration, err := ParseDuration(s) if err != nil { return err } - *d = Duration{duration: duration} + + *d = duration return nil } -func (d *Duration) MarshalJSON() ([]byte, error) { - return json.Marshal(d.duration.String()) +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Duration().String()) } func (d *Duration) Duration() time.Duration { - return d.duration + duration := d.duration + duration += time.Duration(d.days) * 24 * time.Hour + duration += time.Duration(d.months) * 30 * 24 * time.Hour + duration += time.Duration(d.years) * 365 * 24 * time.Hour + return duration } func (d Duration) Equal(other Duration) bool { - return d.duration == other.duration + return d == other +} + +func (d Duration) String() string { + return d.Duration().String() } diff --git a/configtype/duration_test.go b/configtype/duration_test.go index fadd3d1104..2b7e03f5a3 100644 --- a/configtype/duration_test.go +++ b/configtype/duration_test.go @@ -21,6 +21,9 @@ func TestDuration(t *testing.T) { {"1ns", 1 * time.Nanosecond}, {"20s", 20 * time.Second}, {"-50m30s", -50*time.Minute - 30*time.Second}, + {"25 minute", 25 * time.Minute}, + {"50 minutes", 50 * time.Minute}, + {"10 years ago", -10 * 365 * 24 * time.Hour}, } for _, tc := range cases { var d configtype.Duration diff --git a/configtype/time.go b/configtype/time.go index 279f6d6bce..8590ee63cc 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -23,7 +23,7 @@ const ( type Time struct { typ timeType time time.Time - duration time.Duration + duration Duration } func NewTime(t time.Time) Time { @@ -36,7 +36,7 @@ func NewTime(t time.Time) Time { func NewRelativeTime(d time.Duration) Time { return Time{ typ: timeTypeRelative, - duration: d, + duration: Duration{duration: d}, } } @@ -54,9 +54,9 @@ func ParseTime(s string) (Time, error) { case dateRegexp.MatchString(s): t.typ = timeTypeFixed t.time, err = time.Parse(time.DateOnly, s) - case durationRegexp.MatchString(s): + case baseDurationRegexp.MatchString(s), humanRelativeDurationRegexp.MatchString(s): t.typ = timeTypeRelative - t.duration, err = time.ParseDuration(s) + t.duration, err = ParseDuration(s) default: return t, fmt.Errorf("invalid time format: %s", s) } @@ -71,7 +71,7 @@ var ( datePattern = `^\d{4}-\d{2}-\d{2}$` dateRegexp = regexp.MustCompile(datePattern) - timePattern = patternCases(timeRFC3339Pattern, datePattern, durationPattern) + timePattern = patternCases(timeRFC3339Pattern, datePattern, baseDurationPattern) ) func (Time) JSONSchema() *jsonschema.Schema { @@ -84,25 +84,25 @@ func (Time) JSONSchema() *jsonschema.Schema { func (t *Time) UnmarshalJSON(b []byte) error { var s string - err := json.Unmarshal(b, &s) - if err != nil { + if err := json.Unmarshal(b, &s); err != nil { return err } - *t, err = ParseTime(s) + tim, err := ParseTime(s) if err != nil { return err } + *t = tim return nil } -func (t *Time) MarshalJSON() ([]byte, error) { +func (t Time) MarshalJSON() ([]byte, error) { switch t.typ { case timeTypeFixed: return json.Marshal(t.time) case timeTypeRelative: - return json.Marshal(t.duration.String()) + return json.Marshal(t.duration) default: return json.Marshal(time.Time{}) } @@ -113,7 +113,7 @@ func (t Time) Time(now time.Time) time.Time { case timeTypeFixed: return t.time case timeTypeRelative: - return now.Add(t.duration) + return now.Add(t.duration.duration).AddDate(t.duration.years, t.duration.months, t.duration.days) default: return time.Time{} } From 274a3038ef7d39be29de15026d0241393cce1221 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Tue, 24 Sep 2024 11:09:50 -0400 Subject: [PATCH 10/22] fix tests and add now --- configtype/duration.go | 5 +++-- configtype/duration_test.go | 2 ++ configtype/time.go | 18 +++++++++++++++--- configtype/time_test.go | 23 ++++++++++++++++++----- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/configtype/duration.go b/configtype/duration.go index 745a342456..20a9ff5c9d 100644 --- a/configtype/duration.go +++ b/configtype/duration.go @@ -14,12 +14,13 @@ var ( baseDurationPattern = `^[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+$` // copied from time.ParseDuration baseDurationRegexp = regexp.MustCompile(baseDurationPattern) - humanDurationUnits = "seconds?|minutes?|hours?|days?|months?|years?" + humanDurationSigns = `ago|from\s+now` + humanDurationUnits = `seconds?|minutes?|hours?|days?|months?|years?` humanDurationPattern = fmt.Sprintf(`^[0-9]+\s+(%s)$`, humanDurationUnits) humanDurationRegexp = regexp.MustCompile(humanDurationPattern) - humanRelativeDurationPattern = fmt.Sprintf(`^[0-9]+\s+(%s)\s+(ago|from\s+now)$`, humanDurationUnits) + humanRelativeDurationPattern = fmt.Sprintf(`^[0-9]+\s+(%s)\s+(%s)$`, humanDurationUnits, humanDurationSigns) humanRelativeDurationRegexp = regexp.MustCompile(humanRelativeDurationPattern) whitespaceRegexp = regexp.MustCompile(`\s+`) diff --git a/configtype/duration_test.go b/configtype/duration_test.go index 2b7e03f5a3..addc7e9c8a 100644 --- a/configtype/duration_test.go +++ b/configtype/duration_test.go @@ -24,6 +24,8 @@ func TestDuration(t *testing.T) { {"25 minute", 25 * time.Minute}, {"50 minutes", 50 * time.Minute}, {"10 years ago", -10 * 365 * 24 * time.Hour}, + {"1 month from now", 30 * 24 * time.Hour}, + {"1 month from now", 30 * 24 * time.Hour}, } for _, tc := range cases { var d configtype.Duration diff --git a/configtype/time.go b/configtype/time.go index 8590ee63cc..af6f6fbf75 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -33,10 +33,10 @@ func NewTime(t time.Time) Time { } } -func NewRelativeTime(d time.Duration) Time { +func NewRelativeTime(d Duration) Time { return Time{ typ: timeTypeRelative, - duration: Duration{duration: d}, + duration: d, } } @@ -44,6 +44,9 @@ func ParseTime(s string) (Time, error) { var t Time var err error switch { + case timeNowRegexp.MatchString(s): + t.typ = timeTypeRelative + t.duration = NewDuration(0) case timeRFC3339Regexp.MatchString(s): t.time, err = time.Parse(time.RFC3339, s) if t.time.IsZero() { @@ -68,10 +71,19 @@ var ( timeRFC3339Pattern = `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.(\d{1,9}))?(Z|((-|\+)\d{2}:\d{2}))$` timeRFC3339Regexp = regexp.MustCompile(timeRFC3339Pattern) + timeNowPattern = `^now$` + timeNowRegexp = regexp.MustCompile(timeNowPattern) + datePattern = `^\d{4}-\d{2}-\d{2}$` dateRegexp = regexp.MustCompile(datePattern) - timePattern = patternCases(timeRFC3339Pattern, datePattern, baseDurationPattern) + timePattern = patternCases( + timeNowPattern, + timeRFC3339Pattern, + datePattern, + baseDurationPattern, + humanRelativeDurationPattern, + ) ) func (Time) JSONSchema() *jsonschema.Schema { diff --git a/configtype/time_test.go b/configtype/time_test.go index 8655d68033..e9a71793d5 100644 --- a/configtype/time_test.go +++ b/configtype/time_test.go @@ -31,6 +31,9 @@ func TestTime(t *testing.T) { {"2021-09-01T00:00:00.123456+02:00", time.Date(2021, 9, 1, 0, 0, 0, 123456000, time.FixedZone("CET", 2*60*60))}, {"2021-09-01T00:00:00.123456789+02:00", time.Date(2021, 9, 1, 0, 0, 0, 123456789, time.FixedZone("CET", 2*60*60))}, {"2021-09-01", time.Date(2021, 9, 1, 0, 0, 0, 0, time.UTC)}, + {"now", now}, + {"2 days from now", now.AddDate(0, 0, 2)}, + {"5 months ago", now.AddDate(0, -5, 0)}, } for _, tc := range cases { var d configtype.Time @@ -56,13 +59,13 @@ func TestTime_Comparability(t *testing.T) { compare configtype.Time equal bool }{ - {configtype.NewRelativeTime(0), configtype.NewRelativeTime(0), true}, - {configtype.NewRelativeTime(0), configtype.NewRelativeTime(1), false}, + {configtype.NewRelativeTime(configtype.NewDuration(0)), configtype.NewRelativeTime(configtype.NewDuration(0)), true}, + {configtype.NewRelativeTime(configtype.NewDuration(0)), configtype.NewRelativeTime(configtype.NewDuration(1)), false}, {configtype.NewTime(tim1), configtype.NewTime(tim1), true}, {configtype.NewTime(tim1), configtype.NewTime(tim2), false}, // relative and fixed times are never equal - {configtype.NewTime(tim1), configtype.NewRelativeTime(1), false}, - {zeroTime, configtype.NewRelativeTime(0), false}, + {configtype.NewTime(tim1), configtype.NewRelativeTime(configtype.NewDuration(1)), false}, + {zeroTime, configtype.NewRelativeTime(configtype.NewDuration(0)), false}, {zeroTime, zeroTime, true}, } for _, tc := range cases { @@ -114,6 +117,16 @@ func TestTime_JSONSchema(t *testing.T) { Err: true, Spec: `false`, }, + { + Name: "not relative duration", + Err: true, + Spec: `"10 days"`, + }, + { + Name: "relative duration", + Err: false, + Spec: `"10 months from now"`, + }, }, func() []testCase { rnd := rand.New(rand.NewSource(time.Now().UnixNano())) @@ -126,7 +139,7 @@ func TestTime_JSONSchema(t *testing.T) { var result []testCase for i := 0; i < cases; i++ { val := rnd.Int63n(maxDur) - maxDurHalf - dur := configtype.NewRelativeTime(time.Duration(val)) + dur := configtype.NewRelativeTime(configtype.NewDuration(time.Duration(val))) durationData, err := dur.MarshalJSON() require.NoError(t, err) From d5ce087a18f31ce111112f23559e972f515e7ba6 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Tue, 24 Sep 2024 12:40:32 -0400 Subject: [PATCH 11/22] complex literals and better marshalling --- configtype/duration.go | 201 ++++++++++++++++++++++-------------- configtype/duration_test.go | 26 +++++ configtype/time.go | 20 ++-- configtype/time_test.go | 15 ++- configtype/util.go | 14 +++ configtype/util_test.go | 8 ++ 6 files changed, 196 insertions(+), 88 deletions(-) create mode 100644 configtype/util_test.go diff --git a/configtype/duration.go b/configtype/duration.go index 20a9ff5c9d..bd947b65dc 100644 --- a/configtype/duration.go +++ b/configtype/duration.go @@ -5,22 +5,31 @@ import ( "fmt" "regexp" "strconv" + "strings" "time" "github.com/invopop/jsonschema" ) var ( - baseDurationPattern = `^[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+$` // copied from time.ParseDuration - baseDurationRegexp = regexp.MustCompile(baseDurationPattern) + numberRegexp = regexp.MustCompile(`^[0-9]+$`) - humanDurationSigns = `ago|from\s+now` - humanDurationUnits = `seconds?|minutes?|hours?|days?|months?|years?` + baseDurationSegmentPattern = `[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+` // copied from time.ParseDuration + baseDurationPattern = fmt.Sprintf(`^%s$`, baseDurationSegmentPattern) + baseDurationRegexp = regexp.MustCompile(baseDurationPattern) - humanDurationPattern = fmt.Sprintf(`^[0-9]+\s+(%s)$`, humanDurationUnits) + humanDurationSignsPattern = `ago|from\s+now` + humanDurationSignsRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, humanDurationSignsPattern)) + + humanDurationUnitsPattern = `nanoseconds?|ns|microseconds?|us|µs|μs|milliseconds?|ms|seconds?|s|minutes?|m|hours?|h|days?|d|months?|M|years?|Y` + humanDurationUnitsRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, humanDurationUnitsPattern)) + + humanDurationSegmentPattern = fmt.Sprintf(`(([0-9]+\s+(%[1]s)|%[2]s))`, humanDurationUnitsPattern, baseDurationSegmentPattern) + + humanDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*$`, humanDurationSegmentPattern) humanDurationRegexp = regexp.MustCompile(humanDurationPattern) - humanRelativeDurationPattern = fmt.Sprintf(`^[0-9]+\s+(%s)\s+(%s)$`, humanDurationUnits, humanDurationSigns) + humanRelativeDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*\s+(%[2]s)$`, humanDurationSegmentPattern, humanDurationSignsPattern) humanRelativeDurationRegexp = regexp.MustCompile(humanRelativeDurationPattern) whitespaceRegexp = regexp.MustCompile(`\s+`) @@ -33,6 +42,8 @@ var ( // the spec can be extended in the future to support other types of durations // (e.g. a duration that is specified in days). type Duration struct { + relative bool + sign int duration time.Duration days int months int @@ -41,101 +52,111 @@ type Duration struct { func NewDuration(d time.Duration) Duration { return Duration{ + sign: 1, duration: d, } } func ParseDuration(s string) (Duration, error) { var d Duration - var err error - switch { - case humanDurationRegexp.MatchString(s): - d, err = parseHumanDuration(s) - case humanRelativeDurationRegexp.MatchString(s): - d, err = parseHumanRelativeDuration(s) - case baseDurationRegexp.MatchString(s): - d.duration, err = time.ParseDuration(s) - default: - return d, fmt.Errorf("invalid duration format: %q", s) - } - return d, err -} + var inValue bool + var value int64 -func parseHumanDuration(s string) (Duration, error) { - parts := whitespaceRegexp.Split(s, 2) - if len(parts) != 2 { - return Duration{}, fmt.Errorf("invalid duration format: %q", s) - } + var inSign bool - number, err := strconv.Atoi(parts[0]) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: invalid number: %q", s) - } + parts := whitespaceRegexp.Split(s, -1) - d, err := parseHumanDurationUnit(parts[1], 1, number) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: %w", err) - } - - return d, nil -} + var err error -func parseHumanRelativeDuration(s string) (Duration, error) { - parts := whitespaceRegexp.Split(s, 3) - if len(parts) != 3 { - return Duration{}, fmt.Errorf("invalid duration format: %q", s) + for _, part := range parts { + if inSign { + if part != "now" { + return Duration{}, fmt.Errorf("invalid duration format: invalid sign specifier: %q", part) + } + + d.sign = 1 + inSign = false + } else if inValue { + if !humanDurationUnitsRegex.MatchString(part) { + return Duration{}, fmt.Errorf("invalid duration format: invalid unit specifier: %q", part) + } + + err = d.addUnit(part, value) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: %w", err) + } + + value = 0 + inValue = false + } else { + switch { + case part == "ago": + if d.sign != 0 { + return Duration{}, fmt.Errorf("invalid duration format: more than one sign specifier") + } + + d.sign = -1 + case part == "from": + if d.sign != 0 { + return Duration{}, fmt.Errorf("invalid duration format: more than one sign specifier") + } + + inSign = true + case numberRegexp.MatchString(part): + value, err = strconv.ParseInt(part, 10, 64) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) + } + + inValue = true + case baseDurationRegexp.MatchString(part): + duration, err := time.ParseDuration(part) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) + } + + d.duration += duration + default: + return Duration{}, fmt.Errorf("invalid duration format: invalid value: %q", part) + } + } } - number, err := strconv.Atoi(parts[0]) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: invalid number: %q", s) - } + d.relative = d.sign != 0 - sign, err := parseHumanDurationSign(parts[2]) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: %w", err) - } - - d, err := parseHumanDurationUnit(parts[1], sign, number) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: %w", err) + if !d.relative { + d.sign = 1 } return d, nil } -func parseHumanDurationUnit(unit string, sign, number int) (Duration, error) { - var d Duration +func (d *Duration) addUnit(unit string, number int64) error { switch unit { + case "nanosecond", "nanoseconds", "ns": + d.duration += time.Nanosecond * time.Duration(number) + case "microsecond", "microseconds", "us", "μs", "µs": + d.duration += time.Microsecond * time.Duration(number) + case "millisecond", "milliseconds": + d.duration += time.Millisecond * time.Duration(number) case "second", "seconds": - d.duration = time.Second * time.Duration(sign) * time.Duration(number) + d.duration += time.Second * time.Duration(number) case "minute", "minutes": - d.duration = time.Minute * time.Duration(sign) * time.Duration(number) + d.duration += time.Minute * time.Duration(number) case "hour", "hours": - d.duration = time.Hour * time.Duration(sign) * time.Duration(number) + d.duration += time.Hour * time.Duration(number) case "day", "days": - d.days = sign * number + d.days += int(number) case "month", "months": - d.months = sign * number + d.months += int(number) case "year", "years": - d.years = sign * number + d.years += int(number) default: - return Duration{}, fmt.Errorf("invalid unit: %q", unit) + return fmt.Errorf("invalid unit: %q", unit) } - return d, nil -} - -func parseHumanDurationSign(sign string) (int, error) { - switch { - case sign == "ago": - return -1, nil - case fromNowRegexp.MatchString(sign): - return 1, nil - default: - return 0, fmt.Errorf("invalid duration format: invalid sign specifier: %q", sign) - } + return nil } func (Duration) JSONSchema() *jsonschema.Schema { @@ -162,14 +183,15 @@ func (d *Duration) UnmarshalJSON(b []byte) error { } func (d Duration) MarshalJSON() ([]byte, error) { - return json.Marshal(d.Duration().String()) + return json.Marshal(d.String()) } -func (d *Duration) Duration() time.Duration { +func (d Duration) Duration() time.Duration { duration := d.duration duration += time.Duration(d.days) * 24 * time.Hour duration += time.Duration(d.months) * 30 * 24 * time.Hour duration += time.Duration(d.years) * 365 * 24 * time.Hour + duration *= time.Duration(d.sign) return duration } @@ -177,6 +199,35 @@ func (d Duration) Equal(other Duration) bool { return d == other } +func (d Duration) humanString(value int, unit string) string { + return fmt.Sprintf("%d %s%s", abs(value), unit, plural(value)) +} + func (d Duration) String() string { - return d.Duration().String() + var parts []string + if d.years != 0 { + parts = append(parts, d.humanString(d.years, "year")) + } + if d.months != 0 { + parts = append(parts, d.humanString(d.months, "month")) + } + if d.days != 0 { + parts = append(parts, d.humanString(d.days, "day")) + } + + if len(parts) == 0 { + return (d.duration * time.Duration(d.sign)).String() + } + + if d.duration != 0 { + parts = append(parts, d.duration.String()) + } + + if d.sign == -1 { + parts = append(parts, "ago") + } else if d.relative { + parts = append(parts, "from now") + } + + return strings.Join(parts, " ") } diff --git a/configtype/duration_test.go b/configtype/duration_test.go index addc7e9c8a..4caea9f8b4 100644 --- a/configtype/duration_test.go +++ b/configtype/duration_test.go @@ -39,6 +39,32 @@ func TestDuration(t *testing.T) { } } +func TestDuration_JSONMarshal(t *testing.T) { + cases := []struct { + give string + want string + }{ + {"1ns", "1ns"}, + {"20s", "20s"}, + {"-50m30s", "-50m30s"}, + {"25 minute", "25m0s"}, + {"50 minutes", "50m0s"}, + {"10 years ago", "10 years ago"}, + {"1 month from now", "1 month from now"}, + {"1 month from now", "1 month from now"}, + } + for _, tc := range cases { + var d configtype.Duration + err := json.Unmarshal([]byte(`"`+tc.give+`"`), &d) + if err != nil { + t.Fatalf("error calling Unmarshal(%q): %v", tc.give, err) + } + if d.String() != tc.want { + t.Errorf("String(%q) = %q, want %v", tc.give, d.String(), tc.want) + } + } +} + func TestComparability(t *testing.T) { cases := []struct { give configtype.Duration diff --git a/configtype/time.go b/configtype/time.go index af6f6fbf75..d09824e231 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -33,13 +33,6 @@ func NewTime(t time.Time) Time { } } -func NewRelativeTime(d Duration) Time { - return Time{ - typ: timeTypeRelative, - duration: d, - } -} - func ParseTime(s string) (Time, error) { var t Time var err error @@ -125,7 +118,14 @@ func (t Time) Time(now time.Time) time.Time { case timeTypeFixed: return t.time case timeTypeRelative: - return now.Add(t.duration.duration).AddDate(t.duration.years, t.duration.months, t.duration.days) + sign := t.duration.sign + return now.Add( + t.duration.duration*time.Duration(sign), + ).AddDate( + t.duration.years*sign, + t.duration.months*sign, + t.duration.days*sign, + ) default: return time.Time{} } @@ -153,6 +153,10 @@ func (t Time) String() string { case timeTypeFixed: return t.time.String() case timeTypeRelative: + if t.duration.Duration() == 0 { + return "now" + } + return t.duration.String() default: return time.Time{}.String() diff --git a/configtype/time_test.go b/configtype/time_test.go index e9a71793d5..25d7acdea2 100644 --- a/configtype/time_test.go +++ b/configtype/time_test.go @@ -59,13 +59,13 @@ func TestTime_Comparability(t *testing.T) { compare configtype.Time equal bool }{ - {configtype.NewRelativeTime(configtype.NewDuration(0)), configtype.NewRelativeTime(configtype.NewDuration(0)), true}, - {configtype.NewRelativeTime(configtype.NewDuration(0)), configtype.NewRelativeTime(configtype.NewDuration(1)), false}, + {must(configtype.ParseTime("now")), must(configtype.ParseTime("now")), true}, + {must(configtype.ParseTime("now")), must(configtype.ParseTime("1 second from now")), false}, {configtype.NewTime(tim1), configtype.NewTime(tim1), true}, {configtype.NewTime(tim1), configtype.NewTime(tim2), false}, // relative and fixed times are never equal - {configtype.NewTime(tim1), configtype.NewRelativeTime(configtype.NewDuration(1)), false}, - {zeroTime, configtype.NewRelativeTime(configtype.NewDuration(0)), false}, + {configtype.NewTime(tim1), must(configtype.ParseTime("now")), false}, + {zeroTime, must(configtype.ParseTime("now")), false}, {zeroTime, zeroTime, true}, } for _, tc := range cases { @@ -127,6 +127,11 @@ func TestTime_JSONSchema(t *testing.T) { Err: false, Spec: `"10 months from now"`, }, + { + Name: "complex relative duration", + Err: false, + Spec: `"10 months 3 days 4h20m from now"`, + }, }, func() []testCase { rnd := rand.New(rand.NewSource(time.Now().UnixNano())) @@ -139,7 +144,7 @@ func TestTime_JSONSchema(t *testing.T) { var result []testCase for i := 0; i < cases; i++ { val := rnd.Int63n(maxDur) - maxDurHalf - dur := configtype.NewRelativeTime(configtype.NewDuration(time.Duration(val))) + dur := must(configtype.ParseTime(time.Duration(val).String())) durationData, err := dur.MarshalJSON() require.NoError(t, err) diff --git a/configtype/util.go b/configtype/util.go index 0cd4444223..0a71d33284 100644 --- a/configtype/util.go +++ b/configtype/util.go @@ -5,3 +5,17 @@ import "strings" func patternCases(cases ...string) string { return "(" + strings.Join(cases, "|") + ")" } + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +func plural(n int) string { + if n != 1 { + return "s" + } + return "" +} diff --git a/configtype/util_test.go b/configtype/util_test.go new file mode 100644 index 0000000000..dbad2eee77 --- /dev/null +++ b/configtype/util_test.go @@ -0,0 +1,8 @@ +package configtype_test + +func must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} From 99244f81fa7bf4f95d745538011793e381a51355 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Tue, 24 Sep 2024 12:42:11 -0400 Subject: [PATCH 12/22] better test --- configtype/duration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/configtype/duration_test.go b/configtype/duration_test.go index 4caea9f8b4..ca87e23aaf 100644 --- a/configtype/duration_test.go +++ b/configtype/duration_test.go @@ -26,6 +26,7 @@ func TestDuration(t *testing.T) { {"10 years ago", -10 * 365 * 24 * time.Hour}, {"1 month from now", 30 * 24 * time.Hour}, {"1 month from now", 30 * 24 * time.Hour}, + {"1 year 2 month 3 days 4 hours 5 minutes 6 seconds from now", (365+60+3)*24*time.Hour + 4*time.Hour + 5*time.Minute + 6*time.Second}, } for _, tc := range cases { var d configtype.Duration From 1eadc7ff3236f4e3f986143e3b6b7bd5f14c8f57 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Tue, 24 Sep 2024 12:59:21 -0400 Subject: [PATCH 13/22] lints --- configtype/duration.go | 67 +++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/configtype/duration.go b/configtype/duration.go index bd947b65dc..7f54e51e59 100644 --- a/configtype/duration.go +++ b/configtype/duration.go @@ -27,14 +27,11 @@ var ( humanDurationSegmentPattern = fmt.Sprintf(`(([0-9]+\s+(%[1]s)|%[2]s))`, humanDurationUnitsPattern, baseDurationSegmentPattern) humanDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*$`, humanDurationSegmentPattern) - humanDurationRegexp = regexp.MustCompile(humanDurationPattern) humanRelativeDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*\s+(%[2]s)$`, humanDurationSegmentPattern, humanDurationSignsPattern) humanRelativeDurationRegexp = regexp.MustCompile(humanRelativeDurationPattern) whitespaceRegexp = regexp.MustCompile(`\s+`) - - fromNowRegexp = regexp.MustCompile(`from\s+now`) ) // Duration is a wrapper around time.Duration that should be used in config @@ -70,14 +67,15 @@ func ParseDuration(s string) (Duration, error) { var err error for _, part := range parts { - if inSign { + switch { + case inSign: if part != "now" { return Duration{}, fmt.Errorf("invalid duration format: invalid sign specifier: %q", part) } d.sign = 1 inSign = false - } else if inValue { + case inValue: if !humanDurationUnitsRegex.MatchString(part) { return Duration{}, fmt.Errorf("invalid duration format: invalid unit specifier: %q", part) } @@ -89,37 +87,34 @@ func ParseDuration(s string) (Duration, error) { value = 0 inValue = false - } else { - switch { - case part == "ago": - if d.sign != 0 { - return Duration{}, fmt.Errorf("invalid duration format: more than one sign specifier") - } - - d.sign = -1 - case part == "from": - if d.sign != 0 { - return Duration{}, fmt.Errorf("invalid duration format: more than one sign specifier") - } - - inSign = true - case numberRegexp.MatchString(part): - value, err = strconv.ParseInt(part, 10, 64) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) - } - - inValue = true - case baseDurationRegexp.MatchString(part): - duration, err := time.ParseDuration(part) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) - } - - d.duration += duration - default: - return Duration{}, fmt.Errorf("invalid duration format: invalid value: %q", part) + case part == "ago": + if d.sign != 0 { + return Duration{}, fmt.Errorf("invalid duration format: more than one sign specifier") + } + + d.sign = -1 + case part == "from": + if d.sign != 0 { + return Duration{}, fmt.Errorf("invalid duration format: more than one sign specifier") + } + + inSign = true + case numberRegexp.MatchString(part): + value, err = strconv.ParseInt(part, 10, 64) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) } + + inValue = true + case baseDurationRegexp.MatchString(part): + duration, err := time.ParseDuration(part) + if err != nil { + return Duration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) + } + + d.duration += duration + default: + return Duration{}, fmt.Errorf("invalid duration format: invalid value: %q", part) } } @@ -199,7 +194,7 @@ func (d Duration) Equal(other Duration) bool { return d == other } -func (d Duration) humanString(value int, unit string) string { +func (Duration) humanString(value int, unit string) string { return fmt.Sprintf("%d %s%s", abs(value), unit, plural(value)) } From 35bf99e4fbc75b4a1d50cb51c811f98f804aa0c9 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Tue, 24 Sep 2024 13:06:43 -0400 Subject: [PATCH 14/22] lints --- configtype/duration.go | 1 - 1 file changed, 1 deletion(-) diff --git a/configtype/duration.go b/configtype/duration.go index 7f54e51e59..6cb22dd86c 100644 --- a/configtype/duration.go +++ b/configtype/duration.go @@ -19,7 +19,6 @@ var ( baseDurationRegexp = regexp.MustCompile(baseDurationPattern) humanDurationSignsPattern = `ago|from\s+now` - humanDurationSignsRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, humanDurationSignsPattern)) humanDurationUnitsPattern = `nanoseconds?|ns|microseconds?|us|µs|μs|milliseconds?|ms|seconds?|s|minutes?|m|hours?|h|days?|d|months?|M|years?|Y` humanDurationUnitsRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, humanDurationUnitsPattern)) From bdb52db390086bd9a6a3964403edd393fd7c11ed Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Wed, 25 Sep 2024 14:43:08 -0400 Subject: [PATCH 15/22] AsTime Co-authored-by: Erez Rokah --- configtype/time.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configtype/time.go b/configtype/time.go index d09824e231..f8c981833b 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -113,7 +113,7 @@ func (t Time) MarshalJSON() ([]byte, error) { } } -func (t Time) Time(now time.Time) time.Time { +func (t Time) AsTime(now time.Time) time.Time { switch t.typ { case timeTypeFixed: return t.time From bdfd108f532ed49b7c48c6a698041b046c52214c Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Wed, 25 Sep 2024 14:46:38 -0400 Subject: [PATCH 16/22] fix test --- configtype/time_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configtype/time_test.go b/configtype/time_test.go index 25d7acdea2..b41b404306 100644 --- a/configtype/time_test.go +++ b/configtype/time_test.go @@ -41,7 +41,7 @@ func TestTime(t *testing.T) { if err != nil { t.Fatalf("error calling Unmarshal(%q): %v", tc.give, err) } - computedTime := d.Time(now) + computedTime := d.AsTime(now) if !computedTime.Equal(tc.want) { t.Errorf("Unmarshal(%q) = %v, want %v", tc.give, computedTime, tc.want) } From 263750b142816f645bb9b548586602394e87cbc2 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Thu, 26 Sep 2024 10:25:21 -0400 Subject: [PATCH 17/22] implement requested changes --- configtype/duration.go | 36 +++-------------- configtype/duration_test.go | 6 +-- configtype/time.go | 79 ++++++------------------------------- configtype/time_test.go | 38 +----------------- configtype/util_test.go | 11 ++++++ 5 files changed, 34 insertions(+), 136 deletions(-) diff --git a/configtype/duration.go b/configtype/duration.go index 6cb22dd86c..838c9d15fe 100644 --- a/configtype/duration.go +++ b/configtype/duration.go @@ -5,7 +5,6 @@ import ( "fmt" "regexp" "strconv" - "strings" "time" "github.com/invopop/jsonschema" @@ -38,6 +37,8 @@ var ( // the spec can be extended in the future to support other types of durations // (e.g. a duration that is specified in days). type Duration struct { + input string + relative bool sign int duration time.Duration @@ -48,6 +49,7 @@ type Duration struct { func NewDuration(d time.Duration) Duration { return Duration{ + input: d.String(), sign: 1, duration: d, } @@ -55,6 +57,7 @@ func NewDuration(d time.Duration) Duration { func ParseDuration(s string) (Duration, error) { var d Duration + d.input = s var inValue bool var value int64 @@ -193,35 +196,6 @@ func (d Duration) Equal(other Duration) bool { return d == other } -func (Duration) humanString(value int, unit string) string { - return fmt.Sprintf("%d %s%s", abs(value), unit, plural(value)) -} - func (d Duration) String() string { - var parts []string - if d.years != 0 { - parts = append(parts, d.humanString(d.years, "year")) - } - if d.months != 0 { - parts = append(parts, d.humanString(d.months, "month")) - } - if d.days != 0 { - parts = append(parts, d.humanString(d.days, "day")) - } - - if len(parts) == 0 { - return (d.duration * time.Duration(d.sign)).String() - } - - if d.duration != 0 { - parts = append(parts, d.duration.String()) - } - - if d.sign == -1 { - parts = append(parts, "ago") - } else if d.relative { - parts = append(parts, "from now") - } - - return strings.Join(parts, " ") + return d.input } diff --git a/configtype/duration_test.go b/configtype/duration_test.go index ca87e23aaf..b5c47bca9c 100644 --- a/configtype/duration_test.go +++ b/configtype/duration_test.go @@ -48,11 +48,11 @@ func TestDuration_JSONMarshal(t *testing.T) { {"1ns", "1ns"}, {"20s", "20s"}, {"-50m30s", "-50m30s"}, - {"25 minute", "25m0s"}, - {"50 minutes", "50m0s"}, + {"25 minutes", "25 minutes"}, + {"50 minutes", "50 minutes"}, {"10 years ago", "10 years ago"}, {"1 month from now", "1 month from now"}, - {"1 month from now", "1 month from now"}, + {"1 month from now", "1 month from now"}, } for _, tc := range cases { var d configtype.Duration diff --git a/configtype/time.go b/configtype/time.go index f8c981833b..b0c1db208d 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -9,50 +9,31 @@ import ( "github.com/invopop/jsonschema" ) -type timeType int - -const ( - timeTypeZero timeType = iota - timeTypeFixed - timeTypeRelative -) - // Time is a wrapper around time.Time that should be used in config // when a time type is required. We wrap the time.Time type so that // the spec can be extended in the future to support other types of times type Time struct { - typ timeType + input string time time.Time - duration Duration -} - -func NewTime(t time.Time) Time { - return Time{ - typ: timeTypeFixed, - time: t, - } + duration *Duration } func ParseTime(s string) (Time, error) { var t Time + t.input = s + var err error switch { case timeNowRegexp.MatchString(s): - t.typ = timeTypeRelative - t.duration = NewDuration(0) + t.duration = new(Duration) + *t.duration = NewDuration(0) case timeRFC3339Regexp.MatchString(s): t.time, err = time.Parse(time.RFC3339, s) - if t.time.IsZero() { - t.typ = timeTypeZero - } else { - t.typ = timeTypeFixed - } case dateRegexp.MatchString(s): - t.typ = timeTypeFixed t.time, err = time.Parse(time.DateOnly, s) case baseDurationRegexp.MatchString(s), humanRelativeDurationRegexp.MatchString(s): - t.typ = timeTypeRelative - t.duration, err = ParseDuration(s) + t.duration = new(Duration) + *t.duration, err = ParseDuration(s) default: return t, fmt.Errorf("invalid time format: %s", s) } @@ -103,21 +84,11 @@ func (t *Time) UnmarshalJSON(b []byte) error { } func (t Time) MarshalJSON() ([]byte, error) { - switch t.typ { - case timeTypeFixed: - return json.Marshal(t.time) - case timeTypeRelative: - return json.Marshal(t.duration) - default: - return json.Marshal(time.Time{}) - } + return json.Marshal(t.input) } func (t Time) AsTime(now time.Time) time.Time { - switch t.typ { - case timeTypeFixed: - return t.time - case timeTypeRelative: + if t.duration != nil { sign := t.duration.sign return now.Add( t.duration.duration*time.Duration(sign), @@ -126,39 +97,15 @@ func (t Time) AsTime(now time.Time) time.Time { t.duration.months*sign, t.duration.days*sign, ) - default: - return time.Time{} } -} -func (t Time) IsRelative() bool { - return t.typ == timeTypeRelative + return t.time } func (t Time) IsZero() bool { - return t.typ == timeTypeZero -} - -func (t Time) IsFixed() bool { - return t.typ == timeTypeFixed -} - -// Equal compares two Time structs. Note that relative and fixed times are never equal -func (t Time) Equal(other Time) bool { - return t.typ == other.typ && t.time.Equal(other.time) && t.duration == other.duration + return t.duration == nil && t.time.IsZero() } func (t Time) String() string { - switch t.typ { - case timeTypeFixed: - return t.time.String() - case timeTypeRelative: - if t.duration.Duration() == 0 { - return "now" - } - - return t.duration.String() - default: - return time.Time{}.String() - } + return t.input } diff --git a/configtype/time_test.go b/configtype/time_test.go index b41b404306..5467051926 100644 --- a/configtype/time_test.go +++ b/configtype/time_test.go @@ -8,7 +8,6 @@ import ( "github.com/cloudquery/plugin-sdk/v4/configtype" "github.com/cloudquery/plugin-sdk/v4/plugin" - "github.com/google/go-cmp/cmp" "github.com/invopop/jsonschema" "github.com/stretchr/testify/require" ) @@ -30,6 +29,7 @@ func TestTime(t *testing.T) { {"2021-09-01T00:00:00.123+02:00", time.Date(2021, 9, 1, 0, 0, 0, 123000000, time.FixedZone("CET", 2*60*60))}, {"2021-09-01T00:00:00.123456+02:00", time.Date(2021, 9, 1, 0, 0, 0, 123456000, time.FixedZone("CET", 2*60*60))}, {"2021-09-01T00:00:00.123456789+02:00", time.Date(2021, 9, 1, 0, 0, 0, 123456789, time.FixedZone("CET", 2*60*60))}, + {"2024-09-26T10:18:07.37338-04:00", time.Date(2024, 9, 26, 10, 18, 7, 373380000, time.FixedZone("EDT", -4*60*60))}, {"2021-09-01", time.Date(2021, 9, 1, 0, 0, 0, 0, time.UTC)}, {"now", now}, {"2 days from now", now.AddDate(0, 0, 2)}, @@ -48,40 +48,6 @@ func TestTime(t *testing.T) { } } -func TestTime_Comparability(t *testing.T) { - tim1 := time.Now() - tim2 := tim1.Add(1 * time.Second) - - var zeroTime configtype.Time - - cases := []struct { - give configtype.Time - compare configtype.Time - equal bool - }{ - {must(configtype.ParseTime("now")), must(configtype.ParseTime("now")), true}, - {must(configtype.ParseTime("now")), must(configtype.ParseTime("1 second from now")), false}, - {configtype.NewTime(tim1), configtype.NewTime(tim1), true}, - {configtype.NewTime(tim1), configtype.NewTime(tim2), false}, - // relative and fixed times are never equal - {configtype.NewTime(tim1), must(configtype.ParseTime("now")), false}, - {zeroTime, must(configtype.ParseTime("now")), false}, - {zeroTime, zeroTime, true}, - } - for _, tc := range cases { - if (tc.give == tc.compare) != tc.equal { - t.Errorf("comparing %v and %v should be %v", tc.give, tc.compare, tc.equal) - } - - diff := cmp.Diff(tc.give, tc.compare) - if tc.equal && diff != "" { - t.Errorf("comparing %v and %v should be equal, but diff is %s", tc.give, tc.compare, diff) - } else if !tc.equal && diff == "" { - t.Errorf("comparing %v and %v should not be equal, but diff is empty", tc.give, tc.compare) - } - } -} - func TestTime_JSONSchema(t *testing.T) { sc := (&jsonschema.Reflector{RequiredFromJSONSchemaTags: true}).Reflect(configtype.Time{}) schema, err := json.MarshalIndent(sc, "", " ") @@ -153,7 +119,7 @@ func TestTime_JSONSchema(t *testing.T) { Spec: string(durationData), }) - tim := configtype.NewTime(now.Add(time.Duration(val))) + tim := must(configtype.ParseTime(must(marshalString(now.Add(time.Duration(val)))))) timeData, err := tim.MarshalJSON() require.NoError(t, err) diff --git a/configtype/util_test.go b/configtype/util_test.go index dbad2eee77..6d35298944 100644 --- a/configtype/util_test.go +++ b/configtype/util_test.go @@ -1,8 +1,19 @@ package configtype_test +import "encoding/json" + func must[T any](v T, err error) T { if err != nil { panic(err) } return v } + +func marshalString[T any](v T) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + + return string(b)[1 : len(b)-1], nil +} From 465e5d7a68e66cbcef68f6458717a41ee8181f8c Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Thu, 26 Sep 2024 10:30:15 -0400 Subject: [PATCH 18/22] remove unused --- configtype/util.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/configtype/util.go b/configtype/util.go index 0a71d33284..0cd4444223 100644 --- a/configtype/util.go +++ b/configtype/util.go @@ -5,17 +5,3 @@ import "strings" func patternCases(cases ...string) string { return "(" + strings.Join(cases, "|") + ")" } - -func abs(n int) int { - if n < 0 { - return -n - } - return n -} - -func plural(n int) string { - if n != 1 { - return "s" - } - return "" -} From e972602a3f195d494153c457d345267288d743a9 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 30 Sep 2024 13:57:04 -0400 Subject: [PATCH 19/22] dont modify Duration --- configtype/duration.go | 162 ++---------------------------------- configtype/duration_test.go | 32 ------- configtype/time.go | 162 ++++++++++++++++++++++++++++++++++-- 3 files changed, 165 insertions(+), 191 deletions(-) diff --git a/configtype/duration.go b/configtype/duration.go index 838c9d15fe..dc8789ce05 100644 --- a/configtype/duration.go +++ b/configtype/duration.go @@ -2,164 +2,29 @@ package configtype import ( "encoding/json" - "fmt" - "regexp" - "strconv" "time" "github.com/invopop/jsonschema" ) -var ( - numberRegexp = regexp.MustCompile(`^[0-9]+$`) - - baseDurationSegmentPattern = `[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+` // copied from time.ParseDuration - baseDurationPattern = fmt.Sprintf(`^%s$`, baseDurationSegmentPattern) - baseDurationRegexp = regexp.MustCompile(baseDurationPattern) - - humanDurationSignsPattern = `ago|from\s+now` - - humanDurationUnitsPattern = `nanoseconds?|ns|microseconds?|us|µs|μs|milliseconds?|ms|seconds?|s|minutes?|m|hours?|h|days?|d|months?|M|years?|Y` - humanDurationUnitsRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, humanDurationUnitsPattern)) - - humanDurationSegmentPattern = fmt.Sprintf(`(([0-9]+\s+(%[1]s)|%[2]s))`, humanDurationUnitsPattern, baseDurationSegmentPattern) - - humanDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*$`, humanDurationSegmentPattern) - - humanRelativeDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*\s+(%[2]s)$`, humanDurationSegmentPattern, humanDurationSignsPattern) - humanRelativeDurationRegexp = regexp.MustCompile(humanRelativeDurationPattern) - - whitespaceRegexp = regexp.MustCompile(`\s+`) -) - // Duration is a wrapper around time.Duration that should be used in config // when a duration type is required. We wrap the time.Duration type so that // the spec can be extended in the future to support other types of durations // (e.g. a duration that is specified in days). type Duration struct { - input string - - relative bool - sign int duration time.Duration - days int - months int - years int } func NewDuration(d time.Duration) Duration { return Duration{ - input: d.String(), - sign: 1, duration: d, } } -func ParseDuration(s string) (Duration, error) { - var d Duration - d.input = s - - var inValue bool - var value int64 - - var inSign bool - - parts := whitespaceRegexp.Split(s, -1) - - var err error - - for _, part := range parts { - switch { - case inSign: - if part != "now" { - return Duration{}, fmt.Errorf("invalid duration format: invalid sign specifier: %q", part) - } - - d.sign = 1 - inSign = false - case inValue: - if !humanDurationUnitsRegex.MatchString(part) { - return Duration{}, fmt.Errorf("invalid duration format: invalid unit specifier: %q", part) - } - - err = d.addUnit(part, value) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: %w", err) - } - - value = 0 - inValue = false - case part == "ago": - if d.sign != 0 { - return Duration{}, fmt.Errorf("invalid duration format: more than one sign specifier") - } - - d.sign = -1 - case part == "from": - if d.sign != 0 { - return Duration{}, fmt.Errorf("invalid duration format: more than one sign specifier") - } - - inSign = true - case numberRegexp.MatchString(part): - value, err = strconv.ParseInt(part, 10, 64) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) - } - - inValue = true - case baseDurationRegexp.MatchString(part): - duration, err := time.ParseDuration(part) - if err != nil { - return Duration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) - } - - d.duration += duration - default: - return Duration{}, fmt.Errorf("invalid duration format: invalid value: %q", part) - } - } - - d.relative = d.sign != 0 - - if !d.relative { - d.sign = 1 - } - - return d, nil -} - -func (d *Duration) addUnit(unit string, number int64) error { - switch unit { - case "nanosecond", "nanoseconds", "ns": - d.duration += time.Nanosecond * time.Duration(number) - case "microsecond", "microseconds", "us", "μs", "µs": - d.duration += time.Microsecond * time.Duration(number) - case "millisecond", "milliseconds": - d.duration += time.Millisecond * time.Duration(number) - case "second", "seconds": - d.duration += time.Second * time.Duration(number) - case "minute", "minutes": - d.duration += time.Minute * time.Duration(number) - case "hour", "hours": - d.duration += time.Hour * time.Duration(number) - case "day", "days": - d.days += int(number) - case "month", "months": - d.months += int(number) - case "year", "years": - d.years += int(number) - default: - return fmt.Errorf("invalid unit: %q", unit) - } - - return nil -} - func (Duration) JSONSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "string", - Pattern: patternCases(baseDurationPattern, humanDurationPattern, humanRelativeDurationPattern), + Pattern: `^[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+$`, // copied from time.ParseDuration Title: "CloudQuery configtype.Duration", } } @@ -169,33 +34,22 @@ func (d *Duration) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(b, &s); err != nil { return err } - - duration, err := ParseDuration(s) + duration, err := time.ParseDuration(s) if err != nil { return err } - - *d = duration + *d = Duration{duration: duration} return nil } -func (d Duration) MarshalJSON() ([]byte, error) { - return json.Marshal(d.String()) +func (d *Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.duration.String()) } -func (d Duration) Duration() time.Duration { - duration := d.duration - duration += time.Duration(d.days) * 24 * time.Hour - duration += time.Duration(d.months) * 30 * 24 * time.Hour - duration += time.Duration(d.years) * 365 * 24 * time.Hour - duration *= time.Duration(d.sign) - return duration +func (d *Duration) Duration() time.Duration { + return d.duration } func (d Duration) Equal(other Duration) bool { - return d == other -} - -func (d Duration) String() string { - return d.input + return d.duration == other.duration } diff --git a/configtype/duration_test.go b/configtype/duration_test.go index b5c47bca9c..fadd3d1104 100644 --- a/configtype/duration_test.go +++ b/configtype/duration_test.go @@ -21,12 +21,6 @@ func TestDuration(t *testing.T) { {"1ns", 1 * time.Nanosecond}, {"20s", 20 * time.Second}, {"-50m30s", -50*time.Minute - 30*time.Second}, - {"25 minute", 25 * time.Minute}, - {"50 minutes", 50 * time.Minute}, - {"10 years ago", -10 * 365 * 24 * time.Hour}, - {"1 month from now", 30 * 24 * time.Hour}, - {"1 month from now", 30 * 24 * time.Hour}, - {"1 year 2 month 3 days 4 hours 5 minutes 6 seconds from now", (365+60+3)*24*time.Hour + 4*time.Hour + 5*time.Minute + 6*time.Second}, } for _, tc := range cases { var d configtype.Duration @@ -40,32 +34,6 @@ func TestDuration(t *testing.T) { } } -func TestDuration_JSONMarshal(t *testing.T) { - cases := []struct { - give string - want string - }{ - {"1ns", "1ns"}, - {"20s", "20s"}, - {"-50m30s", "-50m30s"}, - {"25 minutes", "25 minutes"}, - {"50 minutes", "50 minutes"}, - {"10 years ago", "10 years ago"}, - {"1 month from now", "1 month from now"}, - {"1 month from now", "1 month from now"}, - } - for _, tc := range cases { - var d configtype.Duration - err := json.Unmarshal([]byte(`"`+tc.give+`"`), &d) - if err != nil { - t.Fatalf("error calling Unmarshal(%q): %v", tc.give, err) - } - if d.String() != tc.want { - t.Errorf("String(%q) = %q, want %v", tc.give, d.String(), tc.want) - } - } -} - func TestComparability(t *testing.T) { cases := []struct { give configtype.Duration diff --git a/configtype/time.go b/configtype/time.go index b0c1db208d..0d3b94ac0e 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -4,18 +4,41 @@ import ( "encoding/json" "fmt" "regexp" + "strconv" "time" "github.com/invopop/jsonschema" ) +var ( + numberRegexp = regexp.MustCompile(`^[0-9]+$`) + + baseDurationSegmentPattern = `[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+` // copied from time.ParseDuration + baseDurationPattern = fmt.Sprintf(`^%s$`, baseDurationSegmentPattern) + baseDurationRegexp = regexp.MustCompile(baseDurationPattern) + + humanDurationSignsPattern = `ago|from\s+now` + + humanDurationUnitsPattern = `nanoseconds?|ns|microseconds?|us|µs|μs|milliseconds?|ms|seconds?|s|minutes?|m|hours?|h|days?|d|months?|M|years?|Y` + humanDurationUnitsRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, humanDurationUnitsPattern)) + + humanDurationSegmentPattern = fmt.Sprintf(`(([0-9]+\s+(%[1]s)|%[2]s))`, humanDurationUnitsPattern, baseDurationSegmentPattern) + + humanDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*$`, humanDurationSegmentPattern) + + humanRelativeDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*\s+(%[2]s)$`, humanDurationSegmentPattern, humanDurationSignsPattern) + humanRelativeDurationRegexp = regexp.MustCompile(humanRelativeDurationPattern) + + whitespaceRegexp = regexp.MustCompile(`\s+`) +) + // Time is a wrapper around time.Time that should be used in config // when a time type is required. We wrap the time.Time type so that // the spec can be extended in the future to support other types of times type Time struct { input string time time.Time - duration *Duration + duration *timeDuration } func ParseTime(s string) (Time, error) { @@ -25,15 +48,15 @@ func ParseTime(s string) (Time, error) { var err error switch { case timeNowRegexp.MatchString(s): - t.duration = new(Duration) - *t.duration = NewDuration(0) + t.duration = new(timeDuration) + *t.duration = newTimeDuration(0) case timeRFC3339Regexp.MatchString(s): t.time, err = time.Parse(time.RFC3339, s) case dateRegexp.MatchString(s): t.time, err = time.Parse(time.DateOnly, s) case baseDurationRegexp.MatchString(s), humanRelativeDurationRegexp.MatchString(s): - t.duration = new(Duration) - *t.duration, err = ParseDuration(s) + t.duration = new(timeDuration) + *t.duration, err = parseTimeDuration(s) default: return t, fmt.Errorf("invalid time format: %s", s) } @@ -109,3 +132,132 @@ func (t Time) IsZero() bool { func (t Time) String() string { return t.input } + +type timeDuration struct { + input string + + relative bool + sign int + duration time.Duration + days int + months int + years int +} + +func newTimeDuration(d time.Duration) timeDuration { + return timeDuration{ + input: d.String(), + sign: 1, + duration: d, + } +} + +func parseTimeDuration(s string) (timeDuration, error) { + var d timeDuration + d.input = s + + var inValue bool + var value int64 + + var inSign bool + + parts := whitespaceRegexp.Split(s, -1) + + var err error + + for _, part := range parts { + switch { + case inSign: + if part != "now" { + return timeDuration{}, fmt.Errorf("invalid duration format: invalid sign specifier: %q", part) + } + + d.sign = 1 + inSign = false + case inValue: + if !humanDurationUnitsRegex.MatchString(part) { + return timeDuration{}, fmt.Errorf("invalid duration format: invalid unit specifier: %q", part) + } + + err = d.addUnit(part, value) + if err != nil { + return timeDuration{}, fmt.Errorf("invalid duration format: %w", err) + } + + value = 0 + inValue = false + case part == "ago": + if d.sign != 0 { + return timeDuration{}, fmt.Errorf("invalid duration format: more than one sign specifier") + } + + d.sign = -1 + case part == "from": + if d.sign != 0 { + return timeDuration{}, fmt.Errorf("invalid duration format: more than one sign specifier") + } + + inSign = true + case numberRegexp.MatchString(part): + value, err = strconv.ParseInt(part, 10, 64) + if err != nil { + return timeDuration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) + } + + inValue = true + case baseDurationRegexp.MatchString(part): + duration, err := time.ParseDuration(part) + if err != nil { + return timeDuration{}, fmt.Errorf("invalid duration format: invalid value specifier: %q", part) + } + + d.duration += duration + default: + return timeDuration{}, fmt.Errorf("invalid duration format: invalid value: %q", part) + } + } + + d.relative = d.sign != 0 + + if !d.relative { + d.sign = 1 + } + + return d, nil +} + +func (d *timeDuration) addUnit(unit string, number int64) error { + switch unit { + case "nanosecond", "nanoseconds", "ns": + d.duration += time.Nanosecond * time.Duration(number) + case "microsecond", "microseconds", "us", "μs", "µs": + d.duration += time.Microsecond * time.Duration(number) + case "millisecond", "milliseconds": + d.duration += time.Millisecond * time.Duration(number) + case "second", "seconds": + d.duration += time.Second * time.Duration(number) + case "minute", "minutes": + d.duration += time.Minute * time.Duration(number) + case "hour", "hours": + d.duration += time.Hour * time.Duration(number) + case "day", "days": + d.days += int(number) + case "month", "months": + d.months += int(number) + case "year", "years": + d.years += int(number) + default: + return fmt.Errorf("invalid unit: %q", unit) + } + + return nil +} + +func (d timeDuration) Duration() time.Duration { + duration := d.duration + duration += time.Duration(d.days) * 24 * time.Hour + duration += time.Duration(d.months) * 30 * 24 * time.Hour + duration += time.Duration(d.years) * 365 * 24 * time.Hour + duration *= time.Duration(d.sign) + return duration +} From b51991670f4e37627b332b122c1b12a86db9b97e Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 30 Sep 2024 14:01:22 -0400 Subject: [PATCH 20/22] remove unused --- configtype/time.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/configtype/time.go b/configtype/time.go index 0d3b94ac0e..3fc38ea455 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -24,8 +24,6 @@ var ( humanDurationSegmentPattern = fmt.Sprintf(`(([0-9]+\s+(%[1]s)|%[2]s))`, humanDurationUnitsPattern, baseDurationSegmentPattern) - humanDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*$`, humanDurationSegmentPattern) - humanRelativeDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*\s+(%[2]s)$`, humanDurationSegmentPattern, humanDurationSignsPattern) humanRelativeDurationRegexp = regexp.MustCompile(humanRelativeDurationPattern) From 5768cbccbc9231718476fafb5198baf68c5b0bb9 Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 30 Sep 2024 14:02:22 -0400 Subject: [PATCH 21/22] group vars --- configtype/time.go | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/configtype/time.go b/configtype/time.go index 3fc38ea455..966e1f8d82 100644 --- a/configtype/time.go +++ b/configtype/time.go @@ -10,26 +10,6 @@ import ( "github.com/invopop/jsonschema" ) -var ( - numberRegexp = regexp.MustCompile(`^[0-9]+$`) - - baseDurationSegmentPattern = `[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+` // copied from time.ParseDuration - baseDurationPattern = fmt.Sprintf(`^%s$`, baseDurationSegmentPattern) - baseDurationRegexp = regexp.MustCompile(baseDurationPattern) - - humanDurationSignsPattern = `ago|from\s+now` - - humanDurationUnitsPattern = `nanoseconds?|ns|microseconds?|us|µs|μs|milliseconds?|ms|seconds?|s|minutes?|m|hours?|h|days?|d|months?|M|years?|Y` - humanDurationUnitsRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, humanDurationUnitsPattern)) - - humanDurationSegmentPattern = fmt.Sprintf(`(([0-9]+\s+(%[1]s)|%[2]s))`, humanDurationUnitsPattern, baseDurationSegmentPattern) - - humanRelativeDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*\s+(%[2]s)$`, humanDurationSegmentPattern, humanDurationSignsPattern) - humanRelativeDurationRegexp = regexp.MustCompile(humanRelativeDurationPattern) - - whitespaceRegexp = regexp.MustCompile(`\s+`) -) - // Time is a wrapper around time.Time that should be used in config // when a time type is required. We wrap the time.Time type so that // the spec can be extended in the future to support other types of times @@ -72,6 +52,24 @@ var ( datePattern = `^\d{4}-\d{2}-\d{2}$` dateRegexp = regexp.MustCompile(datePattern) + numberRegexp = regexp.MustCompile(`^[0-9]+$`) + + baseDurationSegmentPattern = `[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+` // copied from time.ParseDuration + baseDurationPattern = fmt.Sprintf(`^%s$`, baseDurationSegmentPattern) + baseDurationRegexp = regexp.MustCompile(baseDurationPattern) + + humanDurationSignsPattern = `ago|from\s+now` + + humanDurationUnitsPattern = `nanoseconds?|ns|microseconds?|us|µs|μs|milliseconds?|ms|seconds?|s|minutes?|m|hours?|h|days?|d|months?|M|years?|Y` + humanDurationUnitsRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, humanDurationUnitsPattern)) + + humanDurationSegmentPattern = fmt.Sprintf(`(([0-9]+\s+(%[1]s)|%[2]s))`, humanDurationUnitsPattern, baseDurationSegmentPattern) + + humanRelativeDurationPattern = fmt.Sprintf(`^%[1]s(\s+%[1]s)*\s+(%[2]s)$`, humanDurationSegmentPattern, humanDurationSignsPattern) + humanRelativeDurationRegexp = regexp.MustCompile(humanRelativeDurationPattern) + + whitespaceRegexp = regexp.MustCompile(`\s+`) + timePattern = patternCases( timeNowPattern, timeRFC3339Pattern, From 1a5ae0a5ae982e11168e7ad49dcb613ca3ab105c Mon Sep 17 00:00:00 2001 From: James Riley Wilburn Date: Mon, 30 Sep 2024 14:33:45 -0400 Subject: [PATCH 22/22] add clarifying tests --- configtype/time_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/configtype/time_test.go b/configtype/time_test.go index 5467051926..245b350d30 100644 --- a/configtype/time_test.go +++ b/configtype/time_test.go @@ -34,6 +34,8 @@ func TestTime(t *testing.T) { {"now", now}, {"2 days from now", now.AddDate(0, 0, 2)}, {"5 months ago", now.AddDate(0, -5, 0)}, + {"10 months 3 days 4h20m from now", now.AddDate(0, 10, 3).Add(4 * time.Hour).Add(20 * time.Minute)}, + {"10 months 3 days 4 hours 20 minutes from now", now.AddDate(0, 10, 3).Add(4 * time.Hour).Add(20 * time.Minute)}, } for _, tc := range cases { var d configtype.Time @@ -98,6 +100,11 @@ func TestTime_JSONSchema(t *testing.T) { Err: false, Spec: `"10 months 3 days 4h20m from now"`, }, + { + Name: "complex relative duration human", + Err: false, + Spec: `"10 months 3 days 4 hours 20 minutes from now"`, + }, }, func() []testCase { rnd := rand.New(rand.NewSource(time.Now().UnixNano()))